1#![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}