rqbit/crates/librqbit/src/http_api.rs

648 lines
23 KiB
Rust
Raw Normal View History

2023-11-24 14:08:02 +00:00
use anyhow::Context;
use axum::body::Bytes;
2024-08-05 09:30:26 +02:00
use axum::extract::{Path, Query, State};
2024-07-20 10:08:10 +02:00
use axum::response::{IntoResponse, Redirect};
2023-11-24 14:19:39 +00:00
use axum::routing::{get, post};
2024-02-27 08:14:39 +00:00
use futures::future::BoxFuture;
use futures::{FutureExt, TryStreamExt};
2024-04-24 20:56:58 +01:00
use http::{HeaderMap, HeaderValue, StatusCode};
2023-11-22 17:19:35 +00:00
use itertools::Itertools;
2023-12-02 15:19:05 +00:00
use serde::{Deserialize, Serialize};
2024-04-24 19:10:17 +01:00
use std::io::SeekFrom;
2021-10-10 09:57:21 +01:00
use std::net::SocketAddr;
use std::str::FromStr;
2023-11-30 16:05:48 +00:00
use std::time::Duration;
2024-04-24 19:10:17 +01:00
use tokio::io::AsyncSeekExt;
2024-04-29 18:26:36 +01:00
use tracing::{debug, info, trace};
2022-12-04 12:53:55 +00:00
use axum::Router;
2021-06-30 10:14:33 +01:00
2023-12-02 15:19:05 +00:00
use crate::api::Api;
2023-11-30 16:05:48 +00:00
use crate::peer_connection::PeerConnectionOptions;
use crate::session::{AddTorrent, AddTorrentOptions, SUPPORTED_SCHEMES};
2023-12-02 15:19:05 +00:00
use crate::torrent_state::peer::stats::snapshot::PeerStatsFilter;
type ApiState = Api;
2023-12-02 15:19:05 +00:00
use crate::api::Result;
use crate::{ApiError, ListOnlyResponse, ManagedTorrent};
2021-07-08 23:03:58 +01:00
2023-12-03 12:14:50 +00:00
/// An HTTP server for the API.
2022-12-08 15:40:29 +00:00
pub struct HttpApi {
2023-12-02 15:19:05 +00:00
inner: ApiState,
opts: HttpApiOptions,
}
#[derive(Debug, Default)]
pub struct HttpApiOptions {
pub read_only: bool,
2022-12-08 09:28:01 +00:00
}
2022-12-08 15:40:29 +00:00
impl HttpApi {
pub fn new(api: Api, opts: Option<HttpApiOptions>) -> Self {
2022-12-08 09:28:01 +00:00
Self {
inner: api,
opts: opts.unwrap_or_default(),
2022-12-08 09:28:01 +00:00
}
}
2022-12-08 11:06:29 +00:00
2023-12-03 12:14:50 +00:00
/// Run the HTTP server forever on the given address.
/// If read_only is passed, no state-modifying methods will be exposed.
#[inline(never)]
2024-02-27 08:14:39 +00:00
pub fn make_http_api_and_run(self, addr: SocketAddr) -> BoxFuture<'static, anyhow::Result<()>> {
2022-12-08 15:40:29 +00:00
let state = self.inner;
async fn api_root() -> impl IntoResponse {
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 (default torrent is 0)",
"GET /torrents/{index}": "Torrent details",
"GET /torrents/{index}/haves": "The bitfield of have pieces",
"GET /torrents/{index}/stats/v1": "Torrent stats",
2023-11-20 13:55:42 +00:00
"GET /torrents/{index}/peer_stats": "Per peer stats",
"POST /torrents/{index}/pause": "Pause torrent",
"POST /torrents/{index}/start": "Resume torrent",
"POST /torrents/{index}/forget": "Forget about the torrent, keep the files",
"POST /torrents/{index}/delete": "Forget about the torrent, remove the files",
2024-03-30 20:36:56 +00:00
"POST /torrents/{index}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}",
2023-11-21 12:56:07 +00:00
"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)",
2023-11-21 12:56:07 +00:00
"GET /web/": "Web UI",
},
"server": "rqbit",
2023-12-07 12:19:35 +00:00
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn dht_stats(State(state): State<ApiState>) -> Result<impl IntoResponse> {
state.api_dht_stats().map(axum::Json)
}
async fn dht_table(State(state): State<ApiState>) -> Result<impl IntoResponse> {
state.api_dht_table().map(axum::Json)
}
async fn torrents_list(State(state): State<ApiState>) -> impl IntoResponse {
axum::Json(state.api_torrent_list())
}
async fn torrents_post(
State(state): State<ApiState>,
Query(params): Query<TorrentAddQueryParams>,
data: Bytes,
) -> Result<impl IntoResponse> {
2023-12-01 11:28:35 +00:00
let is_url = params.is_url;
let opts = params.into_add_torrent_options();
2023-12-01 11:28:35 +00:00
let data = data.to_vec();
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())) =>
{
AddTorrent::Url(
String::from_utf8(data)
.context("invalid utf-8 for passed URL")?
.into(),
)
}
_ => AddTorrent::TorrentFileBytes(data.into()),
};
state.api_add_torrent(add, Some(opts)).await.map(axum::Json)
}
async fn torrent_details(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
state.api_torrent_details(idx).map(axum::Json)
}
2024-08-09 12:13:09 +01:00
fn torrent_playlist_items(handle: &ManagedTorrent) -> Result<Vec<(usize, String)>> {
let mut playlist_items = handle
.info()
.info
.iter_filenames_and_lengths()?
2024-08-04 13:36:39 +02:00
.enumerate()
2024-08-09 12:13:09 +01:00
.filter_map(|(file_idx, (filename, _))| {
let filename = filename.to_vec().ok()?.join("/");
let is_playable = mime_guess::from_path(&filename)
2024-08-04 13:36:39 +02:00
.first()
.map(|mime| {
mime.type_() == mime_guess::mime::VIDEO
|| mime.type_() == mime_guess::mime::AUDIO
})
.unwrap_or(false);
if is_playable {
2024-08-09 12:13:09 +01:00
let filename = urlencoding::encode(&filename);
Some((file_idx, filename.into_owned()))
2024-08-04 13:36:39 +02:00
} else {
None
}
})
.collect::<Vec<_>>();
2024-08-09 21:38:31 +02:00
playlist_items.sort_by(|left, right| left.1.cmp(&right.1));
2024-08-09 12:13:09 +01:00
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<Item = (usize, usize, String)>,
) -> impl IntoResponse {
let body = it
.into_iter()
2024-08-09 12:13:09 +01:00
.map(|(torrent_idx, file_idx, filename)| {
format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}")
})
.join("\r\n");
2024-08-09 12:13:09 +01:00
(
2024-08-10 13:30:02 +01:00
[
("Content-Type", "application/mpegurl; charset=utf-8"),
(
"Content-Disposition",
"attachment; filename=\"rqbit-playlist.m3u8\"",
),
],
2024-08-09 12:13:09 +01:00
body,
)
}
async fn resolve_magnet(
State(state): State<ApiState>,
url: String,
) -> Result<impl IntoResponse> {
let added = state
.session()
.add_torrent(
AddTorrent::from_url(&url),
Some(AddTorrentOptions {
list_only: true,
..Default::default()
}),
)
.await?;
2024-08-12 23:59:23 +01:00
let (info, content) = match added {
crate::AddTorrentResponse::AlreadyManaged(_, handle) => (
handle.info().info.clone(),
handle.info().torrent_bytes.clone(),
2024-08-12 23:59:23 +01:00
),
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",
))
}
};
2024-08-12 23:59:23 +01:00
let mut headers = HeaderMap::new();
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) {
2024-08-12 23:59:23 +01:00
if let Ok(h) =
HeaderValue::from_str(&format!("attachment; filename=\"{}.torrent\"", name))
{
headers.insert("Content-Disposition", h);
}
}
}
Ok((headers, content))
}
2024-08-09 12:13:09 +01:00
async fn torrent_playlist(
State(state): State<ApiState>,
headers: HeaderMap,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
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<ApiState>,
headers: HeaderMap,
) -> Result<impl IntoResponse> {
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, file_idx, filename)
})
})
.ok()
})
.flatten()
.collect::<Vec<_>>()
});
Ok(build_playlist_content(host, all_items))
2024-08-04 13:36:39 +02:00
}
async fn torrent_haves(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
state.api_dump_haves(idx)
}
2023-11-24 15:04:36 +00:00
async fn torrent_stats_v0(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
2023-11-24 15:04:36 +00:00
state.api_stats_v0(idx).map(axum::Json)
}
async fn torrent_stats_v1(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
state.api_stats_v1(idx).map(axum::Json)
}
2022-12-08 09:28:01 +00:00
2023-11-20 13:55:42 +00:00
async fn peer_stats(
State(state): State<ApiState>,
Path(idx): Path<usize>,
Query(filter): Query<PeerStatsFilter>,
) -> Result<impl IntoResponse> {
state.api_peer_stats(idx, filter).map(axum::Json)
}
2024-04-24 19:10:17 +01:00
async fn torrent_stream_file(
State(state): State<ApiState>,
Path((idx, file_id)): Path<(usize, usize)>,
headers: http::HeaderMap,
) -> Result<impl IntoResponse> {
let mut stream = state.api_stream(idx, file_id)?;
2024-04-24 20:56:58 +01:00
let mut status = StatusCode::OK;
let mut output_headers = HeaderMap::new();
output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes"));
2024-07-27 11:44:15 +02:00
if let Ok(mime) = state.torrent_file_mime_type(idx, file_id) {
output_headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_str(mime).context("bug - invalid MIME")?,
);
}
2024-04-29 18:26:36 +01:00
let range_header = headers.get(http::header::RANGE);
trace!(torrent_id=idx, file_id=file_id, range=?range_header, "request for HTTP stream");
if let Some(range) = range_header {
2024-04-24 19:10:17 +01:00
let offset: Option<u64> = 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 {
2024-04-24 20:56:58 +01:00
status = StatusCode::PARTIAL_CONTENT;
2024-04-24 19:10:17 +01:00
stream
.seek(SeekFrom::Start(offset))
.await
.context("error seeking")?;
2024-04-24 20:56:58 +01:00
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")?,
);
2024-04-24 19:10:17 +01:00
}
} else {
output_headers.insert(
http::header::CONTENT_LENGTH,
HeaderValue::from_str(&format!("{}", stream.len())).context("bug")?,
);
2024-04-24 19:10:17 +01:00
}
let s = tokio_util::io::ReaderStream::new(stream);
2024-04-24 20:56:58 +01:00
Ok((status, (output_headers, axum::body::Body::from_stream(s))))
2024-04-24 19:10:17 +01:00
}
2023-11-24 14:19:39 +00:00
async fn torrent_action_pause(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
2023-11-24 18:28:46 +00:00
state.api_torrent_action_pause(idx).map(axum::Json)
2023-11-24 14:19:39 +00:00
}
async fn torrent_action_start(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
2023-11-24 18:28:46 +00:00
state.api_torrent_action_start(idx).map(axum::Json)
}
async fn torrent_action_forget(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
state.api_torrent_action_forget(idx).map(axum::Json)
}
async fn torrent_action_delete(
State(state): State<ApiState>,
Path(idx): Path<usize>,
) -> Result<impl IntoResponse> {
state.api_torrent_action_delete(idx).map(axum::Json)
2023-11-24 14:19:39 +00:00
}
2024-03-30 17:55:43 +00:00
#[derive(Deserialize)]
struct UpdateOnlyFilesRequest {
only_files: Vec<usize>,
}
async fn torrent_action_update_only_files(
State(state): State<ApiState>,
Path(idx): Path<usize>,
axum::Json(req): axum::Json<UpdateOnlyFilesRequest>,
) -> Result<impl IntoResponse> {
state
.api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect())
.map(axum::Json)
}
async fn set_rust_log(
State(state): State<ApiState>,
new_value: String,
) -> Result<impl IntoResponse> {
state.api_set_rust_log(new_value).map(axum::Json)
}
async fn stream_logs(State(state): State<ApiState>) -> Result<impl IntoResponse> {
let s = state.api_log_lines_stream()?.map_err(|e| {
debug!(error=%e, "stream_logs");
e
});
Ok(axum::body::Body::from_stream(s))
}
2023-11-20 20:15:40 +00:00
let mut app = Router::new()
.route("/", get(api_root))
.route("/stream_logs", get(stream_logs))
2023-11-25 11:21:45 +00:00
.route("/rust_log", post(set_rust_log))
.route("/dht/stats", get(dht_stats))
.route("/dht/table", get(dht_table))
2023-11-25 11:21:45 +00:00
.route("/torrents", get(torrents_list))
.route("/torrents/:id", get(torrent_details))
.route("/torrents/:id/haves", get(torrent_haves))
2023-11-24 15:04:36 +00:00
.route("/torrents/:id/stats", get(torrent_stats_v0))
.route("/torrents/:id/stats/v1", get(torrent_stats_v1))
2024-04-24 19:10:17 +01:00
.route("/torrents/:id/peer_stats", get(peer_stats))
2024-04-29 19:01:04 +01:00
.route("/torrents/:id/stream/:file_id", get(torrent_stream_file))
2024-08-04 13:36:39 +02:00
.route("/torrents/:id/playlist", get(torrent_playlist))
2024-08-09 12:13:09 +01:00
.route("/torrents/playlist", get(global_playlist))
.route("/torrents/resolve_magnet", post(resolve_magnet))
2024-04-29 19:01:04 +01:00
.route(
"/torrents/:id/stream/:file_id/*filename",
get(torrent_stream_file),
);
2023-11-25 11:21:45 +00:00
if !self.opts.read_only {
2023-11-25 11:21:45 +00:00
app = app
.route("/torrents", post(torrents_post))
.route("/torrents/:id/pause", post(torrent_action_pause))
.route("/torrents/:id/start", post(torrent_action_start))
.route("/torrents/:id/forget", post(torrent_action_forget))
2024-03-30 17:55:43 +00:00
.route("/torrents/:id/delete", post(torrent_action_delete))
.route(
"/torrents/:id/update_only_files",
post(torrent_action_update_only_files),
);
2023-11-25 11:21:45 +00:00
}
2023-11-20 20:15:40 +00:00
#[cfg(feature = "webui")]
{
let webui_router = Router::new()
.route(
"/",
get(|| async {
(
[("Content-Type", "text/html")],
include_str!("../webui/dist/index.html"),
2023-11-20 20:15:40 +00:00
)
}),
)
.route(
2023-11-27 17:21:45 +00:00
"/assets/index.js",
2023-11-20 20:15:40 +00:00
get(|| async {
(
[("Content-Type", "application/javascript")],
2023-11-27 17:21:45 +00:00
include_str!("../webui/dist/assets/index.js"),
)
}),
)
.route(
"/assets/index.css",
get(|| async {
(
[("Content-Type", "text/css")],
include_str!("../webui/dist/assets/index.css"),
)
}),
)
2023-11-27 17:21:45 +00:00
.route(
"/assets/logo.svg",
get(|| async {
(
[("Content-Type", "image/svg+xml")],
include_str!("../webui/dist/assets/logo.svg"),
2023-11-20 20:15:40 +00:00
)
}),
);
app = app.nest("/web/", webui_router);
2024-07-20 10:08:10 +02:00
app = app.route("/web", get(|| async { Redirect::permanent("/web/") }))
}
2023-11-20 22:10:01 +00:00
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",
];
tower_http::cors::CorsLayer::default()
.allow_origin(AllowOrigin::predicate(|v, _| {
ALLOWED_ORIGINS.contains(&v.as_bytes())
}))
.allow_headers(AllowHeaders::any())
};
2023-11-20 20:15:40 +00:00
let app = app
.layer(cors_layer)
.layer(tower_http::trace::TraceLayer::new_for_http())
2023-11-20 20:15:40 +00:00
.with_state(state)
.into_make_service();
2022-12-08 09:28:01 +00:00
info!(%addr, "starting HTTP server");
use tokio::net::TcpListener;
2024-02-26 22:52:53 +00:00
async move {
let listener = TcpListener::bind(&addr)
.await
.with_context(|| format!("error binding to {addr}"))?;
axum::serve(listener, app).await?;
Ok(())
}
.boxed()
2022-12-08 09:28:01 +00:00
}
}
2023-12-03 12:14:50 +00:00
pub(crate) struct OnlyFiles(Vec<usize>);
pub(crate) struct InitialPeers(pub Vec<SocketAddr>);
#[derive(Serialize, Deserialize, Default)]
2023-12-03 12:14:50 +00:00
pub(crate) struct TorrentAddQueryParams {
pub overwrite: Option<bool>,
pub output_folder: Option<String>,
pub sub_folder: Option<String>,
pub only_files_regex: Option<String>,
pub only_files: Option<OnlyFiles>,
pub peer_connect_timeout: Option<u64>,
pub peer_read_write_timeout: Option<u64>,
pub initial_peers: Option<InitialPeers>,
// Will force interpreting the content as a URL.
pub is_url: Option<bool>,
pub list_only: Option<bool>,
}
2023-11-22 17:19:35 +00:00
impl Serialize for OnlyFiles {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
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<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let s = String::deserialize(deserializer)?;
let list = s
.split(',')
.try_fold(Vec::<usize>::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))
2023-11-22 15:26:24 +00:00
}
}
impl<'de> Deserialize<'de> for InitialPeers {
fn deserialize<D>(deserializer: D) -> std::prelude::v1::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let string = String::deserialize(deserializer)?;
let mut addrs = Vec::new();
2023-12-01 10:28:20 +00:00
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<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0
.iter()
.map(|s| s.to_string())
.join(",")
.serialize(serializer)
}
}
2022-12-08 15:40:29 +00:00
impl TorrentAddQueryParams {
pub fn into_add_torrent_options(self) -> AddTorrentOptions {
2022-12-08 15:40:29 +00:00
AddTorrentOptions {
overwrite: self.overwrite.unwrap_or(false),
only_files_regex: self.only_files_regex,
2023-11-22 17:19:35 +00:00
only_files: self.only_files.map(|o| o.0),
2022-12-08 15:40:29 +00:00
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),
2023-11-30 16:05:48 +00:00
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()
}),
2022-12-08 15:40:29 +00:00
..Default::default()
}
}
}