Skip to main content

qualia_core_db/services/
webtorrent_routes.rs

1//! HTTP routes for the Qualia-native WebTorrent seeder.
2
3#![cfg(not(target_arch = "wasm32"))]
4
5use axum::http::HeaderMap;
6use axum::{
7    extract::{Path as AxumPath, State},
8    http::{header, StatusCode},
9    response::IntoResponse,
10    routing::{get, post},
11    Json, Router,
12};
13use serde_json::json;
14use std::path::Path;
15
16use crate::webtorrent_seeder::{
17    self, RegisterSeedRequest, SeederBandwidthPolicy, UnregisterSeedRequest,
18};
19
20#[derive(Clone)]
21pub struct TorrentState {
22    pub daemon_port: u16,
23}
24
25fn parse_range(range_header: &str, file_size: u64) -> Option<(u64, u64)> {
26    let trimmed = range_header.trim();
27    let rest = trimmed.strip_prefix("bytes=")?;
28    let (start_s, end_s) = rest.split_once('-')?;
29    let start: u64 = start_s.parse().ok()?;
30    let end = if end_s.is_empty() {
31        file_size.saturating_sub(1)
32    } else {
33        end_s.parse().ok()?
34    };
35    if start > end || end >= file_size {
36        return None;
37    }
38    Some((start, end))
39}
40
41async fn telemetry_handler() -> impl IntoResponse {
42    (StatusCode::OK, Json(webtorrent_seeder::telemetry()))
43}
44
45async fn register_handler(Json(req): Json<RegisterSeedRequest>) -> impl IntoResponse {
46    match webtorrent_seeder::register_seed(req) {
47        Ok(rec) => (
48            StatusCode::OK,
49            Json(json!({
50                "status": "ok",
51                "seed": rec,
52                "seeder": "qualia-daemon",
53            })),
54        ),
55        Err(e) => {
56            let denied = e.contains("publication denied") || e.contains("Sanctuary");
57            (
58                if denied {
59                    StatusCode::FORBIDDEN
60                } else {
61                    StatusCode::BAD_REQUEST
62                },
63                Json(json!({ "status": "error", "message": e })),
64            )
65        }
66    }
67}
68
69async fn unseed_handler(Json(req): Json<UnregisterSeedRequest>) -> impl IntoResponse {
70    let removed = webtorrent_seeder::unregister_seed(&req.info_hash);
71    (
72        StatusCode::OK,
73        Json(json!({
74            "status": if removed { "ok" } else { "not_found" },
75            "info_hash": req.info_hash,
76        })),
77    )
78}
79
80async fn policy_get_handler() -> impl IntoResponse {
81    (
82        StatusCode::OK,
83        Json(webtorrent_seeder::get_bandwidth_policy()),
84    )
85}
86
87async fn policy_set_handler(Json(policy): Json<SeederBandwidthPolicy>) -> impl IntoResponse {
88    webtorrent_seeder::set_bandwidth_policy(policy.clone());
89    (
90        StatusCode::OK,
91        Json(json!({ "status": "ok", "policy": policy })),
92    )
93}
94
95async fn sync_handler(State(state): State<TorrentState>) -> impl IntoResponse {
96    let storage = std::env::var("QUALIA_STORAGE_PATH").unwrap_or_else(|_| {
97        std::env::var("HOME")
98            .or_else(|_| std::env::var("USERPROFILE"))
99            .map(|h| format!("{h}/.qualia"))
100            .unwrap_or_else(|_| ".qualia".to_string())
101    });
102    webtorrent_seeder::sync_from_workbench(&storage, state.daemon_port);
103    (
104        StatusCode::OK,
105        Json(json!({
106            "status": "ok",
107            "active": webtorrent_seeder::list_active_seeds().len(),
108        })),
109    )
110}
111
112async fn webseed_handler(
113    AxumPath(info_hash): AxumPath<String>,
114    headers: HeaderMap,
115) -> impl IntoResponse {
116    let Some(seed) = webtorrent_seeder::lookup_seed(&info_hash) else {
117        return (
118            StatusCode::NOT_FOUND,
119            Json(json!({ "error": "seed not found" })),
120        )
121            .into_response();
122    };
123
124    let path = Path::new(&seed.file_path);
125    if crate::q42_volume::is_unified_volume(path).unwrap_or(false) {
126        let intent = if seed.commons_asserted {
127            crate::q42_volume::PublicationIntent::CommonsCatalog
128        } else {
129            crate::q42_volume::PublicationIntent::Default
130        };
131        match crate::q42_volume::classify_q42_path(path, intent) {
132            Ok(verdict) if verdict.may_http_webseed => {}
133            Ok(verdict) => {
134                let _ = webtorrent_seeder::unregister_seed(&info_hash);
135                return (
136                    StatusCode::FORBIDDEN,
137                    Json(json!({
138                        "error": "publication denied",
139                        "message": verdict.reason,
140                    })),
141                )
142                    .into_response();
143            }
144            Err(e) => {
145                return (
146                    StatusCode::FORBIDDEN,
147                    Json(json!({
148                        "error": "publication denied",
149                        "message": e.to_string(),
150                    })),
151                )
152                    .into_response();
153            }
154        }
155    }
156    let Ok(data) = std::fs::read(path) else {
157        return (
158            StatusCode::INTERNAL_SERVER_ERROR,
159            Json(json!({ "error": "file read failed" })),
160        )
161            .into_response();
162    };
163
164    let file_size = data.len() as u64;
165    let range_hdr_val = headers.get("range").and_then(|v| v.to_str().ok());
166
167    let (body_bytes, status, content_range) = if let Some(range_hdr) = range_hdr_val {
168        if let Some((start, end)) = parse_range(range_hdr, file_size) {
169            let slice = data[start as usize..=end as usize].to_vec();
170            let len = slice.len() as u64;
171            webtorrent_seeder::record_bytes_served(&info_hash, len);
172            let cr = format!("bytes {start}-{end}/{file_size}");
173            (slice, StatusCode::PARTIAL_CONTENT, Some(cr))
174        } else {
175            webtorrent_seeder::record_bytes_served(&info_hash, file_size);
176            webtorrent_seeder::record_full_download(&info_hash);
177            (data, StatusCode::OK, None)
178        }
179    } else {
180        webtorrent_seeder::record_bytes_served(&info_hash, file_size);
181        webtorrent_seeder::record_full_download(&info_hash);
182        (data, StatusCode::OK, None)
183    };
184
185    let mut response_headers = HeaderMap::new();
186    response_headers.insert(
187        header::CONTENT_TYPE,
188        header::HeaderValue::from_static("application/octet-stream"),
189    );
190    response_headers.insert(
191        header::CONTENT_DISPOSITION,
192        header::HeaderValue::from_str(&format!("attachment; filename=\"{}\"", seed.display_name))
193            .unwrap_or_else(|_| header::HeaderValue::from_static("attachment")),
194    );
195    response_headers.insert(
196        header::ACCEPT_RANGES,
197        header::HeaderValue::from_static("bytes"),
198    );
199    if let Some(cr) = content_range {
200        if let Ok(v) = header::HeaderValue::from_str(&cr) {
201            response_headers.insert(header::CONTENT_RANGE, v);
202        }
203    }
204
205    let r: axum::response::Response = (status, response_headers, body_bytes).into_response();
206    r
207}
208
209pub fn webtorrent_routes(daemon_port: u16) -> Router {
210    let state = TorrentState { daemon_port };
211    Router::new()
212        .route("/telemetry", get(telemetry_handler))
213        .route("/seed", post(register_handler))
214        .route("/unseed", post(unseed_handler))
215        .route("/policy", get(policy_get_handler).post(policy_set_handler))
216        .route("/sync", post(sync_handler))
217        .route("/webseed/{info_hash}", get(webseed_handler))
218        .with_state(state)
219}