1#![cfg(not(target_arch = "wasm32"))]
14
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{OnceLock, RwLock};
20
21use serde::{Deserialize, Serialize};
22
23static REGISTRY: OnceLock<RwLock<HashMap<String, SeedRecord>>> = OnceLock::new();
24static BYTES_SERVED_SESSION: AtomicU64 = AtomicU64::new(0);
25static GLOBAL_POLICY: OnceLock<RwLock<SeederBandwidthPolicy>> = OnceLock::new();
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SeederBandwidthPolicy {
29 pub global_limit_kbps: u32,
30 pub metered_mode: bool,
31}
32
33impl Default for SeederBandwidthPolicy {
34 fn default() -> Self {
35 Self {
36 global_limit_kbps: 1024,
37 metered_mode: true,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct SeedRecord {
44 pub info_hash: String,
45 pub file_path: PathBuf,
46 pub display_name: String,
47 pub ontology_id: String,
48 pub file_size: u64,
49 pub bytes_uploaded_total: u64,
50 pub bytes_uploaded_session: u64,
51 pub download_count: u64,
52 pub deprecated: bool,
53 pub commons_asserted: bool,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct RegisterSeedRequest {
60 pub info_hash: String,
61 pub file_path: String,
62 pub display_name: String,
63 pub ontology_id: String,
64 #[serde(default)]
65 pub bandwidth_limit_kbps: u32,
66 #[serde(default)]
69 pub commons_asserted: bool,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73pub struct UnregisterSeedRequest {
74 pub info_hash: String,
75}
76
77fn registry() -> &'static RwLock<HashMap<String, SeedRecord>> {
78 REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
79}
80
81fn policy_lock() -> &'static RwLock<SeederBandwidthPolicy> {
82 GLOBAL_POLICY.get_or_init(|| RwLock::new(SeederBandwidthPolicy::default()))
83}
84
85pub fn normalize_info_hash(raw: &str) -> String {
86 raw.trim().to_ascii_lowercase()
87}
88
89pub fn build_magnet_uri(info_hash_sha1: &str, display_name: &str, daemon_port: u16) -> String {
90 let hash = normalize_info_hash(info_hash_sha1);
91 let dn = urlencoding::encode(display_name);
92 let ws_url = format!("http://127.0.0.1:{daemon_port}/torrent/webseed/{hash}");
93 let ws = urlencoding::encode(&ws_url);
94 format!("magnet:?xt=urn:btih:{hash}&dn={dn}&ws={ws}")
95}
96
97pub fn ensure_magnet_webseed(
98 magnet: &str,
99 info_hash: &str,
100 display_name: &str,
101 port: u16,
102) -> String {
103 if magnet.contains("&ws=") || magnet.contains("?ws=") {
104 return magnet.to_string();
105 }
106 build_magnet_uri(info_hash, display_name, port)
107}
108
109pub fn sha1_file(path: &Path) -> Result<String, String> {
110 use sha1::{Digest, Sha1};
111 let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
112 let mut hasher = Sha1::new();
113 let mut buf = [0u8; 8192];
114 loop {
115 let n = std::io::Read::read(&mut file, &mut buf).map_err(|e| e.to_string())?;
116 if n == 0 {
117 break;
118 }
119 hasher.update(&buf[..n]);
120 }
121 Ok(hasher
122 .finalize()
123 .iter()
124 .map(|b| format!("{b:02x}"))
125 .collect())
126}
127
128pub fn set_bandwidth_policy(policy: SeederBandwidthPolicy) {
129 *policy_lock().write().unwrap() = policy;
130}
131
132pub fn get_bandwidth_policy() -> SeederBandwidthPolicy {
133 policy_lock().read().unwrap().clone()
134}
135
136pub fn register_seed(req: RegisterSeedRequest) -> Result<SeedRecord, String> {
137 let hash = normalize_info_hash(&req.info_hash);
138 let path = PathBuf::from(&req.file_path);
139 if !path.is_file() {
140 return Err(format!("Seed file not found: {}", path.display()));
141 }
142 if crate::q42_volume::is_unified_volume(&path).unwrap_or(false) {
143 let intent = if req.commons_asserted {
144 crate::q42_volume::PublicationIntent::CommonsCatalog
145 } else {
146 crate::q42_volume::PublicationIntent::Default
147 };
148 let verdict = crate::q42_volume::classify_q42_path(&path, intent)
149 .map_err(|e| format!("Q42 publication denied: {e}"))?;
150 if !verdict.may_http_webseed {
151 return Err(verdict.reason);
152 }
153 }
154 let computed = sha1_file(&path)?;
155 if computed != hash {
156 return Err(format!(
157 "Info hash mismatch: file SHA1 {computed} != expected {hash}"
158 ));
159 }
160 let file_size = fs::metadata(&path).map_err(|e| e.to_string())?.len();
161 let record = SeedRecord {
162 info_hash: hash.clone(),
163 file_path: path,
164 display_name: req.display_name,
165 ontology_id: req.ontology_id,
166 file_size,
167 bytes_uploaded_total: 0,
168 bytes_uploaded_session: 0,
169 download_count: 0,
170 deprecated: false,
171 commons_asserted: req.commons_asserted,
172 };
173 registry().write().unwrap().insert(hash, record.clone());
174 println!(
175 "[Qualia WebTorrent] Seeding {} ({} bytes) as {}",
176 record.ontology_id, record.file_size, record.info_hash
177 );
178 Ok(record)
179}
180
181pub fn unregister_seed(info_hash: &str) -> bool {
182 let hash = normalize_info_hash(info_hash);
183 registry().write().unwrap().remove(&hash).is_some()
184}
185
186pub fn deprecate_seed(info_hash: &str) -> bool {
187 let hash = normalize_info_hash(info_hash);
188 if let Some(rec) = registry().write().unwrap().get_mut(&hash) {
189 rec.deprecated = true;
190 return true;
193 }
194 false
195}
196
197pub fn list_active_seeds() -> Vec<SeedRecord> {
198 registry().read().unwrap().values().cloned().collect()
199}
200
201pub fn lookup_seed(info_hash: &str) -> Option<SeedRecord> {
202 registry()
203 .read()
204 .unwrap()
205 .get(&normalize_info_hash(info_hash))
206 .cloned()
207}
208
209pub fn record_bytes_served(info_hash: &str, bytes: u64) {
210 let hash = normalize_info_hash(info_hash);
211 BYTES_SERVED_SESSION.fetch_add(bytes, Ordering::Relaxed);
212 if let Some(rec) = registry().write().unwrap().get_mut(&hash) {
213 rec.bytes_uploaded_session += bytes;
214 rec.bytes_uploaded_total += bytes;
215 }
216}
217
218pub fn record_full_download(info_hash: &str) {
219 let hash = normalize_info_hash(info_hash);
220 if let Some(rec) = registry().write().unwrap().get_mut(&hash) {
221 rec.download_count += 1;
222 }
223}
224
225#[derive(Debug, Clone, Serialize)]
226pub struct SeederTelemetry {
227 pub seeders: usize,
228 pub leechers: u64,
229 pub speed: String,
230 pub status: String,
231 pub uploaded_session_kb: u64,
232 pub uploaded_total_kb: u64,
233 pub active_ontologies: Vec<String>,
234 pub global_bandwidth_kbps: u32,
235 pub metered_mode: bool,
236 pub seeder: String,
237}
238
239pub fn telemetry() -> SeederTelemetry {
240 let seeds = list_active_seeds();
241 let policy = get_bandwidth_policy();
242 let uploaded_session = BYTES_SERVED_SESSION.load(Ordering::Relaxed);
243 let uploaded_total: u64 = seeds.iter().map(|s| s.bytes_uploaded_total).sum();
244 let limit = policy.global_limit_kbps;
245 let speed = if limit == 0 {
246 "unlimited".to_string()
247 } else {
248 format!("{limit} KiB/s cap")
249 };
250 SeederTelemetry {
251 seeders: seeds.len(),
252 leechers: seeds.iter().map(|s| s.download_count).sum(),
253 speed,
254 status: if seeds.is_empty() { "idle" } else { "seeding" }.to_string(),
255 uploaded_session_kb: uploaded_session / 1024,
256 uploaded_total_kb: uploaded_total / 1024,
257 active_ontologies: seeds.iter().map(|s| s.ontology_id.clone()).collect(),
258 global_bandwidth_kbps: policy.global_limit_kbps,
259 metered_mode: policy.metered_mode,
260 seeder: "qualia-daemon".to_string(),
261 }
262}
263
264pub fn sync_from_workbench(storage_path: &str, daemon_port: u16) {
266 let path = Path::new(storage_path)
267 .join("Index")
268 .join("workbench.jsonl");
269 if !path.is_file() {
270 return;
271 }
272 let Ok(text) = fs::read_to_string(&path) else {
273 return;
274 };
275 for line in text.lines() {
276 if line.trim().is_empty() {
277 continue;
278 }
279 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
280 continue;
281 };
282 let seed_active = v["seed_active"].as_bool().unwrap_or(false);
283 let seed_enabled = v["torrent"]["seed_enabled"].as_bool().unwrap_or(false);
284 if !seed_active || !seed_enabled {
285 continue;
286 }
287 let info_hash = v["info_hash_sha1"].as_str().unwrap_or_default();
288 let file_path = v["c_q42_path"].as_str().unwrap_or_default();
289 let ontology_id = v["ontology_id"].as_str().unwrap_or_default();
290 let title = v["title"].as_str().unwrap_or(ontology_id);
291 if info_hash.is_empty() || file_path.is_empty() {
292 continue;
293 }
294 let _ = register_seed(RegisterSeedRequest {
295 info_hash: info_hash.to_string(),
296 file_path: file_path.to_string(),
297 display_name: format!("{title}.c.q42"),
298 ontology_id: ontology_id.to_string(),
299 bandwidth_limit_kbps: v["torrent"]["bandwidth_limit_kbps"].as_u64().unwrap_or(512)
300 as u32,
301 commons_asserted: true,
302 });
303 if let Some(magnet) = v["magnet_uri"].as_str() {
304 let updated =
305 ensure_magnet_webseed(magnet, info_hash, &format!("{title}.c.q42"), daemon_port);
306 if updated != magnet {
307 let _ = updated;
309 }
310 }
311 }
312 let n = list_active_seeds().len();
313 if n > 0 {
314 println!("[Qualia WebTorrent] Restored {n} active seed(s) from workbench");
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn magnet_includes_qualia_webseed() {
324 let m = build_magnet_uri("abc123def", "demo.q42", 4242);
325 assert!(m.contains("urn:btih:abc123def"));
326 assert!(m.contains("ws=http"));
327 assert!(m.contains("abc123def"));
328 assert!(m.contains("torrent"));
329 assert!(m.contains("webseed"));
330 }
331
332 #[test]
333 fn normalize_hash_lowercase() {
334 assert_eq!(normalize_info_hash("ABCD"), "abcd");
335 }
336
337 #[test]
338 fn register_seed_refuses_medical_q42() {
339 use crate::q42_volume::write_unified_volume;
340 use crate::NQuin;
341 use std::collections::HashMap;
342
343 let file = tempfile::NamedTempFile::new().unwrap();
344 let mut quin = NQuin {
345 subject: 1,
346 predicate: 2,
347 object: 3,
348 context: 0,
349 metadata: 0,
350 parity: 0,
351 };
352 quin.set_sensitivity_byte(NQuin::SENSITIVITY_CLASSIFIED);
353 quin.set_sensitivity_tier(NQuin::SENSITIVITY_TIER_MEDICAL);
354 write_unified_volume(
355 file.path(),
356 &HashMap::new(),
357 &[(3, 3)],
358 &[vec![quin]],
359 )
360 .unwrap();
361 let hash = sha1_file(file.path()).unwrap();
362 let err = register_seed(RegisterSeedRequest {
363 info_hash: hash,
364 file_path: file.path().display().to_string(),
365 display_name: "pep-record.q42".into(),
366 ontology_id: "should-not-seed".into(),
367 bandwidth_limit_kbps: 1,
368 commons_asserted: true,
369 })
370 .unwrap_err();
371 assert!(
372 err.contains("publication denied") || err.contains("Selfhood") || err.contains("medical"),
373 "{err}"
374 );
375 }
376}