mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat(core): add internal show route
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::http::server::common::error::AppError;
|
||||||
|
use crate::http::server::common::query::parse_query;
|
||||||
|
use crate::http::server::common::response::{empty_body, BoxedBody};
|
||||||
|
use crate::http::server::AppState;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::{Request, Response, StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Configuration for application-internal endpoints.
|
||||||
|
pub struct InternalConfig {
|
||||||
|
/// Token required by the show route.
|
||||||
|
pub show_token: String,
|
||||||
|
|
||||||
|
/// Channel on which the server emits internal events to the application.
|
||||||
|
pub event_tx: mpsc::Sender<InternalEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events emitted by application-internal endpoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum InternalEvent {
|
||||||
|
/// Another application instance requested the running application to show itself.
|
||||||
|
Show {
|
||||||
|
/// Command-line arguments forwarded by the other application instance.
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct InternalState {
|
||||||
|
show_token: String,
|
||||||
|
event_tx: mpsc::Sender<InternalEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalState {
|
||||||
|
pub(crate) fn new(config: InternalConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
show_token: config.show_token,
|
||||||
|
event_tx: config.event_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct ShowRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn show(
|
||||||
|
req: Request<Incoming>,
|
||||||
|
state: AppState,
|
||||||
|
) -> Result<Response<BoxedBody>, AppError> {
|
||||||
|
let Some(internal) = &state.internal else {
|
||||||
|
return Err(AppError::Status(StatusCode::NOT_FOUND));
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = parse_query(req.uri().query());
|
||||||
|
if query.get("token") != Some(&internal.show_token) {
|
||||||
|
return Err(AppError::Message(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Invalid token".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = req.into_body().collect().await?.to_bytes();
|
||||||
|
let payload = if body.is_empty() {
|
||||||
|
ShowRequest::default()
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice::<ShowRequest>(&body).map_err(|err| {
|
||||||
|
tracing::warn!("Failed to parse show request body: {err:#}");
|
||||||
|
AppError::BadRequest("Invalid JSON body".to_string())
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = internal
|
||||||
|
.event_tx
|
||||||
|
.send(InternalEvent::Show { args: payload.args })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut response = Response::new(empty_body());
|
||||||
|
*response.status_mut() = StatusCode::OK;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod internal;
|
||||||
pub mod v2;
|
pub mod v2;
|
||||||
pub mod v3;
|
pub mod v3;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
use crate::crypto::cert::public_key_from_cert_der;
|
use crate::crypto::cert::public_key_from_cert_der;
|
||||||
|
use crate::http::server::internal::{InternalConfig, InternalState};
|
||||||
use crate::http::server::v2::ServerEventV2;
|
use crate::http::server::v2::ServerEventV2;
|
||||||
use crate::http::server::web::WebSendConfig;
|
use crate::http::server::web::WebSendConfig;
|
||||||
use crate::http::state::ClientInfo;
|
use crate::http::state::ClientInfo;
|
||||||
@@ -59,6 +61,9 @@ pub struct AppState {
|
|||||||
/// State for serving web pages.
|
/// State for serving web pages.
|
||||||
web: Option<Arc<WebPageState>>,
|
web: Option<Arc<WebPageState>>,
|
||||||
|
|
||||||
|
/// State for application-internal endpoints.
|
||||||
|
internal: Option<Arc<InternalState>>,
|
||||||
|
|
||||||
/// Maps client identifiers to nonces that have been received from remote.
|
/// Maps client identifiers to nonces that have been received from remote.
|
||||||
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||||
|
|
||||||
@@ -72,6 +77,7 @@ pub struct AppState {
|
|||||||
impl AppState {
|
impl AppState {
|
||||||
fn new(
|
fn new(
|
||||||
info: Arc<Mutex<ClientInfo>>,
|
info: Arc<Mutex<ClientInfo>>,
|
||||||
|
internal_config: Option<InternalConfig>,
|
||||||
v2_config: Option<ServerConfigV2>,
|
v2_config: Option<ServerConfigV2>,
|
||||||
web_send_config: Option<WebSendConfig>,
|
web_send_config: Option<WebSendConfig>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -85,10 +91,12 @@ impl AppState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config)));
|
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config)));
|
||||||
|
let internal = internal_config.map(|config| Arc::new(InternalState::new(config)));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
info,
|
info,
|
||||||
web,
|
web,
|
||||||
|
internal,
|
||||||
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||||
NonZeroUsize::new(200).unwrap(),
|
NonZeroUsize::new(200).unwrap(),
|
||||||
))),
|
))),
|
||||||
@@ -105,6 +113,7 @@ pub async fn start_with_port(
|
|||||||
port: u16,
|
port: u16,
|
||||||
tls_config: Option<TlsConfig>,
|
tls_config: Option<TlsConfig>,
|
||||||
info: ClientInfo,
|
info: ClientInfo,
|
||||||
|
internal_config: Option<InternalConfig>,
|
||||||
v2_config: Option<ServerConfigV2>,
|
v2_config: Option<ServerConfigV2>,
|
||||||
web_send_config: Option<WebSendConfig>,
|
web_send_config: Option<WebSendConfig>,
|
||||||
stop_rx: oneshot::Receiver<()>,
|
stop_rx: oneshot::Receiver<()>,
|
||||||
@@ -112,7 +121,7 @@ pub async fn start_with_port(
|
|||||||
let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
|
let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
|
||||||
let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port);
|
let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port);
|
||||||
let info = Arc::new(Mutex::new(info));
|
let info = Arc::new(Mutex::new(info));
|
||||||
let state = AppState::new(info.clone(), v2_config, web_send_config);
|
let state = AppState::new(info.clone(), internal_config, v2_config, web_send_config);
|
||||||
|
|
||||||
let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?;
|
let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?;
|
||||||
let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) {
|
let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) {
|
||||||
@@ -365,6 +374,8 @@ async fn handle_request_inner(mut req: Request<Incoming>) -> Result<Response<Box
|
|||||||
|
|
||||||
v2::cancel(req, state).await
|
v2::cancel(req, state).await
|
||||||
}
|
}
|
||||||
|
// The versioned path is retained for compatibility, but this endpoint is internal.
|
||||||
|
(&Method::POST, "/api/localsend/v2/show") => internal::show(req, state).await,
|
||||||
(&Method::POST, "/api/localsend/v3/nonce") => {
|
(&Method::POST, "/api/localsend/v3/nonce") => {
|
||||||
Ok(v3::nonce_exchange(req.into_body(), state, client_info)
|
Ok(v3::nonce_exchange(req.into_body(), state, client_info)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::crypto::token;
|
|||||||
use crate::http::client::LsHttpClientV3;
|
use crate::http::client::LsHttpClientV3;
|
||||||
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
|
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
|
||||||
use crate::http::server::common::save::FileUploadTarget;
|
use crate::http::server::common::save::FileUploadTarget;
|
||||||
|
use crate::http::server::internal::{InternalConfig, InternalEvent};
|
||||||
use crate::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2};
|
use crate::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2};
|
||||||
use crate::http::server::{ServerConfigV2, TlsConfig};
|
use crate::http::server::{ServerConfigV2, TlsConfig};
|
||||||
use crate::model::discovery::DeviceType;
|
use crate::model::discovery::DeviceType;
|
||||||
@@ -140,8 +141,19 @@ async fn server_test() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (stop_tx, stop_rx) = oneshot::channel::<()>();
|
let (stop_tx, stop_rx) = oneshot::channel::<()>();
|
||||||
|
let (internal_event_tx, mut internal_event_rx) = mpsc::channel::<InternalEvent>(16);
|
||||||
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
|
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = internal_event_rx.recv().await {
|
||||||
|
match event {
|
||||||
|
InternalEvent::Show { args } => {
|
||||||
|
tracing::info!("Show application with args: {args:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = event_rx.recv().await {
|
while let Some(event) = event_rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
@@ -202,6 +214,10 @@ async fn server_test() -> Result<()> {
|
|||||||
private_key: PRIVATE_KEY.to_string(),
|
private_key: PRIVATE_KEY.to_string(),
|
||||||
}),
|
}),
|
||||||
client_info,
|
client_info,
|
||||||
|
Some(InternalConfig {
|
||||||
|
show_token: "show-token".to_string(),
|
||||||
|
event_tx: internal_event_tx,
|
||||||
|
}),
|
||||||
Some(ServerConfigV2 {
|
Some(ServerConfigV2 {
|
||||||
pin: None,
|
pin: None,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#![cfg(feature = "http")]
|
||||||
|
|
||||||
|
use localsend::http::server::internal::{InternalConfig, InternalEvent};
|
||||||
|
use localsend::http::server::start_with_port;
|
||||||
|
use localsend::http::state::ClientInfo;
|
||||||
|
use std::sync::atomic::{AtomicU16, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
port: u16,
|
||||||
|
show_args: Arc<Mutex<Vec<Vec<String>>>>,
|
||||||
|
_stop_tx: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_test_server(internal_enabled: bool) -> TestServer {
|
||||||
|
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||||
|
let port = free_port();
|
||||||
|
let show_args = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (event_tx, mut event_rx) = mpsc::channel::<InternalEvent>(16);
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let show_args = show_args.clone();
|
||||||
|
async move {
|
||||||
|
while let Some(event) = event_rx.recv().await {
|
||||||
|
match event {
|
||||||
|
InternalEvent::Show { args } => show_args.lock().await.push(args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let internal_config = internal_enabled.then_some(InternalConfig {
|
||||||
|
show_token: "show-token".to_string(),
|
||||||
|
event_tx,
|
||||||
|
});
|
||||||
|
let (stop_tx, stop_rx) = oneshot::channel::<()>();
|
||||||
|
|
||||||
|
start_with_port(
|
||||||
|
port,
|
||||||
|
None,
|
||||||
|
ClientInfo {
|
||||||
|
alias: "Test Server".to_string(),
|
||||||
|
version: "2.1".to_string(),
|
||||||
|
device_model: Some("Rust".to_string()),
|
||||||
|
device_type: None,
|
||||||
|
token: "server-fingerprint".to_string(),
|
||||||
|
},
|
||||||
|
internal_config,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
stop_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start server");
|
||||||
|
|
||||||
|
wait_until_reachable(port).await;
|
||||||
|
|
||||||
|
TestServer {
|
||||||
|
port,
|
||||||
|
show_args,
|
||||||
|
_stop_tx: stop_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free_port() -> u16 {
|
||||||
|
static PORT_COUNTER: AtomicU16 = AtomicU16::new(42551);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_reachable(port: u16) {
|
||||||
|
for _ in 0..100 {
|
||||||
|
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
panic!("Server did not become reachable on port {port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_show() {
|
||||||
|
let server = start_test_server(true).await;
|
||||||
|
|
||||||
|
let response = localsend::reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"http://127.0.0.1:{}/api/localsend/v2/show?token=show-token",
|
||||||
|
server.port
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"args": ["file-a", "file-b"]}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 200);
|
||||||
|
assert_eq!(
|
||||||
|
*server.show_args.lock().await,
|
||||||
|
vec![vec!["file-a".to_string(), "file-b".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_show_with_empty_body() {
|
||||||
|
let server = start_test_server(true).await;
|
||||||
|
|
||||||
|
let response = localsend::reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"http://127.0.0.1:{}/api/localsend/v2/show?token=show-token",
|
||||||
|
server.port
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 200);
|
||||||
|
assert_eq!(*server.show_args.lock().await, vec![Vec::<String>::new()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_show_with_invalid_token() {
|
||||||
|
let server = start_test_server(true).await;
|
||||||
|
|
||||||
|
let response = localsend::reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"http://127.0.0.1:{}/api/localsend/v2/show?token=wrong-token",
|
||||||
|
server.port
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"args": ["file-a"]}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 403);
|
||||||
|
assert!(server.show_args.lock().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_show_disabled() {
|
||||||
|
let server = start_test_server(false).await;
|
||||||
|
|
||||||
|
let response = localsend::reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"http://127.0.0.1:{}/api/localsend/v2/show?token=show-token",
|
||||||
|
server.port
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 404);
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ async fn start_test_server(
|
|||||||
device_type: None,
|
device_type: None,
|
||||||
token: "server-fingerprint".to_string(),
|
token: "server-fingerprint".to_string(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
Some(ServerConfigV2 { pin, event_tx }),
|
Some(ServerConfigV2 { pin, event_tx }),
|
||||||
None,
|
None,
|
||||||
stop_rx,
|
stop_rx,
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ async fn start_test_server(
|
|||||||
device_type: None,
|
device_type: None,
|
||||||
token: "server-fingerprint".to_string(),
|
token: "server-fingerprint".to_string(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
Some(ServerConfigV2 {
|
Some(ServerConfigV2 {
|
||||||
pin: None,
|
pin: None,
|
||||||
event_tx: v2_event_tx,
|
event_tx: v2_event_tx,
|
||||||
|
|||||||
Reference in New Issue
Block a user