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 internal;
|
||||
pub mod v2;
|
||||
pub mod v3;
|
||||
pub mod web;
|
||||
|
||||
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::web::WebSendConfig;
|
||||
use crate::http::state::ClientInfo;
|
||||
@@ -59,6 +61,9 @@ pub struct AppState {
|
||||
/// State for serving web pages.
|
||||
web: Option<Arc<WebPageState>>,
|
||||
|
||||
/// State for application-internal endpoints.
|
||||
internal: Option<Arc<InternalState>>,
|
||||
|
||||
/// Maps client identifiers to nonces that have been received from remote.
|
||||
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||
|
||||
@@ -72,6 +77,7 @@ pub struct AppState {
|
||||
impl AppState {
|
||||
fn new(
|
||||
info: Arc<Mutex<ClientInfo>>,
|
||||
internal_config: Option<InternalConfig>,
|
||||
v2_config: Option<ServerConfigV2>,
|
||||
web_send_config: Option<WebSendConfig>,
|
||||
) -> Self {
|
||||
@@ -85,10 +91,12 @@ impl AppState {
|
||||
});
|
||||
|
||||
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config)));
|
||||
let internal = internal_config.map(|config| Arc::new(InternalState::new(config)));
|
||||
|
||||
Self {
|
||||
info,
|
||||
web,
|
||||
internal,
|
||||
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(200).unwrap(),
|
||||
))),
|
||||
@@ -105,6 +113,7 @@ pub async fn start_with_port(
|
||||
port: u16,
|
||||
tls_config: Option<TlsConfig>,
|
||||
info: ClientInfo,
|
||||
internal_config: Option<InternalConfig>,
|
||||
v2_config: Option<ServerConfigV2>,
|
||||
web_send_config: Option<WebSendConfig>,
|
||||
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 ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port);
|
||||
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 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
|
||||
}
|
||||
// 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") => {
|
||||
Ok(v3::nonce_exchange(req.into_body(), state, client_info)
|
||||
.await?
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::crypto::token;
|
||||
use crate::http::client::LsHttpClientV3;
|
||||
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
|
||||
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::{ServerConfigV2, TlsConfig};
|
||||
use crate::model::discovery::DeviceType;
|
||||
@@ -140,8 +141,19 @@ async fn server_test() -> Result<()> {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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 {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
match event {
|
||||
@@ -202,6 +214,10 @@ async fn server_test() -> Result<()> {
|
||||
private_key: PRIVATE_KEY.to_string(),
|
||||
}),
|
||||
client_info,
|
||||
Some(InternalConfig {
|
||||
show_token: "show-token".to_string(),
|
||||
event_tx: internal_event_tx,
|
||||
}),
|
||||
Some(ServerConfigV2 {
|
||||
pin: None,
|
||||
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,
|
||||
token: "server-fingerprint".to_string(),
|
||||
},
|
||||
None,
|
||||
Some(ServerConfigV2 { pin, event_tx }),
|
||||
None,
|
||||
stop_rx,
|
||||
|
||||
@@ -133,6 +133,7 @@ async fn start_test_server(
|
||||
device_type: None,
|
||||
token: "server-fingerprint".to_string(),
|
||||
},
|
||||
None,
|
||||
Some(ServerConfigV2 {
|
||||
pin: None,
|
||||
event_tx: v2_event_tx,
|
||||
|
||||
Reference in New Issue
Block a user