From c8d4c8d7136493820c08eb85d870f23550537062 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 09:50:18 +0000 Subject: [PATCH 1/9] HTTP API: move functions out to global scope --- crates/librqbit/src/http_api.rs | 854 ++++++++++++++++---------------- 1 file changed, 426 insertions(+), 428 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index b7fa8c04..3ee393ae 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -144,6 +144,432 @@ mod timeout { use timeout::Timeout; +async fn dht_stats(State(state): State) -> Result { + state.api_dht_stats().map(axum::Json) +} + +async fn dht_table(State(state): State) -> Result { + state.api_dht_table().map(axum::Json) +} + +async fn session_stats(State(state): State) -> impl IntoResponse { + axum::Json(state.api_session_stats()) +} + +async fn torrents_list( + State(state): State, + Query(opts): Query, +) -> impl IntoResponse { + axum::Json(state.api_torrent_list_ext(opts)) +} + +async fn torrents_post( + State(state): State, + Query(params): Query, + Timeout(timeout): Timeout<600_000, 3_600_000>, + data: Bytes, +) -> Result { + let is_url = params.is_url; + let opts = params.into_add_torrent_options(); + let data = data.to_vec(); + let maybe_magnet = |data: &[u8]| -> bool { + std::str::from_utf8(data) + .ok() + .and_then(|s| Magnet::parse(s).ok()) + .is_some() + }; + let add = match is_url { + Some(true) => AddTorrent::Url( + String::from_utf8(data) + .context("invalid utf-8 for passed URL")? + .into(), + ), + Some(false) => AddTorrent::TorrentFileBytes(data.into()), + + // Guess the format. + None if SUPPORTED_SCHEMES + .iter() + .any(|s| data.starts_with(s.as_bytes())) + || maybe_magnet(&data) => + { + AddTorrent::Url( + String::from_utf8(data) + .context("invalid utf-8 for passed URL")? + .into(), + ) + } + _ => AddTorrent::TorrentFileBytes(data.into()), + }; + tokio::time::timeout(timeout, state.api_add_torrent(add, Some(opts))) + .await + .context("timeout")? + .map(axum::Json) +} + +async fn torrent_details( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_torrent_details(idx).map(axum::Json) +} + +fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { + let mut playlist_items = handle + .metadata + .load() + .as_ref() + .context("torrent metadata not resolved")? + .info + .iter_file_details()? + .enumerate() + .filter_map(|(file_idx, file_details)| { + let filename = file_details.filename.to_vec().ok()?.join("/"); + let is_playable = mime_guess::from_path(&filename) + .first() + .map(|mime| { + mime.type_() == mime_guess::mime::VIDEO + || mime.type_() == mime_guess::mime::AUDIO + }) + .unwrap_or(false); + if is_playable { + let filename = urlencoding::encode(&filename); + Some((file_idx, filename.into_owned())) + } else { + None + } + }) + .collect::>(); + playlist_items.sort_by(|left, right| left.1.cmp(&right.1)); + Ok(playlist_items) +} + +fn get_host(headers: &HeaderMap) -> Result<&str> { + Ok(headers + .get("host") + .ok_or_else(|| ApiError::new_from_text(StatusCode::BAD_REQUEST, "Missing host header"))? + .to_str() + .context("hostname is not string")?) +} + +fn build_playlist_content( + host: &str, + it: impl IntoIterator, +) -> impl IntoResponse { + let body = it + .into_iter() + .map(|(torrent_idx, file_idx, filename)| { + // TODO: add #EXTINF:{duration} and maybe codecs ? + format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}") + }) + .join("\r\n"); + ( + [ + ("Content-Type", "application/mpegurl; charset=utf-8"), + ( + "Content-Disposition", + "attachment; filename=\"rqbit-playlist.m3u8\"", + ), + ], + format!("#EXTM3U\r\n{body}"), // https://en.wikipedia.org/wiki/M3U + ) +} + +async fn resolve_magnet( + State(state): State, + Timeout(timeout): Timeout<600_000, 3_600_000>, + inp_headers: HeaderMap, + url: String, +) -> Result { + let added = tokio::time::timeout( + timeout, + state.session().add_torrent( + AddTorrent::from_url(&url), + Some(AddTorrentOptions { + list_only: true, + ..Default::default() + }), + ), + ) + .await + .context("timeout")??; + + let (info, content) = match added { + crate::AddTorrentResponse::AlreadyManaged(_, handle) => { + handle.with_metadata(|r| (r.info.clone(), r.torrent_bytes.clone()))? + } + crate::AddTorrentResponse::ListOnly(ListOnlyResponse { + info, + torrent_bytes, + .. + }) => (info, torrent_bytes), + crate::AddTorrentResponse::Added(_, _) => { + return Err(ApiError::new_from_text( + StatusCode::INTERNAL_SERVER_ERROR, + "bug: torrent was added to session, but shouldn't have been", + )) + } + }; + + let mut headers = HeaderMap::new(); + + if inp_headers + .get("Accept") + .and_then(|v| std::str::from_utf8(v.as_bytes()).ok()) + == Some("application/json") + { + let data = bencode::dyn_from_bytes::>(&content) + .context("error decoding .torrent file content")?; + let data = serde_json::to_string(&data).context("error serializing")?; + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + return Ok((headers, data).into_response()); + } + + headers.insert( + "Content-Type", + HeaderValue::from_static("application/x-bittorrent"), + ); + + if let Some(name) = info.name.as_ref() { + if let Ok(name) = std::str::from_utf8(name) { + if let Ok(h) = + HeaderValue::from_str(&format!("attachment; filename=\"{}.torrent\"", name)) + { + headers.insert("Content-Disposition", h); + } + } + } + Ok((headers, content).into_response()) +} + +async fn torrent_playlist( + State(state): State, + headers: HeaderMap, + Path(idx): Path, +) -> Result { + let host = get_host(&headers)?; + let playlist_items = torrent_playlist_items(&*state.mgr_handle(idx)?)?; + Ok(build_playlist_content( + host, + playlist_items + .into_iter() + .map(move |(file_idx, filename)| (idx, file_idx, filename)), + )) +} + +async fn global_playlist( + State(state): State, + headers: HeaderMap, +) -> Result { + let host = get_host(&headers)?; + let all_items = state.session().with_torrents(|torrents| { + torrents + .filter_map(|(torrent_idx, handle)| { + torrent_playlist_items(handle) + .map(move |items| { + items.into_iter().map(move |(file_idx, filename)| { + (torrent_idx.into(), file_idx, filename) + }) + }) + .ok() + }) + .flatten() + .collect::>() + }); + Ok(build_playlist_content(host, all_items)) +} + +async fn torrent_haves( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_dump_haves(idx) +} + +async fn torrent_stats_v0( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_stats_v0(idx).map(axum::Json) +} + +async fn torrent_stats_v1( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_stats_v1(idx).map(axum::Json) +} + +async fn peer_stats( + State(state): State, + Path(idx): Path, + Query(filter): Query, +) -> Result { + state.api_peer_stats(idx, filter).map(axum::Json) +} + +#[derive(Deserialize)] +struct StreamPathParams { + id: TorrentIdOrHash, + file_id: usize, + #[serde(rename = "filename")] + _filename: Option>, +} + +async fn torrent_stream_file( + State(state): State, + Path(StreamPathParams { id, file_id, .. }): Path, + headers: http::HeaderMap, +) -> Result { + let mut stream = state.api_stream(id, file_id)?; + let mut status = StatusCode::OK; + let mut output_headers = HeaderMap::new(); + output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + + const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; + const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; + const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; + + if headers + .get(DLNA_TRANSFER_MODE) + .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) + .unwrap_or(false) + { + output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); + } + + if headers + .get(DLNA_GET_CONTENT_FEATURES) + .map(|v| v.as_bytes() == b"1") + .unwrap_or(false) + { + output_headers.insert( + DLNA_CONTENT_FEATURES, + HeaderValue::from_static("DLNA.ORG_OP=01"), + ); + } + + if let Ok(mime) = state.torrent_file_mime_type(id, file_id) { + output_headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_str(mime).context("bug - invalid MIME")?, + ); + } + + let range_header = headers.get(http::header::RANGE); + trace!(torrent_id=%id, file_id=file_id, range=?range_header, "request for HTTP stream"); + + if let Some(range) = range_header { + let offset: Option = range + .to_str() + .ok() + .and_then(|s| s.strip_prefix("bytes=")) + .and_then(|s| s.strip_suffix('-')) + .and_then(|s| s.parse().ok()); + if let Some(offset) = offset { + status = StatusCode::PARTIAL_CONTENT; + stream + .seek(SeekFrom::Start(offset)) + .await + .context("error seeking")?; + + output_headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_str(&format!("{}", stream.len() - stream.position())) + .context("bug")?, + ); + output_headers.insert( + http::header::CONTENT_RANGE, + HeaderValue::from_str(&format!( + "bytes {}-{}/{}", + stream.position(), + stream.len().saturating_sub(1), + stream.len() + )) + .context("bug")?, + ); + } + } else { + output_headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_str(&format!("{}", stream.len())).context("bug")?, + ); + } + + let s = tokio_util::io::ReaderStream::with_capacity(stream, 65536); + Ok((status, (output_headers, axum::body::Body::from_stream(s)))) +} + +async fn torrent_action_pause( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_torrent_action_pause(idx).await.map(axum::Json) +} + +async fn torrent_action_start( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_torrent_action_start(idx).await.map(axum::Json) +} + +async fn torrent_action_forget( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_torrent_action_forget(idx).await.map(axum::Json) +} + +async fn torrent_action_delete( + State(state): State, + Path(idx): Path, +) -> Result { + state.api_torrent_action_delete(idx).await.map(axum::Json) +} + +#[derive(Deserialize)] +struct UpdateOnlyFilesRequest { + only_files: Vec, +} + +async fn torrent_action_update_only_files( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + state + .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) + .await + .map(axum::Json) +} + +async fn set_rust_log( + State(state): State, + new_value: String, +) -> Result { + state.api_set_rust_log(new_value).map(axum::Json) +} + +async fn stream_logs(State(state): State) -> Result { + let s = state.api_log_lines_stream()?.map_err(|e| { + debug!(error=%e, "stream_logs"); + e + }); + Ok(axum::body::Body::from_stream(s)) +} + +async fn update_session_ratelimits( + State(state): State, + Json(limits): Json, +) -> Result { + state.session().ratelimits.set_upload_bps(limits.upload_bps); + state + .session() + .ratelimits + .set_download_bps(limits.download_bps); + Ok(Json(EmptyJsonResponse {})) +} + impl HttpApi { pub fn new(api: Api, opts: Option) -> Self { Self { @@ -205,434 +631,6 @@ impl HttpApi { ).into_response() }; - async fn dht_stats(State(state): State) -> Result { - state.api_dht_stats().map(axum::Json) - } - - async fn dht_table(State(state): State) -> Result { - state.api_dht_table().map(axum::Json) - } - - async fn session_stats(State(state): State) -> impl IntoResponse { - axum::Json(state.api_session_stats()) - } - - async fn torrents_list( - State(state): State, - Query(opts): Query, - ) -> impl IntoResponse { - axum::Json(state.api_torrent_list_ext(opts)) - } - - async fn torrents_post( - State(state): State, - Query(params): Query, - Timeout(timeout): Timeout<600_000, 3_600_000>, - data: Bytes, - ) -> Result { - let is_url = params.is_url; - let opts = params.into_add_torrent_options(); - let data = data.to_vec(); - let maybe_magnet = |data: &[u8]| -> bool { - std::str::from_utf8(data) - .ok() - .and_then(|s| Magnet::parse(s).ok()) - .is_some() - }; - let add = match is_url { - Some(true) => AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ), - Some(false) => AddTorrent::TorrentFileBytes(data.into()), - - // Guess the format. - None if SUPPORTED_SCHEMES - .iter() - .any(|s| data.starts_with(s.as_bytes())) - || maybe_magnet(&data) => - { - AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ) - } - _ => AddTorrent::TorrentFileBytes(data.into()), - }; - tokio::time::timeout(timeout, state.api_add_torrent(add, Some(opts))) - .await - .context("timeout")? - .map(axum::Json) - } - - async fn torrent_details( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_torrent_details(idx).map(axum::Json) - } - - fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { - let mut playlist_items = handle - .metadata - .load() - .as_ref() - .context("torrent metadata not resolved")? - .info - .iter_file_details()? - .enumerate() - .filter_map(|(file_idx, file_details)| { - let filename = file_details.filename.to_vec().ok()?.join("/"); - let is_playable = mime_guess::from_path(&filename) - .first() - .map(|mime| { - mime.type_() == mime_guess::mime::VIDEO - || mime.type_() == mime_guess::mime::AUDIO - }) - .unwrap_or(false); - if is_playable { - let filename = urlencoding::encode(&filename); - Some((file_idx, filename.into_owned())) - } else { - None - } - }) - .collect::>(); - playlist_items.sort_by(|left, right| left.1.cmp(&right.1)); - Ok(playlist_items) - } - - fn get_host(headers: &HeaderMap) -> Result<&str> { - Ok(headers - .get("host") - .ok_or_else(|| { - ApiError::new_from_text(StatusCode::BAD_REQUEST, "Missing host header") - })? - .to_str() - .context("hostname is not string")?) - } - - fn build_playlist_content( - host: &str, - it: impl IntoIterator, - ) -> impl IntoResponse { - let body = it - .into_iter() - .map(|(torrent_idx, file_idx, filename)| { - // TODO: add #EXTINF:{duration} and maybe codecs ? - format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}") - }) - .join("\r\n"); - ( - [ - ("Content-Type", "application/mpegurl; charset=utf-8"), - ( - "Content-Disposition", - "attachment; filename=\"rqbit-playlist.m3u8\"", - ), - ], - format!("#EXTM3U\r\n{body}"), // https://en.wikipedia.org/wiki/M3U - ) - } - - async fn resolve_magnet( - State(state): State, - Timeout(timeout): Timeout<600_000, 3_600_000>, - inp_headers: HeaderMap, - url: String, - ) -> Result { - let added = tokio::time::timeout( - timeout, - state.session().add_torrent( - AddTorrent::from_url(&url), - Some(AddTorrentOptions { - list_only: true, - ..Default::default() - }), - ), - ) - .await - .context("timeout")??; - - let (info, content) = match added { - crate::AddTorrentResponse::AlreadyManaged(_, handle) => { - handle.with_metadata(|r| (r.info.clone(), r.torrent_bytes.clone()))? - } - crate::AddTorrentResponse::ListOnly(ListOnlyResponse { - info, - torrent_bytes, - .. - }) => (info, torrent_bytes), - crate::AddTorrentResponse::Added(_, _) => { - return Err(ApiError::new_from_text( - StatusCode::INTERNAL_SERVER_ERROR, - "bug: torrent was added to session, but shouldn't have been", - )) - } - }; - - let mut headers = HeaderMap::new(); - - if inp_headers - .get("Accept") - .and_then(|v| std::str::from_utf8(v.as_bytes()).ok()) - == Some("application/json") - { - let data = bencode::dyn_from_bytes::>(&content) - .context("error decoding .torrent file content")?; - let data = serde_json::to_string(&data).context("error serializing")?; - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - return Ok((headers, data).into_response()); - } - - headers.insert( - "Content-Type", - HeaderValue::from_static("application/x-bittorrent"), - ); - - if let Some(name) = info.name.as_ref() { - if let Ok(name) = std::str::from_utf8(name) { - if let Ok(h) = - HeaderValue::from_str(&format!("attachment; filename=\"{}.torrent\"", name)) - { - headers.insert("Content-Disposition", h); - } - } - } - Ok((headers, content).into_response()) - } - - async fn torrent_playlist( - State(state): State, - headers: HeaderMap, - Path(idx): Path, - ) -> Result { - let host = get_host(&headers)?; - let playlist_items = torrent_playlist_items(&*state.mgr_handle(idx)?)?; - Ok(build_playlist_content( - host, - playlist_items - .into_iter() - .map(move |(file_idx, filename)| (idx, file_idx, filename)), - )) - } - - async fn global_playlist( - State(state): State, - headers: HeaderMap, - ) -> Result { - let host = get_host(&headers)?; - let all_items = state.session().with_torrents(|torrents| { - torrents - .filter_map(|(torrent_idx, handle)| { - torrent_playlist_items(handle) - .map(move |items| { - items.into_iter().map(move |(file_idx, filename)| { - (torrent_idx.into(), file_idx, filename) - }) - }) - .ok() - }) - .flatten() - .collect::>() - }); - Ok(build_playlist_content(host, all_items)) - } - - async fn torrent_haves( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_dump_haves(idx) - } - - async fn torrent_stats_v0( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_stats_v0(idx).map(axum::Json) - } - - async fn torrent_stats_v1( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_stats_v1(idx).map(axum::Json) - } - - async fn peer_stats( - State(state): State, - Path(idx): Path, - Query(filter): Query, - ) -> Result { - state.api_peer_stats(idx, filter).map(axum::Json) - } - - #[derive(Deserialize)] - struct StreamPathParams { - id: TorrentIdOrHash, - file_id: usize, - #[serde(rename = "filename")] - _filename: Option>, - } - - async fn torrent_stream_file( - State(state): State, - Path(StreamPathParams { id, file_id, .. }): Path, - headers: http::HeaderMap, - ) -> Result { - let mut stream = state.api_stream(id, file_id)?; - let mut status = StatusCode::OK; - let mut output_headers = HeaderMap::new(); - output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - - const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; - const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; - const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; - - if headers - .get(DLNA_TRANSFER_MODE) - .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) - .unwrap_or(false) - { - output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); - } - - if headers - .get(DLNA_GET_CONTENT_FEATURES) - .map(|v| v.as_bytes() == b"1") - .unwrap_or(false) - { - output_headers.insert( - DLNA_CONTENT_FEATURES, - HeaderValue::from_static("DLNA.ORG_OP=01"), - ); - } - - if let Ok(mime) = state.torrent_file_mime_type(id, file_id) { - output_headers.insert( - http::header::CONTENT_TYPE, - HeaderValue::from_str(mime).context("bug - invalid MIME")?, - ); - } - - let range_header = headers.get(http::header::RANGE); - trace!(torrent_id=%id, file_id=file_id, range=?range_header, "request for HTTP stream"); - - if let Some(range) = range_header { - let offset: Option = range - .to_str() - .ok() - .and_then(|s| s.strip_prefix("bytes=")) - .and_then(|s| s.strip_suffix('-')) - .and_then(|s| s.parse().ok()); - if let Some(offset) = offset { - status = StatusCode::PARTIAL_CONTENT; - stream - .seek(SeekFrom::Start(offset)) - .await - .context("error seeking")?; - - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_str(&format!("{}", stream.len() - stream.position())) - .context("bug")?, - ); - output_headers.insert( - http::header::CONTENT_RANGE, - HeaderValue::from_str(&format!( - "bytes {}-{}/{}", - stream.position(), - stream.len().saturating_sub(1), - stream.len() - )) - .context("bug")?, - ); - } - } else { - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_str(&format!("{}", stream.len())).context("bug")?, - ); - } - - let s = tokio_util::io::ReaderStream::with_capacity(stream, 65536); - Ok((status, (output_headers, axum::body::Body::from_stream(s)))) - } - - async fn torrent_action_pause( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_torrent_action_pause(idx).await.map(axum::Json) - } - - async fn torrent_action_start( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_torrent_action_start(idx).await.map(axum::Json) - } - - async fn torrent_action_forget( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_torrent_action_forget(idx).await.map(axum::Json) - } - - async fn torrent_action_delete( - State(state): State, - Path(idx): Path, - ) -> Result { - state.api_torrent_action_delete(idx).await.map(axum::Json) - } - - #[derive(Deserialize)] - struct UpdateOnlyFilesRequest { - only_files: Vec, - } - - async fn torrent_action_update_only_files( - State(state): State, - Path(idx): Path, - axum::Json(req): axum::Json, - ) -> Result { - state - .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) - .await - .map(axum::Json) - } - - async fn set_rust_log( - State(state): State, - new_value: String, - ) -> Result { - state.api_set_rust_log(new_value).map(axum::Json) - } - - async fn stream_logs(State(state): State) -> Result { - let s = state.api_log_lines_stream()?.map_err(|e| { - debug!(error=%e, "stream_logs"); - e - }); - Ok(axum::body::Body::from_stream(s)) - } - - async fn update_session_ratelimits( - State(state): State, - Json(limits): Json, - ) -> Result { - state.session().ratelimits.set_upload_bps(limits.upload_bps); - state - .session() - .ratelimits - .set_download_bps(limits.download_bps); - Ok(Json(EmptyJsonResponse {})) - } - let mut app = Router::new() .route("/", get(api_root)) .route("/stream_logs", get(stream_logs)) From d3323ac7acb033e21d9a738b3126f332089b5dff Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:03:22 +0000 Subject: [PATCH 2/9] Move HTTP API state one layer down --- crates/librqbit/src/http_api.rs | 82 +++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 3ee393ae..65d9231e 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -33,14 +33,14 @@ use crate::peer_connection::PeerConnectionOptions; use crate::session::{AddTorrent, AddTorrentOptions, SUPPORTED_SCHEMES}; use crate::torrent_state::peer::stats::snapshot::PeerStatsFilter; -type ApiState = Api; +type ApiState = Arc; use crate::api::Result; use crate::{ApiError, ListOnlyResponse, ManagedTorrent}; /// An HTTP server for the API. pub struct HttpApi { - inner: ApiState, + api: Api, opts: HttpApiOptions, } @@ -145,22 +145,22 @@ mod timeout { use timeout::Timeout; async fn dht_stats(State(state): State) -> Result { - state.api_dht_stats().map(axum::Json) + state.api.api_dht_stats().map(axum::Json) } async fn dht_table(State(state): State) -> Result { - state.api_dht_table().map(axum::Json) + state.api.api_dht_table().map(axum::Json) } async fn session_stats(State(state): State) -> impl IntoResponse { - axum::Json(state.api_session_stats()) + axum::Json(state.api.api_session_stats()) } async fn torrents_list( State(state): State, Query(opts): Query, ) -> impl IntoResponse { - axum::Json(state.api_torrent_list_ext(opts)) + axum::Json(state.api.api_torrent_list_ext(opts)) } async fn torrents_post( @@ -200,7 +200,7 @@ async fn torrents_post( } _ => AddTorrent::TorrentFileBytes(data.into()), }; - tokio::time::timeout(timeout, state.api_add_torrent(add, Some(opts))) + tokio::time::timeout(timeout, state.api.api_add_torrent(add, Some(opts))) .await .context("timeout")? .map(axum::Json) @@ -210,7 +210,7 @@ async fn torrent_details( State(state): State, Path(idx): Path, ) -> Result { - state.api_torrent_details(idx).map(axum::Json) + state.api.api_torrent_details(idx).map(axum::Json) } fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { @@ -282,7 +282,7 @@ async fn resolve_magnet( ) -> Result { let added = tokio::time::timeout( timeout, - state.session().add_torrent( + state.api.session().add_torrent( AddTorrent::from_url(&url), Some(AddTorrentOptions { list_only: true, @@ -347,7 +347,7 @@ async fn torrent_playlist( Path(idx): Path, ) -> Result { let host = get_host(&headers)?; - let playlist_items = torrent_playlist_items(&*state.mgr_handle(idx)?)?; + let playlist_items = torrent_playlist_items(&*state.api.mgr_handle(idx)?)?; Ok(build_playlist_content( host, playlist_items @@ -361,7 +361,7 @@ async fn global_playlist( headers: HeaderMap, ) -> Result { let host = get_host(&headers)?; - let all_items = state.session().with_torrents(|torrents| { + let all_items = state.api.session().with_torrents(|torrents| { torrents .filter_map(|(torrent_idx, handle)| { torrent_playlist_items(handle) @@ -382,21 +382,21 @@ async fn torrent_haves( State(state): State, Path(idx): Path, ) -> Result { - state.api_dump_haves(idx) + state.api.api_dump_haves(idx) } async fn torrent_stats_v0( State(state): State, Path(idx): Path, ) -> Result { - state.api_stats_v0(idx).map(axum::Json) + state.api.api_stats_v0(idx).map(axum::Json) } async fn torrent_stats_v1( State(state): State, Path(idx): Path, ) -> Result { - state.api_stats_v1(idx).map(axum::Json) + state.api.api_stats_v1(idx).map(axum::Json) } async fn peer_stats( @@ -404,7 +404,7 @@ async fn peer_stats( Path(idx): Path, Query(filter): Query, ) -> Result { - state.api_peer_stats(idx, filter).map(axum::Json) + state.api.api_peer_stats(idx, filter).map(axum::Json) } #[derive(Deserialize)] @@ -420,7 +420,7 @@ async fn torrent_stream_file( Path(StreamPathParams { id, file_id, .. }): Path, headers: http::HeaderMap, ) -> Result { - let mut stream = state.api_stream(id, file_id)?; + let mut stream = state.api.api_stream(id, file_id)?; let mut status = StatusCode::OK; let mut output_headers = HeaderMap::new(); output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); @@ -448,7 +448,7 @@ async fn torrent_stream_file( ); } - if let Ok(mime) = state.torrent_file_mime_type(id, file_id) { + if let Ok(mime) = state.api.torrent_file_mime_type(id, file_id) { output_headers.insert( http::header::CONTENT_TYPE, HeaderValue::from_str(mime).context("bug - invalid MIME")?, @@ -503,28 +503,44 @@ async fn torrent_action_pause( State(state): State, Path(idx): Path, ) -> Result { - state.api_torrent_action_pause(idx).await.map(axum::Json) + state + .api + .api_torrent_action_pause(idx) + .await + .map(axum::Json) } async fn torrent_action_start( State(state): State, Path(idx): Path, ) -> Result { - state.api_torrent_action_start(idx).await.map(axum::Json) + state + .api + .api_torrent_action_start(idx) + .await + .map(axum::Json) } async fn torrent_action_forget( State(state): State, Path(idx): Path, ) -> Result { - state.api_torrent_action_forget(idx).await.map(axum::Json) + state + .api + .api_torrent_action_forget(idx) + .await + .map(axum::Json) } async fn torrent_action_delete( State(state): State, Path(idx): Path, ) -> Result { - state.api_torrent_action_delete(idx).await.map(axum::Json) + state + .api + .api_torrent_action_delete(idx) + .await + .map(axum::Json) } #[derive(Deserialize)] @@ -538,6 +554,7 @@ async fn torrent_action_update_only_files( axum::Json(req): axum::Json, ) -> Result { state + .api .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) .await .map(axum::Json) @@ -547,11 +564,11 @@ async fn set_rust_log( State(state): State, new_value: String, ) -> Result { - state.api_set_rust_log(new_value).map(axum::Json) + state.api.api_set_rust_log(new_value).map(axum::Json) } async fn stream_logs(State(state): State) -> Result { - let s = state.api_log_lines_stream()?.map_err(|e| { + let s = state.api.api_log_lines_stream()?.map_err(|e| { debug!(error=%e, "stream_logs"); e }); @@ -562,8 +579,13 @@ async fn update_session_ratelimits( State(state): State, Json(limits): Json, ) -> Result { - state.session().ratelimits.set_upload_bps(limits.upload_bps); state + .api + .session() + .ratelimits + .set_upload_bps(limits.upload_bps); + state + .api .session() .ratelimits .set_download_bps(limits.download_bps); @@ -573,7 +595,7 @@ async fn update_session_ratelimits( impl HttpApi { pub fn new(api: Api, opts: Option) -> Self { Self { - inner: api, + api, opts: opts.unwrap_or_default(), } } @@ -582,11 +604,11 @@ impl HttpApi { /// If read_only is passed, no state-modifying methods will be exposed. #[inline(never)] pub fn make_http_api_and_run( - mut self, + self, listener: TcpListener, upnp_router: Option, ) -> BoxFuture<'static, anyhow::Result<()>> { - let state = self.inner; + let state = Arc::new(self); let api_root = move |parts: Parts| async move { // If browser, and webui enabled, redirect to web @@ -653,7 +675,7 @@ impl HttpApi { get(torrent_stream_file), ); - if !self.opts.read_only { + if !state.opts.read_only { app = app .route("/torrents", post(torrents_post)) .route("/torrents/limits", post(update_session_ratelimits)) @@ -741,10 +763,10 @@ impl HttpApi { .allow_headers(AllowHeaders::any()) }; - let mut app = app.with_state(state); + let mut app = app.with_state(state.clone()); // Simple one-user basic auth - if let Some((user, pass)) = self.opts.basic_auth.take() { + if let Some((user, pass)) = state.opts.basic_auth.clone() { info!("Enabling simple basic authentication in HTTP API"); app = app.route_layer(axum::middleware::from_fn(move |headers, request, next| { From c474d7b454177f63c32a5ceb45cb28a4d005aedd Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:05:23 +0000 Subject: [PATCH 3/9] Move api_root to global scope --- crates/librqbit/src/http_api.rs | 86 ++++++++++++++++----------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 65d9231e..e158f6e7 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -592,6 +592,49 @@ async fn update_session_ratelimits( Ok(Json(EmptyJsonResponse {})) } +async fn api_root(parts: Parts) -> impl IntoResponse { + // If browser, and webui enabled, redirect to web + #[cfg(feature = "webui")] + { + if parts.headers.get("Accept").map_or(false, |h| { + std::str::from_utf8(h.as_bytes()).map_or(false, |s| s.contains("text/html")) + }) { + return Redirect::temporary("./web/").into_response(); + } + } + + ( + [("Content-Type", "application/json")], + axum::Json(serde_json::json!({ + "apis": { + "GET /": "list all available APIs", + "GET /dht/stats": "DHT stats", + "GET /dht/table": "DHT routing table", + "GET /torrents": "List torrents", + "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", + "GET /stats": "Global session stats", + "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", + "GET /torrents/{id_or_infohash}": "Torrent details", + "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", + "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", + "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", + "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", + "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", + "POST /torrents/{id_or_infohash}/pause": "Pause torrent", + "POST /torrents/{id_or_infohash}/start": "Resume torrent", + "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", + "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", + "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", + "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", + "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", + "GET /web/": "Web UI", + }, + "server": "rqbit", + "version": env!("CARGO_PKG_VERSION"), + })), + ).into_response() +} + impl HttpApi { pub fn new(api: Api, opts: Option) -> Self { Self { @@ -610,49 +653,6 @@ impl HttpApi { ) -> BoxFuture<'static, anyhow::Result<()>> { let state = Arc::new(self); - let api_root = move |parts: Parts| async move { - // If browser, and webui enabled, redirect to web - #[cfg(feature = "webui")] - { - if parts.headers.get("Accept").map_or(false, |h| { - std::str::from_utf8(h.as_bytes()).map_or(false, |s| s.contains("text/html")) - }) { - return Redirect::temporary("./web/").into_response(); - } - } - - ( - [("Content-Type", "application/json")], - axum::Json(serde_json::json!({ - "apis": { - "GET /": "list all available APIs", - "GET /dht/stats": "DHT stats", - "GET /dht/table": "DHT routing table", - "GET /torrents": "List torrents", - "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", - "GET /stats": "Global session stats", - "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", - "GET /torrents/{id_or_infohash}": "Torrent details", - "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", - "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", - "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", - "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", - "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", - "POST /torrents/{id_or_infohash}/pause": "Pause torrent", - "POST /torrents/{id_or_infohash}/start": "Resume torrent", - "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", - "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", - "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", - "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", - "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", - "GET /web/": "Web UI", - }, - "server": "rqbit", - "version": env!("CARGO_PKG_VERSION"), - })), - ).into_response() - }; - let mut app = Router::new() .route("/", get(api_root)) .route("/stream_logs", get(stream_logs)) From 8b35ddd59c756aca596acdef2e9c3bf2975d9bc9 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:27:58 +0000 Subject: [PATCH 4/9] LazyLock --- crates/librqbit/src/http_api.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index e158f6e7..66cb7074 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use std::io::SeekFrom; use std::net::SocketAddr; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use tokio::io::AsyncSeekExt; use tokio::net::TcpListener; @@ -596,16 +596,18 @@ async fn api_root(parts: Parts) -> impl IntoResponse { // If browser, and webui enabled, redirect to web #[cfg(feature = "webui")] { - if parts.headers.get("Accept").map_or(false, |h| { - std::str::from_utf8(h.as_bytes()).map_or(false, |s| s.contains("text/html")) - }) { + if parts + .headers + .get("Accept") + .and_then(|h| h.to_str().ok()) + .map_or(false, |h| h.contains("text/html")) + { return Redirect::temporary("./web/").into_response(); } } - ( - [("Content-Type", "application/json")], - axum::Json(serde_json::json!({ + static API_ROOT_JSON: LazyLock> = LazyLock::new(|| { + Arc::new(serde_json::json!({ "apis": { "GET /": "list all available APIs", "GET /dht/stats": "DHT stats", @@ -631,8 +633,14 @@ async fn api_root(parts: Parts) -> impl IntoResponse { }, "server": "rqbit", "version": env!("CARGO_PKG_VERSION"), - })), - ).into_response() + })) + }); + + ( + [("Content-Type", "application/json")], + axum::Json(API_ROOT_JSON.clone()), + ) + .into_response() } impl HttpApi { From fd5feba50133119f3503e94f0c66a0934902e8fb Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:32:39 +0000 Subject: [PATCH 5/9] Rename all HTTP handlers --- crates/librqbit/src/http_api.rs | 97 +++++++++++++++++---------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 66cb7074..2a3c5d30 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -144,26 +144,26 @@ mod timeout { use timeout::Timeout; -async fn dht_stats(State(state): State) -> Result { +async fn h_dht_stats(State(state): State) -> Result { state.api.api_dht_stats().map(axum::Json) } -async fn dht_table(State(state): State) -> Result { +async fn h_dht_table(State(state): State) -> Result { state.api.api_dht_table().map(axum::Json) } -async fn session_stats(State(state): State) -> impl IntoResponse { +async fn h_session_stats(State(state): State) -> impl IntoResponse { axum::Json(state.api.api_session_stats()) } -async fn torrents_list( +async fn h_torrents_list( State(state): State, Query(opts): Query, ) -> impl IntoResponse { axum::Json(state.api.api_torrent_list_ext(opts)) } -async fn torrents_post( +async fn h_torrents_post( State(state): State, Query(params): Query, Timeout(timeout): Timeout<600_000, 3_600_000>, @@ -206,7 +206,7 @@ async fn torrents_post( .map(axum::Json) } -async fn torrent_details( +async fn h_torrent_details( State(state): State, Path(idx): Path, ) -> Result { @@ -274,7 +274,7 @@ fn build_playlist_content( ) } -async fn resolve_magnet( +async fn h_resolve_magnet( State(state): State, Timeout(timeout): Timeout<600_000, 3_600_000>, inp_headers: HeaderMap, @@ -341,7 +341,7 @@ async fn resolve_magnet( Ok((headers, content).into_response()) } -async fn torrent_playlist( +async fn h_torrent_playlist( State(state): State, headers: HeaderMap, Path(idx): Path, @@ -356,7 +356,7 @@ async fn torrent_playlist( )) } -async fn global_playlist( +async fn h_global_playlist( State(state): State, headers: HeaderMap, ) -> Result { @@ -378,28 +378,28 @@ async fn global_playlist( Ok(build_playlist_content(host, all_items)) } -async fn torrent_haves( +async fn h_torrent_haves( State(state): State, Path(idx): Path, ) -> Result { state.api.api_dump_haves(idx) } -async fn torrent_stats_v0( +async fn h_torrent_stats_v0( State(state): State, Path(idx): Path, ) -> Result { state.api.api_stats_v0(idx).map(axum::Json) } -async fn torrent_stats_v1( +async fn h_torrent_stats_v1( State(state): State, Path(idx): Path, ) -> Result { state.api.api_stats_v1(idx).map(axum::Json) } -async fn peer_stats( +async fn h_peer_stats( State(state): State, Path(idx): Path, Query(filter): Query, @@ -415,7 +415,7 @@ struct StreamPathParams { _filename: Option>, } -async fn torrent_stream_file( +async fn h_torrent_stream_file( State(state): State, Path(StreamPathParams { id, file_id, .. }): Path, headers: http::HeaderMap, @@ -499,7 +499,7 @@ async fn torrent_stream_file( Ok((status, (output_headers, axum::body::Body::from_stream(s)))) } -async fn torrent_action_pause( +async fn h_torrent_action_pause( State(state): State, Path(idx): Path, ) -> Result { @@ -510,7 +510,7 @@ async fn torrent_action_pause( .map(axum::Json) } -async fn torrent_action_start( +async fn h_torrent_action_start( State(state): State, Path(idx): Path, ) -> Result { @@ -521,7 +521,7 @@ async fn torrent_action_start( .map(axum::Json) } -async fn torrent_action_forget( +async fn h_torrent_action_forget( State(state): State, Path(idx): Path, ) -> Result { @@ -532,7 +532,7 @@ async fn torrent_action_forget( .map(axum::Json) } -async fn torrent_action_delete( +async fn h_torrent_action_delete( State(state): State, Path(idx): Path, ) -> Result { @@ -548,7 +548,7 @@ struct UpdateOnlyFilesRequest { only_files: Vec, } -async fn torrent_action_update_only_files( +async fn h_torrent_action_update_only_files( State(state): State, Path(idx): Path, axum::Json(req): axum::Json, @@ -560,14 +560,14 @@ async fn torrent_action_update_only_files( .map(axum::Json) } -async fn set_rust_log( +async fn h_set_rust_log( State(state): State, new_value: String, ) -> Result { state.api.api_set_rust_log(new_value).map(axum::Json) } -async fn stream_logs(State(state): State) -> Result { +async fn h_stream_logs(State(state): State) -> Result { let s = state.api.api_log_lines_stream()?.map_err(|e| { debug!(error=%e, "stream_logs"); e @@ -575,7 +575,7 @@ async fn stream_logs(State(state): State) -> Result Ok(axum::body::Body::from_stream(s)) } -async fn update_session_ratelimits( +async fn h_update_session_ratelimits( State(state): State, Json(limits): Json, ) -> Result { @@ -592,7 +592,7 @@ async fn update_session_ratelimits( Ok(Json(EmptyJsonResponse {})) } -async fn api_root(parts: Parts) -> impl IntoResponse { +async fn h_api_root(parts: Parts) -> impl IntoResponse { // If browser, and webui enabled, redirect to web #[cfg(feature = "webui")] { @@ -662,38 +662,41 @@ impl HttpApi { let state = Arc::new(self); let mut app = Router::new() - .route("/", get(api_root)) - .route("/stream_logs", get(stream_logs)) - .route("/rust_log", post(set_rust_log)) - .route("/dht/stats", get(dht_stats)) - .route("/dht/table", get(dht_table)) - .route("/stats", get(session_stats)) - .route("/torrents", get(torrents_list)) - .route("/torrents/{id}", get(torrent_details)) - .route("/torrents/{id}/haves", get(torrent_haves)) - .route("/torrents/{id}/stats", get(torrent_stats_v0)) - .route("/torrents/{id}/stats/v1", get(torrent_stats_v1)) - .route("/torrents/{id}/peer_stats", get(peer_stats)) - .route("/torrents/{id}/playlist", get(torrent_playlist)) - .route("/torrents/playlist", get(global_playlist)) - .route("/torrents/resolve_magnet", post(resolve_magnet)) - .route("/torrents/{id}/stream/{file_id}", get(torrent_stream_file)) + .route("/", get(h_api_root)) + .route("/stream_logs", get(h_stream_logs)) + .route("/rust_log", post(h_set_rust_log)) + .route("/dht/stats", get(h_dht_stats)) + .route("/dht/table", get(h_dht_table)) + .route("/stats", get(h_session_stats)) + .route("/torrents", get(h_torrents_list)) + .route("/torrents/{id}", get(h_torrent_details)) + .route("/torrents/{id}/haves", get(h_torrent_haves)) + .route("/torrents/{id}/stats", get(h_torrent_stats_v0)) + .route("/torrents/{id}/stats/v1", get(h_torrent_stats_v1)) + .route("/torrents/{id}/peer_stats", get(h_peer_stats)) + .route("/torrents/{id}/playlist", get(h_torrent_playlist)) + .route("/torrents/playlist", get(h_global_playlist)) + .route("/torrents/resolve_magnet", post(h_resolve_magnet)) + .route( + "/torrents/{id}/stream/{file_id}", + get(h_torrent_stream_file), + ) .route( "/torrents/{id}/stream/{file_id}/{*filename}", - get(torrent_stream_file), + get(h_torrent_stream_file), ); if !state.opts.read_only { app = app - .route("/torrents", post(torrents_post)) - .route("/torrents/limits", post(update_session_ratelimits)) - .route("/torrents/{id}/pause", post(torrent_action_pause)) - .route("/torrents/{id}/start", post(torrent_action_start)) - .route("/torrents/{id}/forget", post(torrent_action_forget)) - .route("/torrents/{id}/delete", post(torrent_action_delete)) + .route("/torrents", post(h_torrents_post)) + .route("/torrents/limits", post(h_update_session_ratelimits)) + .route("/torrents/{id}/pause", post(h_torrent_action_pause)) + .route("/torrents/{id}/start", post(h_torrent_action_start)) + .route("/torrents/{id}/forget", post(h_torrent_action_forget)) + .route("/torrents/{id}/delete", post(h_torrent_action_delete)) .route( "/torrents/{id}/update_only_files", - post(torrent_action_update_only_files), + post(h_torrent_action_update_only_files), ); } From 8abbf683c003579e557b1ba82e4cb1f32c7ec67a Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:44:38 +0000 Subject: [PATCH 6/9] Nothing --- crates/librqbit/src/http_api.rs | 180 +++++++++++++++++--------------- 1 file changed, 95 insertions(+), 85 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 2a3c5d30..19515995 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -643,6 +643,90 @@ async fn h_api_root(parts: Parts) -> impl IntoResponse { .into_response() } +fn make_api_router(state: ApiState) -> Router { + let mut api_router = Router::new() + .route("/", get(h_api_root)) + .route("/stream_logs", get(h_stream_logs)) + .route("/rust_log", post(h_set_rust_log)) + .route("/dht/stats", get(h_dht_stats)) + .route("/dht/table", get(h_dht_table)) + .route("/stats", get(h_session_stats)) + .route("/torrents", get(h_torrents_list)) + .route("/torrents/{id}", get(h_torrent_details)) + .route("/torrents/{id}/haves", get(h_torrent_haves)) + .route("/torrents/{id}/stats", get(h_torrent_stats_v0)) + .route("/torrents/{id}/stats/v1", get(h_torrent_stats_v1)) + .route("/torrents/{id}/peer_stats", get(h_peer_stats)) + .route("/torrents/{id}/playlist", get(h_torrent_playlist)) + .route("/torrents/playlist", get(h_global_playlist)) + .route("/torrents/resolve_magnet", post(h_resolve_magnet)) + .route( + "/torrents/{id}/stream/{file_id}", + get(h_torrent_stream_file), + ) + .route( + "/torrents/{id}/stream/{file_id}/{*filename}", + get(h_torrent_stream_file), + ); + + if !state.opts.read_only { + api_router = api_router + .route("/torrents", post(h_torrents_post)) + .route("/torrents/limits", post(h_update_session_ratelimits)) + .route("/torrents/{id}/pause", post(h_torrent_action_pause)) + .route("/torrents/{id}/start", post(h_torrent_action_start)) + .route("/torrents/{id}/forget", post(h_torrent_action_forget)) + .route("/torrents/{id}/delete", post(h_torrent_action_delete)) + .route( + "/torrents/{id}/update_only_files", + post(h_torrent_action_update_only_files), + ); + } + + api_router.with_state(state) +} + +#[cfg(feature = "webui")] +fn make_webui_router() -> Router { + Router::new() + .route( + "/", + get(|| async { + ( + [("Content-Type", "text/html")], + include_str!("../webui/dist/index.html"), + ) + }), + ) + .route( + "/assets/index.js", + get(|| async { + ( + [("Content-Type", "application/javascript")], + include_str!("../webui/dist/assets/index.js"), + ) + }), + ) + .route( + "/assets/index.css", + get(|| async { + ( + [("Content-Type", "text/css")], + include_str!("../webui/dist/assets/index.css"), + ) + }), + ) + .route( + "/assets/logo.svg", + get(|| async { + ( + [("Content-Type", "image/svg+xml")], + include_str!("../webui/dist/assets/logo.svg"), + ) + }), + ) +} + impl HttpApi { pub fn new(api: Api, opts: Option) -> Self { Self { @@ -661,89 +745,16 @@ impl HttpApi { ) -> BoxFuture<'static, anyhow::Result<()>> { let state = Arc::new(self); - let mut app = Router::new() - .route("/", get(h_api_root)) - .route("/stream_logs", get(h_stream_logs)) - .route("/rust_log", post(h_set_rust_log)) - .route("/dht/stats", get(h_dht_stats)) - .route("/dht/table", get(h_dht_table)) - .route("/stats", get(h_session_stats)) - .route("/torrents", get(h_torrents_list)) - .route("/torrents/{id}", get(h_torrent_details)) - .route("/torrents/{id}/haves", get(h_torrent_haves)) - .route("/torrents/{id}/stats", get(h_torrent_stats_v0)) - .route("/torrents/{id}/stats/v1", get(h_torrent_stats_v1)) - .route("/torrents/{id}/peer_stats", get(h_peer_stats)) - .route("/torrents/{id}/playlist", get(h_torrent_playlist)) - .route("/torrents/playlist", get(h_global_playlist)) - .route("/torrents/resolve_magnet", post(h_resolve_magnet)) - .route( - "/torrents/{id}/stream/{file_id}", - get(h_torrent_stream_file), - ) - .route( - "/torrents/{id}/stream/{file_id}/{*filename}", - get(h_torrent_stream_file), - ); - - if !state.opts.read_only { - app = app - .route("/torrents", post(h_torrents_post)) - .route("/torrents/limits", post(h_update_session_ratelimits)) - .route("/torrents/{id}/pause", post(h_torrent_action_pause)) - .route("/torrents/{id}/start", post(h_torrent_action_start)) - .route("/torrents/{id}/forget", post(h_torrent_action_forget)) - .route("/torrents/{id}/delete", post(h_torrent_action_delete)) - .route( - "/torrents/{id}/update_only_files", - post(h_torrent_action_update_only_files), - ); - } + let mut main_router = Router::new(); + main_router = main_router.nest("/", make_api_router(state.clone())); #[cfg(feature = "webui")] { use axum::response::Redirect; - let webui_router = Router::new() - .route( - "/", - get(|| async { - ( - [("Content-Type", "text/html")], - include_str!("../webui/dist/index.html"), - ) - }), - ) - .route( - "/assets/index.js", - get(|| async { - ( - [("Content-Type", "application/javascript")], - include_str!("../webui/dist/assets/index.js"), - ) - }), - ) - .route( - "/assets/index.css", - get(|| async { - ( - [("Content-Type", "text/css")], - include_str!("../webui/dist/assets/index.css"), - ) - }), - ) - .route( - "/assets/logo.svg", - get(|| async { - ( - [("Content-Type", "image/svg+xml")], - include_str!("../webui/dist/assets/logo.svg"), - ) - }), - ); - - app = app.nest("/web/", webui_router); - app = app.route("/web", get(|| async { Redirect::permanent("/web/") })) + let webui_router = make_webui_router(); + main_router = main_router.nest("/web/", webui_router); + main_router = main_router.route("/web", get(|| async { Redirect::permanent("/web/") })) } let cors_layer = { @@ -774,26 +785,25 @@ impl HttpApi { .allow_headers(AllowHeaders::any()) }; - let mut app = app.with_state(state.clone()); - // Simple one-user basic auth if let Some((user, pass)) = state.opts.basic_auth.clone() { info!("Enabling simple basic authentication in HTTP API"); - app = - app.route_layer(axum::middleware::from_fn(move |headers, request, next| { + main_router = main_router.route_layer(axum::middleware::from_fn( + move |headers, request, next| { let user = user.clone(); let pass = pass.clone(); async move { simple_basic_auth(Some(&user), Some(&pass), headers, request, next).await } - })); + }, + )); } if let Some(upnp_router) = upnp_router { - app = app.nest("/upnp", upnp_router); + main_router = main_router.nest("/upnp", upnp_router); } - let app = app + let app = main_router .layer(cors_layer) .layer( tower_http::trace::TraceLayer::new_for_http() From bde8d7cf40b72559d6026b4ef46bf557611b227b Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 10:47:26 +0000 Subject: [PATCH 7/9] Web redirect is relative now --- crates/librqbit/src/http_api.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 19515995..450aa1dc 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -745,8 +745,7 @@ impl HttpApi { ) -> BoxFuture<'static, anyhow::Result<()>> { let state = Arc::new(self); - let mut main_router = Router::new(); - main_router = main_router.nest("/", make_api_router(state.clone())); + let mut main_router = make_api_router(state.clone()); #[cfg(feature = "webui")] { @@ -754,7 +753,7 @@ impl HttpApi { let webui_router = make_webui_router(); main_router = main_router.nest("/web/", webui_router); - main_router = main_router.route("/web", get(|| async { Redirect::permanent("/web/") })) + main_router = main_router.route("/web", get(|| async { Redirect::permanent("./web/") })) } let cors_layer = { From c60f36540e1a3cd1fe40a777d02fc182a86db4fc Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 11:13:42 +0000 Subject: [PATCH 8/9] Split up http_api into files --- crates/librqbit/Cargo.toml | 3 +- crates/librqbit/src/http_api.rs | 951 ------------------ .../src/http_api/handlers/configure.rs | 24 + crates/librqbit/src/http_api/handlers/dht.rs | 12 + .../librqbit/src/http_api/handlers/logging.rs | 21 + crates/librqbit/src/http_api/handlers/mod.rs | 128 +++ .../librqbit/src/http_api/handlers/other.rs | 78 ++ .../src/http_api/handlers/playlist.rs | 111 ++ .../src/http_api/handlers/streaming.rs | 106 ++ .../src/http_api/handlers/torrents.rs | 168 ++++ crates/librqbit/src/http_api/mod.rs | 190 ++++ crates/librqbit/src/http_api/timeout.rs | 49 + crates/librqbit/src/http_api/webui.rs | 41 + crates/librqbit/src/http_api_client.rs | 2 +- crates/librqbit/src/http_api_types.rs | 111 ++ crates/librqbit/src/lib.rs | 4 +- crates/rqbit/Cargo.toml | 1 + 17 files changed, 1046 insertions(+), 954 deletions(-) delete mode 100644 crates/librqbit/src/http_api.rs create mode 100644 crates/librqbit/src/http_api/handlers/configure.rs create mode 100644 crates/librqbit/src/http_api/handlers/dht.rs create mode 100644 crates/librqbit/src/http_api/handlers/logging.rs create mode 100644 crates/librqbit/src/http_api/handlers/mod.rs create mode 100644 crates/librqbit/src/http_api/handlers/other.rs create mode 100644 crates/librqbit/src/http_api/handlers/playlist.rs create mode 100644 crates/librqbit/src/http_api/handlers/streaming.rs create mode 100644 crates/librqbit/src/http_api/handlers/torrents.rs create mode 100644 crates/librqbit/src/http_api/mod.rs create mode 100644 crates/librqbit/src/http_api/timeout.rs create mode 100644 crates/librqbit/src/http_api/webui.rs create mode 100644 crates/librqbit/src/http_api_types.rs diff --git a/crates/librqbit/Cargo.toml b/crates/librqbit/Cargo.toml index 75a058dc..29da0152 100644 --- a/crates/librqbit/Cargo.toml +++ b/crates/librqbit/Cargo.toml @@ -12,9 +12,10 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["default-tls"] +default = ["default-tls", "http-api-client"] tokio-console = ["console-subscriber", "tokio/tracing"] http-api = ["axum", "tower-http"] +http-api-client = [] upnp-serve-adapter = ["upnp-serve"] webui = [] timed_existence = [] diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs deleted file mode 100644 index 450aa1dc..00000000 --- a/crates/librqbit/src/http_api.rs +++ /dev/null @@ -1,951 +0,0 @@ -use anyhow::Context; -use axum::body::Bytes; -use axum::extract::{ConnectInfo, Path, Query, Request, State}; -use axum::middleware::Next; -use axum::response::{IntoResponse, Redirect}; -use axum::routing::{get, post}; -use base64::Engine; -use bencode::AsDisplay; -use buffers::ByteBuf; -use futures::future::BoxFuture; -use futures::{FutureExt, TryStreamExt}; -use http::request::Parts; -use http::{HeaderMap, HeaderValue, StatusCode}; -use itertools::Itertools; - -use librqbit_core::magnet::Magnet; -use serde::{Deserialize, Serialize}; -use std::io::SeekFrom; -use std::net::SocketAddr; -use std::str::FromStr; -use std::sync::{Arc, LazyLock}; -use std::time::Duration; -use tokio::io::AsyncSeekExt; -use tokio::net::TcpListener; -use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, OnFailure}; -use tracing::{debug, error_span, info, trace, Span}; - -use axum::{Json, Router}; - -use crate::api::{Api, ApiTorrentListOpts, EmptyJsonResponse, TorrentIdOrHash}; -use crate::limits::LimitsConfig; -use crate::peer_connection::PeerConnectionOptions; -use crate::session::{AddTorrent, AddTorrentOptions, SUPPORTED_SCHEMES}; -use crate::torrent_state::peer::stats::snapshot::PeerStatsFilter; - -type ApiState = Arc; - -use crate::api::Result; -use crate::{ApiError, ListOnlyResponse, ManagedTorrent}; - -/// An HTTP server for the API. -pub struct HttpApi { - api: Api, - opts: HttpApiOptions, -} - -#[derive(Debug, Default)] -pub struct HttpApiOptions { - pub read_only: bool, - pub basic_auth: Option<(String, String)>, -} - -async fn simple_basic_auth( - expected_username: Option<&str>, - expected_password: Option<&str>, - headers: HeaderMap, - request: axum::extract::Request, - next: Next, -) -> Result { - let (expected_user, expected_pass) = match (expected_username, expected_password) { - (Some(u), Some(p)) => (u, p), - _ => return Ok(next.run(request).await), - }; - let user_pass = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Basic ")) - .and_then(|v| base64::engine::general_purpose::STANDARD.decode(v).ok()) - .and_then(|v| String::from_utf8(v).ok()); - let user_pass = match user_pass { - Some(user_pass) => user_pass, - None => { - return Ok(( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", "Basic realm=\"API\"")], - ) - .into_response()) - } - }; - // TODO: constant time compare - match user_pass.split_once(':') { - Some((u, p)) if u == expected_user && p == expected_pass => Ok(next.run(request).await), - _ => Err(ApiError::unathorized()), - } -} - -mod timeout { - use std::time::Duration; - - use anyhow::Context; - use axum::{extract::Query, RequestPartsExt}; - use http::request::Parts; - use serde::Deserialize; - - use crate::ApiError; - - pub struct Timeout(pub Duration); - - impl axum::extract::FromRequestParts - for Timeout - where - S: Send + Sync, - { - type Rejection = ApiError; - - /// Perform the extraction. - async fn from_request_parts( - parts: &mut Parts, - _state: &S, - ) -> Result { - #[derive(Deserialize)] - struct QueryT { - timeout_ms: Option, - } - - let q = parts - .extract::>() - .await - .context("error running Timeout extractor")?; - - let timeout_ms = q - .timeout_ms - .map(Ok) - .or_else(|| { - parts - .headers - .get("x-req-timeout-ms") - .map(|v| { - std::str::from_utf8(v.as_bytes()) - .context("invalid utf-8 in timeout value") - }) - .map(|v| { - v.and_then(|v| v.parse::().context("invalid timeout integer")) - }) - }) - .transpose() - .context("error parsing timeout")? - .unwrap_or(DEFAULT_MS); - let timeout_ms = timeout_ms.min(MAX_MS); - Ok(Timeout(Duration::from_millis(timeout_ms as u64))) - } - } -} - -use timeout::Timeout; - -async fn h_dht_stats(State(state): State) -> Result { - state.api.api_dht_stats().map(axum::Json) -} - -async fn h_dht_table(State(state): State) -> Result { - state.api.api_dht_table().map(axum::Json) -} - -async fn h_session_stats(State(state): State) -> impl IntoResponse { - axum::Json(state.api.api_session_stats()) -} - -async fn h_torrents_list( - State(state): State, - Query(opts): Query, -) -> impl IntoResponse { - axum::Json(state.api.api_torrent_list_ext(opts)) -} - -async fn h_torrents_post( - State(state): State, - Query(params): Query, - Timeout(timeout): Timeout<600_000, 3_600_000>, - data: Bytes, -) -> Result { - let is_url = params.is_url; - let opts = params.into_add_torrent_options(); - let data = data.to_vec(); - let maybe_magnet = |data: &[u8]| -> bool { - std::str::from_utf8(data) - .ok() - .and_then(|s| Magnet::parse(s).ok()) - .is_some() - }; - let add = match is_url { - Some(true) => AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ), - Some(false) => AddTorrent::TorrentFileBytes(data.into()), - - // Guess the format. - None if SUPPORTED_SCHEMES - .iter() - .any(|s| data.starts_with(s.as_bytes())) - || maybe_magnet(&data) => - { - AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ) - } - _ => AddTorrent::TorrentFileBytes(data.into()), - }; - tokio::time::timeout(timeout, state.api.api_add_torrent(add, Some(opts))) - .await - .context("timeout")? - .map(axum::Json) -} - -async fn h_torrent_details( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_torrent_details(idx).map(axum::Json) -} - -fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { - let mut playlist_items = handle - .metadata - .load() - .as_ref() - .context("torrent metadata not resolved")? - .info - .iter_file_details()? - .enumerate() - .filter_map(|(file_idx, file_details)| { - let filename = file_details.filename.to_vec().ok()?.join("/"); - let is_playable = mime_guess::from_path(&filename) - .first() - .map(|mime| { - mime.type_() == mime_guess::mime::VIDEO - || mime.type_() == mime_guess::mime::AUDIO - }) - .unwrap_or(false); - if is_playable { - let filename = urlencoding::encode(&filename); - Some((file_idx, filename.into_owned())) - } else { - None - } - }) - .collect::>(); - playlist_items.sort_by(|left, right| left.1.cmp(&right.1)); - Ok(playlist_items) -} - -fn get_host(headers: &HeaderMap) -> Result<&str> { - Ok(headers - .get("host") - .ok_or_else(|| ApiError::new_from_text(StatusCode::BAD_REQUEST, "Missing host header"))? - .to_str() - .context("hostname is not string")?) -} - -fn build_playlist_content( - host: &str, - it: impl IntoIterator, -) -> impl IntoResponse { - let body = it - .into_iter() - .map(|(torrent_idx, file_idx, filename)| { - // TODO: add #EXTINF:{duration} and maybe codecs ? - format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}") - }) - .join("\r\n"); - ( - [ - ("Content-Type", "application/mpegurl; charset=utf-8"), - ( - "Content-Disposition", - "attachment; filename=\"rqbit-playlist.m3u8\"", - ), - ], - format!("#EXTM3U\r\n{body}"), // https://en.wikipedia.org/wiki/M3U - ) -} - -async fn h_resolve_magnet( - State(state): State, - Timeout(timeout): Timeout<600_000, 3_600_000>, - inp_headers: HeaderMap, - url: String, -) -> Result { - let added = tokio::time::timeout( - timeout, - state.api.session().add_torrent( - AddTorrent::from_url(&url), - Some(AddTorrentOptions { - list_only: true, - ..Default::default() - }), - ), - ) - .await - .context("timeout")??; - - let (info, content) = match added { - crate::AddTorrentResponse::AlreadyManaged(_, handle) => { - handle.with_metadata(|r| (r.info.clone(), r.torrent_bytes.clone()))? - } - crate::AddTorrentResponse::ListOnly(ListOnlyResponse { - info, - torrent_bytes, - .. - }) => (info, torrent_bytes), - crate::AddTorrentResponse::Added(_, _) => { - return Err(ApiError::new_from_text( - StatusCode::INTERNAL_SERVER_ERROR, - "bug: torrent was added to session, but shouldn't have been", - )) - } - }; - - let mut headers = HeaderMap::new(); - - if inp_headers - .get("Accept") - .and_then(|v| std::str::from_utf8(v.as_bytes()).ok()) - == Some("application/json") - { - let data = bencode::dyn_from_bytes::>(&content) - .context("error decoding .torrent file content")?; - let data = serde_json::to_string(&data).context("error serializing")?; - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - return Ok((headers, data).into_response()); - } - - headers.insert( - "Content-Type", - HeaderValue::from_static("application/x-bittorrent"), - ); - - if let Some(name) = info.name.as_ref() { - if let Ok(name) = std::str::from_utf8(name) { - if let Ok(h) = - HeaderValue::from_str(&format!("attachment; filename=\"{}.torrent\"", name)) - { - headers.insert("Content-Disposition", h); - } - } - } - Ok((headers, content).into_response()) -} - -async fn h_torrent_playlist( - State(state): State, - headers: HeaderMap, - Path(idx): Path, -) -> Result { - let host = get_host(&headers)?; - let playlist_items = torrent_playlist_items(&*state.api.mgr_handle(idx)?)?; - Ok(build_playlist_content( - host, - playlist_items - .into_iter() - .map(move |(file_idx, filename)| (idx, file_idx, filename)), - )) -} - -async fn h_global_playlist( - State(state): State, - headers: HeaderMap, -) -> Result { - let host = get_host(&headers)?; - let all_items = state.api.session().with_torrents(|torrents| { - torrents - .filter_map(|(torrent_idx, handle)| { - torrent_playlist_items(handle) - .map(move |items| { - items.into_iter().map(move |(file_idx, filename)| { - (torrent_idx.into(), file_idx, filename) - }) - }) - .ok() - }) - .flatten() - .collect::>() - }); - Ok(build_playlist_content(host, all_items)) -} - -async fn h_torrent_haves( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_dump_haves(idx) -} - -async fn h_torrent_stats_v0( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_stats_v0(idx).map(axum::Json) -} - -async fn h_torrent_stats_v1( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_stats_v1(idx).map(axum::Json) -} - -async fn h_peer_stats( - State(state): State, - Path(idx): Path, - Query(filter): Query, -) -> Result { - state.api.api_peer_stats(idx, filter).map(axum::Json) -} - -#[derive(Deserialize)] -struct StreamPathParams { - id: TorrentIdOrHash, - file_id: usize, - #[serde(rename = "filename")] - _filename: Option>, -} - -async fn h_torrent_stream_file( - State(state): State, - Path(StreamPathParams { id, file_id, .. }): Path, - headers: http::HeaderMap, -) -> Result { - let mut stream = state.api.api_stream(id, file_id)?; - let mut status = StatusCode::OK; - let mut output_headers = HeaderMap::new(); - output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - - const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; - const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; - const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; - - if headers - .get(DLNA_TRANSFER_MODE) - .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) - .unwrap_or(false) - { - output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); - } - - if headers - .get(DLNA_GET_CONTENT_FEATURES) - .map(|v| v.as_bytes() == b"1") - .unwrap_or(false) - { - output_headers.insert( - DLNA_CONTENT_FEATURES, - HeaderValue::from_static("DLNA.ORG_OP=01"), - ); - } - - if let Ok(mime) = state.api.torrent_file_mime_type(id, file_id) { - output_headers.insert( - http::header::CONTENT_TYPE, - HeaderValue::from_str(mime).context("bug - invalid MIME")?, - ); - } - - let range_header = headers.get(http::header::RANGE); - trace!(torrent_id=%id, file_id=file_id, range=?range_header, "request for HTTP stream"); - - if let Some(range) = range_header { - let offset: Option = range - .to_str() - .ok() - .and_then(|s| s.strip_prefix("bytes=")) - .and_then(|s| s.strip_suffix('-')) - .and_then(|s| s.parse().ok()); - if let Some(offset) = offset { - status = StatusCode::PARTIAL_CONTENT; - stream - .seek(SeekFrom::Start(offset)) - .await - .context("error seeking")?; - - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_str(&format!("{}", stream.len() - stream.position())) - .context("bug")?, - ); - output_headers.insert( - http::header::CONTENT_RANGE, - HeaderValue::from_str(&format!( - "bytes {}-{}/{}", - stream.position(), - stream.len().saturating_sub(1), - stream.len() - )) - .context("bug")?, - ); - } - } else { - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_str(&format!("{}", stream.len())).context("bug")?, - ); - } - - let s = tokio_util::io::ReaderStream::with_capacity(stream, 65536); - Ok((status, (output_headers, axum::body::Body::from_stream(s)))) -} - -async fn h_torrent_action_pause( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_pause(idx) - .await - .map(axum::Json) -} - -async fn h_torrent_action_start( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_start(idx) - .await - .map(axum::Json) -} - -async fn h_torrent_action_forget( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_forget(idx) - .await - .map(axum::Json) -} - -async fn h_torrent_action_delete( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_delete(idx) - .await - .map(axum::Json) -} - -#[derive(Deserialize)] -struct UpdateOnlyFilesRequest { - only_files: Vec, -} - -async fn h_torrent_action_update_only_files( - State(state): State, - Path(idx): Path, - axum::Json(req): axum::Json, -) -> Result { - state - .api - .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) - .await - .map(axum::Json) -} - -async fn h_set_rust_log( - State(state): State, - new_value: String, -) -> Result { - state.api.api_set_rust_log(new_value).map(axum::Json) -} - -async fn h_stream_logs(State(state): State) -> Result { - let s = state.api.api_log_lines_stream()?.map_err(|e| { - debug!(error=%e, "stream_logs"); - e - }); - Ok(axum::body::Body::from_stream(s)) -} - -async fn h_update_session_ratelimits( - State(state): State, - Json(limits): Json, -) -> Result { - state - .api - .session() - .ratelimits - .set_upload_bps(limits.upload_bps); - state - .api - .session() - .ratelimits - .set_download_bps(limits.download_bps); - Ok(Json(EmptyJsonResponse {})) -} - -async fn h_api_root(parts: Parts) -> impl IntoResponse { - // If browser, and webui enabled, redirect to web - #[cfg(feature = "webui")] - { - if parts - .headers - .get("Accept") - .and_then(|h| h.to_str().ok()) - .map_or(false, |h| h.contains("text/html")) - { - return Redirect::temporary("./web/").into_response(); - } - } - - static API_ROOT_JSON: LazyLock> = LazyLock::new(|| { - Arc::new(serde_json::json!({ - "apis": { - "GET /": "list all available APIs", - "GET /dht/stats": "DHT stats", - "GET /dht/table": "DHT routing table", - "GET /torrents": "List torrents", - "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", - "GET /stats": "Global session stats", - "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", - "GET /torrents/{id_or_infohash}": "Torrent details", - "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", - "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", - "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", - "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", - "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", - "POST /torrents/{id_or_infohash}/pause": "Pause torrent", - "POST /torrents/{id_or_infohash}/start": "Resume torrent", - "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", - "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", - "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", - "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", - "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", - "GET /web/": "Web UI", - }, - "server": "rqbit", - "version": env!("CARGO_PKG_VERSION"), - })) - }); - - ( - [("Content-Type", "application/json")], - axum::Json(API_ROOT_JSON.clone()), - ) - .into_response() -} - -fn make_api_router(state: ApiState) -> Router { - let mut api_router = Router::new() - .route("/", get(h_api_root)) - .route("/stream_logs", get(h_stream_logs)) - .route("/rust_log", post(h_set_rust_log)) - .route("/dht/stats", get(h_dht_stats)) - .route("/dht/table", get(h_dht_table)) - .route("/stats", get(h_session_stats)) - .route("/torrents", get(h_torrents_list)) - .route("/torrents/{id}", get(h_torrent_details)) - .route("/torrents/{id}/haves", get(h_torrent_haves)) - .route("/torrents/{id}/stats", get(h_torrent_stats_v0)) - .route("/torrents/{id}/stats/v1", get(h_torrent_stats_v1)) - .route("/torrents/{id}/peer_stats", get(h_peer_stats)) - .route("/torrents/{id}/playlist", get(h_torrent_playlist)) - .route("/torrents/playlist", get(h_global_playlist)) - .route("/torrents/resolve_magnet", post(h_resolve_magnet)) - .route( - "/torrents/{id}/stream/{file_id}", - get(h_torrent_stream_file), - ) - .route( - "/torrents/{id}/stream/{file_id}/{*filename}", - get(h_torrent_stream_file), - ); - - if !state.opts.read_only { - api_router = api_router - .route("/torrents", post(h_torrents_post)) - .route("/torrents/limits", post(h_update_session_ratelimits)) - .route("/torrents/{id}/pause", post(h_torrent_action_pause)) - .route("/torrents/{id}/start", post(h_torrent_action_start)) - .route("/torrents/{id}/forget", post(h_torrent_action_forget)) - .route("/torrents/{id}/delete", post(h_torrent_action_delete)) - .route( - "/torrents/{id}/update_only_files", - post(h_torrent_action_update_only_files), - ); - } - - api_router.with_state(state) -} - -#[cfg(feature = "webui")] -fn make_webui_router() -> Router { - Router::new() - .route( - "/", - get(|| async { - ( - [("Content-Type", "text/html")], - include_str!("../webui/dist/index.html"), - ) - }), - ) - .route( - "/assets/index.js", - get(|| async { - ( - [("Content-Type", "application/javascript")], - include_str!("../webui/dist/assets/index.js"), - ) - }), - ) - .route( - "/assets/index.css", - get(|| async { - ( - [("Content-Type", "text/css")], - include_str!("../webui/dist/assets/index.css"), - ) - }), - ) - .route( - "/assets/logo.svg", - get(|| async { - ( - [("Content-Type", "image/svg+xml")], - include_str!("../webui/dist/assets/logo.svg"), - ) - }), - ) -} - -impl HttpApi { - pub fn new(api: Api, opts: Option) -> Self { - Self { - api, - opts: opts.unwrap_or_default(), - } - } - - /// Run the HTTP server forever on the given address. - /// If read_only is passed, no state-modifying methods will be exposed. - #[inline(never)] - pub fn make_http_api_and_run( - self, - listener: TcpListener, - upnp_router: Option, - ) -> BoxFuture<'static, anyhow::Result<()>> { - let state = Arc::new(self); - - let mut main_router = make_api_router(state.clone()); - - #[cfg(feature = "webui")] - { - use axum::response::Redirect; - - let webui_router = make_webui_router(); - main_router = main_router.nest("/web/", webui_router); - main_router = main_router.route("/web", get(|| async { Redirect::permanent("./web/") })) - } - - let cors_layer = { - use tower_http::cors::{AllowHeaders, AllowOrigin}; - - const ALLOWED_ORIGINS: [&[u8]; 4] = [ - // Webui-dev - b"http://localhost:3031", - b"http://127.0.0.1:3031", - // Tauri dev - b"http://localhost:1420", - // Tauri prod - b"tauri://localhost", - ]; - - let allow_regex = std::env::var("CORS_ALLOW_REGEXP") - .ok() - .and_then(|value| regex::bytes::Regex::new(&value).ok()); - - tower_http::cors::CorsLayer::default() - .allow_origin(AllowOrigin::predicate(move |v, _| { - ALLOWED_ORIGINS.contains(&v.as_bytes()) - || allow_regex - .as_ref() - .map(move |r| r.is_match(v.as_bytes())) - .unwrap_or(false) - })) - .allow_headers(AllowHeaders::any()) - }; - - // Simple one-user basic auth - if let Some((user, pass)) = state.opts.basic_auth.clone() { - info!("Enabling simple basic authentication in HTTP API"); - main_router = main_router.route_layer(axum::middleware::from_fn( - move |headers, request, next| { - let user = user.clone(); - let pass = pass.clone(); - async move { - simple_basic_auth(Some(&user), Some(&pass), headers, request, next).await - } - }, - )); - } - - if let Some(upnp_router) = upnp_router { - main_router = main_router.nest("/upnp", upnp_router); - } - - let app = main_router - .layer(cors_layer) - .layer( - tower_http::trace::TraceLayer::new_for_http() - .make_span_with(|req: &Request| { - let method = req.method(); - let uri = req.uri(); - if let Some(ConnectInfo(addr)) = - req.extensions().get::>() - { - let addr = SocketAddr::new(addr.ip().to_canonical(), addr.port()); - error_span!("request", %method, %uri, %addr) - } else { - error_span!("request", %method, %uri) - } - }) - .on_request(|req: &Request, _: &Span| { - if req.uri().path().starts_with("/upnp") { - debug!(headers=?req.headers()) - } - }) - .on_response(DefaultOnResponse::new().include_headers(true)) - .on_failure({ - let mut default = DefaultOnFailure::new(); - move |failure_class, latency, span: &Span| match failure_class { - tower_http::classify::ServerErrorsFailureClass::StatusCode( - StatusCode::NOT_IMPLEMENTED, - ) => {} - _ => default.on_failure(failure_class, latency, span), - } - }), - ) - .into_make_service_with_connect_info::(); - - async move { - axum::serve(listener, app) - .await - .context("error running HTTP API") - } - .boxed() - } -} - -pub(crate) struct OnlyFiles(Vec); -pub(crate) struct InitialPeers(pub Vec); - -#[derive(Serialize, Deserialize, Default)] -pub(crate) struct TorrentAddQueryParams { - pub overwrite: Option, - pub output_folder: Option, - pub sub_folder: Option, - pub only_files_regex: Option, - pub only_files: Option, - pub peer_connect_timeout: Option, - pub peer_read_write_timeout: Option, - pub initial_peers: Option, - // Will force interpreting the content as a URL. - pub is_url: Option, - pub list_only: Option, -} - -impl Serialize for OnlyFiles { - fn serialize(&self, serializer: S) -> core::result::Result - where - S: serde::Serializer, - { - let s = self.0.iter().map(|id| id.to_string()).join(","); - s.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for OnlyFiles { - fn deserialize(deserializer: D) -> core::result::Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - - let s = String::deserialize(deserializer)?; - let list = s - .split(',') - .try_fold(Vec::::new(), |mut acc, c| match c.parse() { - Ok(i) => { - acc.push(i); - Ok(acc) - } - Err(_) => Err(D::Error::custom(format!( - "only_files: failed to parse {:?} as integer", - c - ))), - })?; - if list.is_empty() { - return Err(D::Error::custom( - "only_files: should contain at least one file id", - )); - } - Ok(OnlyFiles(list)) - } -} - -impl<'de> Deserialize<'de> for InitialPeers { - fn deserialize(deserializer: D) -> std::prelude::v1::Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - let string = String::deserialize(deserializer)?; - let mut addrs = Vec::new(); - for addr_str in string.split(',').filter(|s| !s.is_empty()) { - addrs.push(SocketAddr::from_str(addr_str).map_err(D::Error::custom)?); - } - Ok(InitialPeers(addrs)) - } -} - -impl Serialize for InitialPeers { - fn serialize(&self, serializer: S) -> std::prelude::v1::Result - where - S: serde::Serializer, - { - self.0 - .iter() - .map(|s| s.to_string()) - .join(",") - .serialize(serializer) - } -} - -impl TorrentAddQueryParams { - pub fn into_add_torrent_options(self) -> AddTorrentOptions { - AddTorrentOptions { - overwrite: self.overwrite.unwrap_or(false), - only_files_regex: self.only_files_regex, - only_files: self.only_files.map(|o| o.0), - output_folder: self.output_folder, - sub_folder: self.sub_folder, - list_only: self.list_only.unwrap_or(false), - initial_peers: self.initial_peers.map(|i| i.0), - peer_opts: Some(PeerConnectionOptions { - connect_timeout: self.peer_connect_timeout.map(Duration::from_secs), - read_write_timeout: self.peer_read_write_timeout.map(Duration::from_secs), - ..Default::default() - }), - ..Default::default() - } - } -} diff --git a/crates/librqbit/src/http_api/handlers/configure.rs b/crates/librqbit/src/http_api/handlers/configure.rs new file mode 100644 index 00000000..dd770e2f --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/configure.rs @@ -0,0 +1,24 @@ +use axum::{extract::State, response::IntoResponse, Json}; + +use super::ApiState; +use crate::{ + api::{EmptyJsonResponse, Result}, + limits::LimitsConfig, +}; + +pub async fn h_update_session_ratelimits( + State(state): State, + Json(limits): Json, +) -> Result { + state + .api + .session() + .ratelimits + .set_upload_bps(limits.upload_bps); + state + .api + .session() + .ratelimits + .set_download_bps(limits.download_bps); + Ok(Json(EmptyJsonResponse {})) +} diff --git a/crates/librqbit/src/http_api/handlers/dht.rs b/crates/librqbit/src/http_api/handlers/dht.rs new file mode 100644 index 00000000..3964b021 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/dht.rs @@ -0,0 +1,12 @@ +use axum::{extract::State, response::IntoResponse}; + +use super::ApiState; +use crate::api::Result; + +pub async fn h_dht_stats(State(state): State) -> Result { + state.api.api_dht_stats().map(axum::Json) +} + +pub async fn h_dht_table(State(state): State) -> Result { + state.api.api_dht_table().map(axum::Json) +} diff --git a/crates/librqbit/src/http_api/handlers/logging.rs b/crates/librqbit/src/http_api/handlers/logging.rs new file mode 100644 index 00000000..5190afe8 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/logging.rs @@ -0,0 +1,21 @@ +use axum::{extract::State, response::IntoResponse}; +use futures::TryStreamExt; +use tracing::debug; + +use super::ApiState; +use crate::api::Result; + +pub async fn h_set_rust_log( + State(state): State, + new_value: String, +) -> Result { + state.api.api_set_rust_log(new_value).map(axum::Json) +} + +pub async fn h_stream_logs(State(state): State) -> Result { + let s = state.api.api_log_lines_stream()?.map_err(|e| { + debug!(error=%e, "stream_logs"); + e + }); + Ok(axum::body::Body::from_stream(s)) +} diff --git a/crates/librqbit/src/http_api/handlers/mod.rs b/crates/librqbit/src/http_api/handlers/mod.rs new file mode 100644 index 00000000..256b64e4 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/mod.rs @@ -0,0 +1,128 @@ +mod configure; +mod dht; +mod logging; +mod other; +mod playlist; +mod streaming; +mod torrents; + +use std::sync::{Arc, LazyLock}; + +use axum::{ + response::{IntoResponse, Redirect}, + routing::{get, post}, + Router, +}; +use http::request::Parts; + +use super::HttpApi; +type ApiState = Arc; + +async fn h_api_root(parts: Parts) -> impl IntoResponse { + // If browser, and webui enabled, redirect to web + #[cfg(feature = "webui")] + { + if parts + .headers + .get("Accept") + .and_then(|h| h.to_str().ok()) + .map_or(false, |h| h.contains("text/html")) + { + return Redirect::temporary("./web/").into_response(); + } + } + + static API_ROOT_JSON: LazyLock> = LazyLock::new(|| { + Arc::new(serde_json::json!({ + "apis": { + "GET /": "list all available APIs", + "GET /dht/stats": "DHT stats", + "GET /dht/table": "DHT routing table", + "GET /torrents": "List torrents", + "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", + "GET /stats": "Global session stats", + "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", + "GET /torrents/{id_or_infohash}": "Torrent details", + "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", + "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", + "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", + "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", + "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", + "POST /torrents/{id_or_infohash}/pause": "Pause torrent", + "POST /torrents/{id_or_infohash}/start": "Resume torrent", + "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", + "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", + "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", + "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", + "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", + "GET /web/": "Web UI", + }, + "server": "rqbit", + "version": env!("CARGO_PKG_VERSION"), + })) + }); + + ( + [("Content-Type", "application/json")], + axum::Json(API_ROOT_JSON.clone()), + ) + .into_response() +} + +pub fn make_api_router(state: ApiState) -> Router { + let mut api_router = Router::new() + .route("/", get(h_api_root)) + .route("/stream_logs", get(logging::h_stream_logs)) + .route("/rust_log", post(logging::h_set_rust_log)) + .route("/dht/stats", get(dht::h_dht_stats)) + .route("/dht/table", get(dht::h_dht_table)) + .route("/stats", get(torrents::h_session_stats)) + .route("/torrents", get(torrents::h_torrents_list)) + .route("/torrents/{id}", get(torrents::h_torrent_details)) + .route("/torrents/{id}/haves", get(torrents::h_torrent_haves)) + .route("/torrents/{id}/stats", get(torrents::h_torrent_stats_v0)) + .route("/torrents/{id}/stats/v1", get(torrents::h_torrent_stats_v1)) + .route("/torrents/{id}/peer_stats", get(torrents::h_peer_stats)) + .route("/torrents/{id}/playlist", get(playlist::h_torrent_playlist)) + .route("/torrents/playlist", get(playlist::h_global_playlist)) + .route("/torrents/resolve_magnet", post(other::h_resolve_magnet)) + .route( + "/torrents/{id}/stream/{file_id}", + get(streaming::h_torrent_stream_file), + ) + .route( + "/torrents/{id}/stream/{file_id}/{*filename}", + get(streaming::h_torrent_stream_file), + ); + + if !state.opts.read_only { + api_router = api_router + .route("/torrents", post(torrents::h_torrents_post)) + .route( + "/torrents/limits", + post(configure::h_update_session_ratelimits), + ) + .route( + "/torrents/{id}/pause", + post(torrents::h_torrent_action_pause), + ) + .route( + "/torrents/{id}/start", + post(torrents::h_torrent_action_start), + ) + .route( + "/torrents/{id}/forget", + post(torrents::h_torrent_action_forget), + ) + .route( + "/torrents/{id}/delete", + post(torrents::h_torrent_action_delete), + ) + .route( + "/torrents/{id}/update_only_files", + post(torrents::h_torrent_action_update_only_files), + ); + } + + api_router.with_state(state) +} diff --git a/crates/librqbit/src/http_api/handlers/other.rs b/crates/librqbit/src/http_api/handlers/other.rs new file mode 100644 index 00000000..80e18991 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/other.rs @@ -0,0 +1,78 @@ +use anyhow::Context; +use axum::{extract::State, response::IntoResponse}; +use bencode::AsDisplay; +use buffers::ByteBuf; +use http::{HeaderMap, HeaderValue, StatusCode}; + +use super::ApiState; +use crate::{ + api::Result, http_api::timeout::Timeout, AddTorrent, AddTorrentOptions, ApiError, + ListOnlyResponse, +}; + +pub async fn h_resolve_magnet( + State(state): State, + Timeout(timeout): Timeout<600_000, 3_600_000>, + inp_headers: HeaderMap, + url: String, +) -> Result { + let added = tokio::time::timeout( + timeout, + state.api.session().add_torrent( + AddTorrent::from_url(&url), + Some(AddTorrentOptions { + list_only: true, + ..Default::default() + }), + ), + ) + .await + .context("timeout")??; + + let (info, content) = match added { + crate::AddTorrentResponse::AlreadyManaged(_, handle) => { + handle.with_metadata(|r| (r.info.clone(), r.torrent_bytes.clone()))? + } + crate::AddTorrentResponse::ListOnly(ListOnlyResponse { + info, + torrent_bytes, + .. + }) => (info, torrent_bytes), + crate::AddTorrentResponse::Added(_, _) => { + return Err(ApiError::new_from_text( + StatusCode::INTERNAL_SERVER_ERROR, + "bug: torrent was added to session, but shouldn't have been", + )) + } + }; + + let mut headers = HeaderMap::new(); + + if inp_headers + .get("Accept") + .and_then(|v| std::str::from_utf8(v.as_bytes()).ok()) + == Some("application/json") + { + let data = bencode::dyn_from_bytes::>(&content) + .context("error decoding .torrent file content")?; + let data = serde_json::to_string(&data).context("error serializing")?; + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + return Ok((headers, data).into_response()); + } + + headers.insert( + "Content-Type", + HeaderValue::from_static("application/x-bittorrent"), + ); + + if let Some(name) = info.name.as_ref() { + if let Ok(name) = std::str::from_utf8(name) { + if let Ok(h) = + HeaderValue::from_str(&format!("attachment; filename=\"{}.torrent\"", name)) + { + headers.insert("Content-Disposition", h); + } + } + } + Ok((headers, content).into_response()) +} diff --git a/crates/librqbit/src/http_api/handlers/playlist.rs b/crates/librqbit/src/http_api/handlers/playlist.rs new file mode 100644 index 00000000..348d8329 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/playlist.rs @@ -0,0 +1,111 @@ +use anyhow::Context; +use axum::{ + extract::{Path, State}, + response::IntoResponse, +}; +use http::{HeaderMap, StatusCode}; +use itertools::Itertools; + +use super::ApiState; +use crate::{ + api::{Result, TorrentIdOrHash}, + ApiError, ManagedTorrent, +}; + +fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { + let mut playlist_items = handle + .metadata + .load() + .as_ref() + .context("torrent metadata not resolved")? + .info + .iter_file_details()? + .enumerate() + .filter_map(|(file_idx, file_details)| { + let filename = file_details.filename.to_vec().ok()?.join("/"); + let is_playable = mime_guess::from_path(&filename) + .first() + .map(|mime| { + mime.type_() == mime_guess::mime::VIDEO + || mime.type_() == mime_guess::mime::AUDIO + }) + .unwrap_or(false); + if is_playable { + let filename = urlencoding::encode(&filename); + Some((file_idx, filename.into_owned())) + } else { + None + } + }) + .collect::>(); + playlist_items.sort_by(|left, right| left.1.cmp(&right.1)); + Ok(playlist_items) +} + +fn get_host(headers: &HeaderMap) -> Result<&str> { + Ok(headers + .get("host") + .ok_or_else(|| ApiError::new_from_text(StatusCode::BAD_REQUEST, "Missing host header"))? + .to_str() + .context("hostname is not string")?) +} + +fn build_playlist_content( + host: &str, + it: impl IntoIterator, +) -> impl IntoResponse { + let body = it + .into_iter() + .map(|(torrent_idx, file_idx, filename)| { + // TODO: add #EXTINF:{duration} and maybe codecs ? + format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}") + }) + .join("\r\n"); + ( + [ + ("Content-Type", "application/mpegurl; charset=utf-8"), + ( + "Content-Disposition", + "attachment; filename=\"rqbit-playlist.m3u8\"", + ), + ], + format!("#EXTM3U\r\n{body}"), // https://en.wikipedia.org/wiki/M3U + ) +} + +pub async fn h_torrent_playlist( + State(state): State, + headers: HeaderMap, + Path(idx): Path, +) -> Result { + let host = get_host(&headers)?; + let playlist_items = torrent_playlist_items(&*state.api.mgr_handle(idx)?)?; + Ok(build_playlist_content( + host, + playlist_items + .into_iter() + .map(move |(file_idx, filename)| (idx, file_idx, filename)), + )) +} + +pub async fn h_global_playlist( + State(state): State, + headers: HeaderMap, +) -> Result { + let host = get_host(&headers)?; + let all_items = state.api.session().with_torrents(|torrents| { + torrents + .filter_map(|(torrent_idx, handle)| { + torrent_playlist_items(handle) + .map(move |items| { + items.into_iter().map(move |(file_idx, filename)| { + (torrent_idx.into(), file_idx, filename) + }) + }) + .ok() + }) + .flatten() + .collect::>() + }); + Ok(build_playlist_content(host, all_items)) +} diff --git a/crates/librqbit/src/http_api/handlers/streaming.rs b/crates/librqbit/src/http_api/handlers/streaming.rs new file mode 100644 index 00000000..22060b68 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/streaming.rs @@ -0,0 +1,106 @@ +use std::{io::SeekFrom, sync::Arc}; + +use anyhow::Context; +use axum::{ + extract::{Path, State}, + response::IntoResponse, +}; +use http::{HeaderMap, HeaderValue, StatusCode}; +use serde::Deserialize; +use tokio::io::AsyncSeekExt; +use tracing::trace; + +use super::ApiState; +use crate::api::{Result, TorrentIdOrHash}; + +#[derive(Deserialize)] +pub struct StreamPathParams { + id: TorrentIdOrHash, + file_id: usize, + #[serde(rename = "filename")] + _filename: Option>, +} + +pub async fn h_torrent_stream_file( + State(state): State, + Path(StreamPathParams { id, file_id, .. }): Path, + headers: http::HeaderMap, +) -> Result { + let mut stream = state.api.api_stream(id, file_id)?; + let mut status = StatusCode::OK; + let mut output_headers = HeaderMap::new(); + output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + + const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; + const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; + const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; + + if headers + .get(DLNA_TRANSFER_MODE) + .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) + .unwrap_or(false) + { + output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); + } + + if headers + .get(DLNA_GET_CONTENT_FEATURES) + .map(|v| v.as_bytes() == b"1") + .unwrap_or(false) + { + output_headers.insert( + DLNA_CONTENT_FEATURES, + HeaderValue::from_static("DLNA.ORG_OP=01"), + ); + } + + if let Ok(mime) = state.api.torrent_file_mime_type(id, file_id) { + output_headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_str(mime).context("bug - invalid MIME")?, + ); + } + + let range_header = headers.get(http::header::RANGE); + trace!(torrent_id=%id, file_id=file_id, range=?range_header, "request for HTTP stream"); + + if let Some(range) = range_header { + let offset: Option = range + .to_str() + .ok() + .and_then(|s| s.strip_prefix("bytes=")) + .and_then(|s| s.strip_suffix('-')) + .and_then(|s| s.parse().ok()); + if let Some(offset) = offset { + status = StatusCode::PARTIAL_CONTENT; + stream + .seek(SeekFrom::Start(offset)) + .await + .context("error seeking")?; + + output_headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_str(&format!("{}", stream.len() - stream.position())) + .context("bug")?, + ); + output_headers.insert( + http::header::CONTENT_RANGE, + HeaderValue::from_str(&format!( + "bytes {}-{}/{}", + stream.position(), + stream.len().saturating_sub(1), + stream.len() + )) + .context("bug")?, + ); + } + } else { + output_headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_str(&format!("{}", stream.len())).context("bug")?, + ); + } + + let s = tokio_util::io::ReaderStream::with_capacity(stream, 65536); + Ok((status, (output_headers, axum::body::Body::from_stream(s)))) +} diff --git a/crates/librqbit/src/http_api/handlers/torrents.rs b/crates/librqbit/src/http_api/handlers/torrents.rs new file mode 100644 index 00000000..75ffb098 --- /dev/null +++ b/crates/librqbit/src/http_api/handlers/torrents.rs @@ -0,0 +1,168 @@ +use anyhow::Context; +use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, +}; +use bytes::Bytes; +use librqbit_core::magnet::Magnet; +use serde::Deserialize; + +use super::ApiState; +use crate::{ + api::{ApiTorrentListOpts, Result, TorrentIdOrHash}, + http_api::timeout::Timeout, + http_api_types::TorrentAddQueryParams, + torrent_state::peer::stats::snapshot::PeerStatsFilter, + AddTorrent, SUPPORTED_SCHEMES, +}; + +pub async fn h_torrents_list( + State(state): State, + Query(opts): Query, +) -> impl IntoResponse { + axum::Json(state.api.api_torrent_list_ext(opts)) +} + +pub async fn h_torrents_post( + State(state): State, + Query(params): Query, + Timeout(timeout): Timeout<600_000, 3_600_000>, + data: Bytes, +) -> Result { + let is_url = params.is_url; + let opts = params.into_add_torrent_options(); + let data = data.to_vec(); + let maybe_magnet = |data: &[u8]| -> bool { + std::str::from_utf8(data) + .ok() + .and_then(|s| Magnet::parse(s).ok()) + .is_some() + }; + let add = match is_url { + Some(true) => AddTorrent::Url( + String::from_utf8(data) + .context("invalid utf-8 for passed URL")? + .into(), + ), + Some(false) => AddTorrent::TorrentFileBytes(data.into()), + + // Guess the format. + None if SUPPORTED_SCHEMES + .iter() + .any(|s| data.starts_with(s.as_bytes())) + || maybe_magnet(&data) => + { + AddTorrent::Url( + String::from_utf8(data) + .context("invalid utf-8 for passed URL")? + .into(), + ) + } + _ => AddTorrent::TorrentFileBytes(data.into()), + }; + tokio::time::timeout(timeout, state.api.api_add_torrent(add, Some(opts))) + .await + .context("timeout")? + .map(axum::Json) +} + +pub async fn h_torrent_details( + State(state): State, + Path(idx): Path, +) -> Result { + state.api.api_torrent_details(idx).map(axum::Json) +} + +pub async fn h_torrent_haves( + State(state): State, + Path(idx): Path, +) -> Result { + state.api.api_dump_haves(idx) +} + +pub async fn h_torrent_stats_v0( + State(state): State, + Path(idx): Path, +) -> Result { + state.api.api_stats_v0(idx).map(axum::Json) +} + +pub async fn h_torrent_stats_v1( + State(state): State, + Path(idx): Path, +) -> Result { + state.api.api_stats_v1(idx).map(axum::Json) +} + +pub async fn h_peer_stats( + State(state): State, + Path(idx): Path, + Query(filter): Query, +) -> Result { + state.api.api_peer_stats(idx, filter).map(axum::Json) +} + +pub async fn h_torrent_action_pause( + State(state): State, + Path(idx): Path, +) -> Result { + state + .api + .api_torrent_action_pause(idx) + .await + .map(axum::Json) +} + +pub async fn h_torrent_action_start( + State(state): State, + Path(idx): Path, +) -> Result { + state + .api + .api_torrent_action_start(idx) + .await + .map(axum::Json) +} + +pub async fn h_torrent_action_forget( + State(state): State, + Path(idx): Path, +) -> Result { + state + .api + .api_torrent_action_forget(idx) + .await + .map(axum::Json) +} + +pub async fn h_torrent_action_delete( + State(state): State, + Path(idx): Path, +) -> Result { + state + .api + .api_torrent_action_delete(idx) + .await + .map(axum::Json) +} + +#[derive(Deserialize)] +pub struct UpdateOnlyFilesRequest { + only_files: Vec, +} + +pub async fn h_torrent_action_update_only_files( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + state + .api + .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) + .await + .map(axum::Json) +} + +pub async fn h_session_stats(State(state): State) -> impl IntoResponse { + axum::Json(state.api.api_session_stats()) +} diff --git a/crates/librqbit/src/http_api/mod.rs b/crates/librqbit/src/http_api/mod.rs new file mode 100644 index 00000000..25d887eb --- /dev/null +++ b/crates/librqbit/src/http_api/mod.rs @@ -0,0 +1,190 @@ +use anyhow::Context; +use axum::extract::{ConnectInfo, Request}; +use axum::middleware::Next; +use axum::response::IntoResponse; +use axum::routing::get; +use base64::Engine; +use futures::future::BoxFuture; +use futures::FutureExt; +use http::{HeaderMap, StatusCode}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, OnFailure}; +use tracing::{debug, error_span, info, Span}; + +use axum::Router; + +use crate::api::Api; + +use crate::api::Result; +use crate::ApiError; + +mod handlers; +mod timeout; +#[cfg(feature = "webui")] +mod webui; + +/// An HTTP server for the API. +pub struct HttpApi { + api: Api, + opts: HttpApiOptions, +} + +#[derive(Debug, Default)] +pub struct HttpApiOptions { + pub read_only: bool, + pub basic_auth: Option<(String, String)>, +} + +async fn simple_basic_auth( + expected_username: Option<&str>, + expected_password: Option<&str>, + headers: HeaderMap, + request: axum::extract::Request, + next: Next, +) -> Result { + let (expected_user, expected_pass) = match (expected_username, expected_password) { + (Some(u), Some(p)) => (u, p), + _ => return Ok(next.run(request).await), + }; + let user_pass = headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Basic ")) + .and_then(|v| base64::engine::general_purpose::STANDARD.decode(v).ok()) + .and_then(|v| String::from_utf8(v).ok()); + let user_pass = match user_pass { + Some(user_pass) => user_pass, + None => { + return Ok(( + StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "Basic realm=\"API\"")], + ) + .into_response()) + } + }; + // TODO: constant time compare + match user_pass.split_once(':') { + Some((u, p)) if u == expected_user && p == expected_pass => Ok(next.run(request).await), + _ => Err(ApiError::unathorized()), + } +} + +impl HttpApi { + pub fn new(api: Api, opts: Option) -> Self { + Self { + api, + opts: opts.unwrap_or_default(), + } + } + + /// Run the HTTP server forever on the given address. + /// If read_only is passed, no state-modifying methods will be exposed. + #[inline(never)] + pub fn make_http_api_and_run( + self, + listener: TcpListener, + upnp_router: Option, + ) -> BoxFuture<'static, anyhow::Result<()>> { + let state = Arc::new(self); + + let mut main_router = handlers::make_api_router(state.clone()); + + #[cfg(feature = "webui")] + { + use axum::response::Redirect; + + let webui_router = webui::make_webui_router(); + main_router = main_router.nest("/web/", webui_router); + main_router = main_router.route("/web", get(|| async { Redirect::permanent("./web/") })) + } + + let cors_layer = { + use tower_http::cors::{AllowHeaders, AllowOrigin}; + + const ALLOWED_ORIGINS: [&[u8]; 4] = [ + // Webui-dev + b"http://localhost:3031", + b"http://127.0.0.1:3031", + // Tauri dev + b"http://localhost:1420", + // Tauri prod + b"tauri://localhost", + ]; + + let allow_regex = std::env::var("CORS_ALLOW_REGEXP") + .ok() + .and_then(|value| regex::bytes::Regex::new(&value).ok()); + + tower_http::cors::CorsLayer::default() + .allow_origin(AllowOrigin::predicate(move |v, _| { + ALLOWED_ORIGINS.contains(&v.as_bytes()) + || allow_regex + .as_ref() + .map(move |r| r.is_match(v.as_bytes())) + .unwrap_or(false) + })) + .allow_headers(AllowHeaders::any()) + }; + + // Simple one-user basic auth + if let Some((user, pass)) = state.opts.basic_auth.clone() { + info!("Enabling simple basic authentication in HTTP API"); + main_router = main_router.route_layer(axum::middleware::from_fn( + move |headers, request, next| { + let user = user.clone(); + let pass = pass.clone(); + async move { + simple_basic_auth(Some(&user), Some(&pass), headers, request, next).await + } + }, + )); + } + + if let Some(upnp_router) = upnp_router { + main_router = main_router.nest("/upnp", upnp_router); + } + + let app = main_router + .layer(cors_layer) + .layer( + tower_http::trace::TraceLayer::new_for_http() + .make_span_with(|req: &Request| { + let method = req.method(); + let uri = req.uri(); + if let Some(ConnectInfo(addr)) = + req.extensions().get::>() + { + let addr = SocketAddr::new(addr.ip().to_canonical(), addr.port()); + error_span!("request", %method, %uri, %addr) + } else { + error_span!("request", %method, %uri) + } + }) + .on_request(|req: &Request, _: &Span| { + if req.uri().path().starts_with("/upnp") { + debug!(headers=?req.headers()) + } + }) + .on_response(DefaultOnResponse::new().include_headers(true)) + .on_failure({ + let mut default = DefaultOnFailure::new(); + move |failure_class, latency, span: &Span| match failure_class { + tower_http::classify::ServerErrorsFailureClass::StatusCode( + StatusCode::NOT_IMPLEMENTED, + ) => {} + _ => default.on_failure(failure_class, latency, span), + } + }), + ) + .into_make_service_with_connect_info::(); + + async move { + axum::serve(listener, app) + .await + .context("error running HTTP API") + } + .boxed() + } +} diff --git a/crates/librqbit/src/http_api/timeout.rs b/crates/librqbit/src/http_api/timeout.rs new file mode 100644 index 00000000..9c23f564 --- /dev/null +++ b/crates/librqbit/src/http_api/timeout.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use anyhow::Context; +use axum::{extract::Query, RequestPartsExt}; +use http::request::Parts; +use serde::Deserialize; + +use crate::ApiError; + +pub struct Timeout(pub Duration); + +impl axum::extract::FromRequestParts + for Timeout +where + S: Send + Sync, +{ + type Rejection = ApiError; + + /// Perform the extraction. + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + #[derive(Deserialize)] + struct QueryT { + timeout_ms: Option, + } + + let q = parts + .extract::>() + .await + .context("error running Timeout extractor")?; + + let timeout_ms = q + .timeout_ms + .map(Ok) + .or_else(|| { + parts + .headers + .get("x-req-timeout-ms") + .map(|v| { + std::str::from_utf8(v.as_bytes()).context("invalid utf-8 in timeout value") + }) + .map(|v| v.and_then(|v| v.parse::().context("invalid timeout integer"))) + }) + .transpose() + .context("error parsing timeout")? + .unwrap_or(DEFAULT_MS); + let timeout_ms = timeout_ms.min(MAX_MS); + Ok(Timeout(Duration::from_millis(timeout_ms as u64))) + } +} diff --git a/crates/librqbit/src/http_api/webui.rs b/crates/librqbit/src/http_api/webui.rs new file mode 100644 index 00000000..9f155cc3 --- /dev/null +++ b/crates/librqbit/src/http_api/webui.rs @@ -0,0 +1,41 @@ +use axum::{routing::get, Router}; + +pub fn make_webui_router() -> Router { + Router::new() + .route( + "/", + get(|| async { + ( + [("Content-Type", "text/html")], + include_str!("../../webui/dist/index.html"), + ) + }), + ) + .route( + "/assets/index.js", + get(|| async { + ( + [("Content-Type", "application/javascript")], + include_str!("../../webui/dist/assets/index.js"), + ) + }), + ) + .route( + "/assets/index.css", + get(|| async { + ( + [("Content-Type", "text/css")], + include_str!("../../webui/dist/assets/index.css"), + ) + }), + ) + .route( + "/assets/logo.svg", + get(|| async { + ( + [("Content-Type", "image/svg+xml")], + include_str!("../../webui/dist/assets/logo.svg"), + ) + }), + ) +} diff --git a/crates/librqbit/src/http_api_client.rs b/crates/librqbit/src/http_api_client.rs index 24f9a936..8cca96cd 100644 --- a/crates/librqbit/src/http_api_client.rs +++ b/crates/librqbit/src/http_api_client.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use crate::{ api::ApiAddTorrentResponse, - http_api::{InitialPeers, TorrentAddQueryParams}, + http_api_types::{InitialPeers, TorrentAddQueryParams}, session::{AddTorrent, AddTorrentOptions}, }; diff --git a/crates/librqbit/src/http_api_types.rs b/crates/librqbit/src/http_api_types.rs new file mode 100644 index 00000000..c3aba54c --- /dev/null +++ b/crates/librqbit/src/http_api_types.rs @@ -0,0 +1,111 @@ +use std::{net::SocketAddr, str::FromStr, time::Duration}; + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +use crate::{AddTorrentOptions, PeerConnectionOptions}; + +pub struct OnlyFiles(Vec); +pub struct InitialPeers(pub Vec); + +#[derive(Serialize, Deserialize, Default)] +pub struct TorrentAddQueryParams { + pub overwrite: Option, + pub output_folder: Option, + pub sub_folder: Option, + pub only_files_regex: Option, + pub only_files: Option, + pub peer_connect_timeout: Option, + pub peer_read_write_timeout: Option, + pub initial_peers: Option, + // Will force interpreting the content as a URL. + pub is_url: Option, + pub list_only: Option, +} + +impl Serialize for OnlyFiles { + fn serialize(&self, serializer: S) -> core::result::Result + where + S: serde::Serializer, + { + let s = self.0.iter().map(|id| id.to_string()).join(","); + s.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for OnlyFiles { + fn deserialize(deserializer: D) -> core::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + let list = s + .split(',') + .try_fold(Vec::::new(), |mut acc, c| match c.parse() { + Ok(i) => { + acc.push(i); + Ok(acc) + } + Err(_) => Err(D::Error::custom(format!( + "only_files: failed to parse {:?} as integer", + c + ))), + })?; + if list.is_empty() { + return Err(D::Error::custom( + "only_files: should contain at least one file id", + )); + } + Ok(OnlyFiles(list)) + } +} + +impl<'de> Deserialize<'de> for InitialPeers { + fn deserialize(deserializer: D) -> std::prelude::v1::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let string = String::deserialize(deserializer)?; + let mut addrs = Vec::new(); + for addr_str in string.split(',').filter(|s| !s.is_empty()) { + addrs.push(SocketAddr::from_str(addr_str).map_err(D::Error::custom)?); + } + Ok(InitialPeers(addrs)) + } +} + +impl Serialize for InitialPeers { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: serde::Serializer, + { + self.0 + .iter() + .map(|s| s.to_string()) + .join(",") + .serialize(serializer) + } +} + +impl TorrentAddQueryParams { + pub fn into_add_torrent_options(self) -> AddTorrentOptions { + AddTorrentOptions { + overwrite: self.overwrite.unwrap_or(false), + only_files_regex: self.only_files_regex, + only_files: self.only_files.map(|o| o.0), + output_folder: self.output_folder, + sub_folder: self.sub_folder, + list_only: self.list_only.unwrap_or(false), + initial_peers: self.initial_peers.map(|i| i.0), + peer_opts: Some(PeerConnectionOptions { + connect_timeout: self.peer_connect_timeout.map(Duration::from_secs), + read_write_timeout: self.peer_read_write_timeout.map(Duration::from_secs), + ..Default::default() + }), + ..Default::default() + } + } +} diff --git a/crates/librqbit/src/lib.rs b/crates/librqbit/src/lib.rs index 0b689dcf..7f33882c 100644 --- a/crates/librqbit/src/lib.rs +++ b/crates/librqbit/src/lib.rs @@ -49,8 +49,10 @@ pub mod file_info; mod file_ops; #[cfg(feature = "http-api")] pub mod http_api; -#[cfg(feature = "http-api")] +#[cfg(feature = "http-api-client")] pub mod http_api_client; +#[cfg(any(feature = "http-api", feature = "http-api-client"))] +pub mod http_api_types; pub mod limits; mod merge_streams; mod peer_connection; diff --git a/crates/rqbit/Cargo.toml b/crates/rqbit/Cargo.toml index 78b81b8e..e592b103 100644 --- a/crates/rqbit/Cargo.toml +++ b/crates/rqbit/Cargo.toml @@ -26,6 +26,7 @@ disable-upload = ["librqbit/disable-upload"] [dependencies] librqbit = { version = "8.0.0", path = "../librqbit", default-features = false, features = [ "http-api", + "http-api-client", "tracing-subscriber-utils", "upnp-serve-adapter", "watch", From 2c85624ec2fe1a8f34dbb0c16d42032f50f7e8f9 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 14 Jan 2025 11:27:14 +0000 Subject: [PATCH 9/9] Remove LazyLock (incompatible with older Rusts) --- crates/librqbit/src/http_api/handlers/mod.rs | 62 +++++++++----------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/crates/librqbit/src/http_api/handlers/mod.rs b/crates/librqbit/src/http_api/handlers/mod.rs index 256b64e4..50949742 100644 --- a/crates/librqbit/src/http_api/handlers/mod.rs +++ b/crates/librqbit/src/http_api/handlers/mod.rs @@ -6,7 +6,7 @@ mod playlist; mod streaming; mod torrents; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use axum::{ response::{IntoResponse, Redirect}, @@ -32,41 +32,35 @@ async fn h_api_root(parts: Parts) -> impl IntoResponse { } } - static API_ROOT_JSON: LazyLock> = LazyLock::new(|| { - Arc::new(serde_json::json!({ - "apis": { - "GET /": "list all available APIs", - "GET /dht/stats": "DHT stats", - "GET /dht/table": "DHT routing table", - "GET /torrents": "List torrents", - "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", - "GET /stats": "Global session stats", - "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", - "GET /torrents/{id_or_infohash}": "Torrent details", - "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", - "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", - "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", - "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", - "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", - "POST /torrents/{id_or_infohash}/pause": "Pause torrent", - "POST /torrents/{id_or_infohash}/start": "Resume torrent", - "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", - "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", - "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", - "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", - "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", - "GET /web/": "Web UI", - }, - "server": "rqbit", - "version": env!("CARGO_PKG_VERSION"), - })) + let json = serde_json::json!({ + "apis": { + "GET /": "list all available APIs", + "GET /dht/stats": "DHT stats", + "GET /dht/table": "DHT routing table", + "GET /torrents": "List torrents", + "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", + "GET /stats": "Global session stats", + "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", + "GET /torrents/{id_or_infohash}": "Torrent details", + "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", + "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", + "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", + "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", + "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", + "POST /torrents/{id_or_infohash}/pause": "Pause torrent", + "POST /torrents/{id_or_infohash}/start": "Resume torrent", + "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", + "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", + "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", + "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", + "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", + "GET /web/": "Web UI", + }, + "server": "rqbit", + "version": env!("CARGO_PKG_VERSION"), }); - ( - [("Content-Type", "application/json")], - axum::Json(API_ROOT_JSON.clone()), - ) - .into_response() + ([("Content-Type", "application/json")], axum::Json(json)).into_response() } pub fn make_api_router(state: ApiState) -> Router {