1use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use sha1::{Digest as Sha1Digest, Sha1};
10use sha2::Sha256;
11
12use crate::q42_compress;
13use crate::resource_import;
14use crate::social_connect::ChatContact;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum ShareAudience {
19 Private,
20 AddressbookAll,
21 AddressbookCategory,
22 SpecificDids,
23 ChatSessions,
25 PublicSeed,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct OntologyTorrentPolicy {
30 pub seed_enabled: bool,
31 pub share_enabled: bool,
32 pub audience: ShareAudience,
33 #[serde(default)]
34 pub allowed_categories: Vec<String>,
35 #[serde(default)]
36 pub allowed_contact_dids: Vec<String>,
37 #[serde(default)]
38 pub allowed_session_dids: Vec<String>,
39 #[serde(default)]
41 pub bandwidth_limit_kbps: u32,
42 #[serde(default)]
43 pub max_upload_mb_per_day: Option<u32>,
44}
45
46impl Default for OntologyTorrentPolicy {
47 fn default() -> Self {
48 Self {
49 seed_enabled: false,
50 share_enabled: false,
51 audience: ShareAudience::Private,
52 allowed_categories: vec![],
53 allowed_contact_dids: vec![],
54 allowed_session_dids: vec![],
55 bandwidth_limit_kbps: 512,
56 max_upload_mb_per_day: Some(500),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TorrentBandwidthGlobal {
63 #[serde(default)]
64 pub global_limit_kbps: u32,
65 #[serde(default = "default_metered")]
66 pub metered_mode: bool,
67}
68
69fn default_metered() -> bool {
70 true
71}
72
73impl Default for TorrentBandwidthGlobal {
74 fn default() -> Self {
75 Self {
76 global_limit_kbps: 1024,
77 metered_mode: true,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct OntologyWorkbenchEntry {
84 pub ontology_id: String,
85 pub title: String,
86 pub source_uri: String,
87 pub domain: String,
88 pub c_q42_path: String,
89 pub quin_count: u64,
90 pub sha256: String,
91 pub info_hash_sha1: String,
92 pub magnet_uri: String,
93 pub imported_at: u64,
94 pub torrent: OntologyTorrentPolicy,
95 pub seed_active: bool,
96 pub bytes_uploaded_total: u64,
97 pub bytes_uploaded_today: u64,
98 pub uploaded_day_epoch: u64,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WorkbenchImportResult {
103 pub entry: OntologyWorkbenchEntry,
104 pub compress_ratio: f64,
105 pub source_removed: bool,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct OntologyShareCard {
110 pub ontology_id: String,
111 pub title: String,
112 pub domain: String,
113 pub magnet_uri: String,
114 pub info_hash_sha1: String,
115 pub quin_count: u64,
116}
117
118fn workbench_path(storage_root: &Path) -> PathBuf {
119 resource_import::index_dir(storage_root).join("workbench.jsonl")
120}
121
122fn bandwidth_policy_path() -> PathBuf {
123 crate::state::app_meta_dir().join("torrent_bandwidth.json")
124}
125
126fn unix_now() -> u64 {
127 std::time::SystemTime::now()
128 .duration_since(std::time::UNIX_EPOCH)
129 .unwrap_or_default()
130 .as_secs()
131}
132
133fn day_epoch(ts: u64) -> u64 {
134 ts / 86_400
135}
136
137pub fn derive_ontology_id_from_uri(uri: &str) -> String {
138 let trimmed = uri.trim();
139 if let Ok(url) = url::Url::parse(trimmed) {
140 if let Some(segments) = url.path_segments() {
141 let last = segments
142 .filter(|s| !s.is_empty())
143 .last()
144 .unwrap_or("ontology");
145 let stem = Path::new(last)
146 .file_stem()
147 .and_then(|s| s.to_str())
148 .unwrap_or(last);
149 return sanitize_id(stem);
150 }
151 if let Some(host) = url.host_str() {
152 return sanitize_id(host);
153 }
154 }
155 sanitize_id(trimmed)
156}
157
158fn sanitize_id(raw: &str) -> String {
159 let lower = raw.to_lowercase();
160 let mut out = String::new();
161 for ch in lower.chars() {
162 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
163 out.push(ch);
164 } else if ch == '.' || ch == '/' {
165 out.push('-');
166 }
167 }
168 if out.is_empty() {
169 format!("ontology-{}", unix_now())
170 } else {
171 out.trim_matches('-').to_string()
172 }
173}
174
175fn extension_from_uri(uri: &str) -> String {
176 if let Ok(url) = url::Url::parse(uri) {
177 if let Some(path) = url.path_segments() {
178 if let Some(last) = path.filter(|s| !s.is_empty()).last() {
179 return Path::new(last)
180 .extension()
181 .and_then(|s| s.to_str())
182 .unwrap_or("ttl")
183 .to_lowercase();
184 }
185 }
186 }
187 "ttl".to_string()
188}
189
190fn sha256_file(path: &Path) -> Result<String, String> {
191 use sha2::Digest as Sha2Digest;
192 let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
193 let mut hasher = Sha256::new();
194 let mut buf = [0u8; 65_536];
195 loop {
196 let n = std::io::Read::read(&mut file, &mut buf).map_err(|e| e.to_string())?;
197 if n == 0 {
198 break;
199 }
200 hasher.update(&buf[..n]);
201 }
202 Ok(hex::encode(hasher.finalize()))
203}
204
205pub fn sha1_file(path: &Path) -> Result<String, String> {
206 let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
207 let mut hasher = Sha1::new();
208 let mut buf = [0u8; 8192];
209 loop {
210 let n = std::io::Read::read(&mut file, &mut buf).map_err(|e| e.to_string())?;
211 if n == 0 {
212 break;
213 }
214 hasher.update(&buf[..n]);
215 }
216 Ok(hasher
217 .finalize()
218 .iter()
219 .map(|b| format!("{b:02x}"))
220 .collect())
221}
222
223pub fn build_magnet_uri(info_hash_sha1: &str, display_name: &str) -> String {
224 qualia_core_db::webtorrent_seeder::build_magnet_uri(
225 info_hash_sha1,
226 display_name,
227 crate::api::get_active_daemon_port(),
228 )
229}
230
231fn daemon_base_url() -> String {
232 format!("http://127.0.0.1:{}", crate::api::get_active_daemon_port())
233}
234
235fn daemon_get(path: &str) -> Result<serde_json::Value, String> {
236 let client = reqwest::blocking::Client::builder()
237 .timeout(Duration::from_secs(6))
238 .build()
239 .map_err(|e| e.to_string())?;
240 let url = format!("{}{}", daemon_base_url(), path);
241 let resp = client.get(&url).send().map_err(|e| e.to_string())?;
242 if !resp.status().is_success() {
243 return Err(format!("daemon GET {path} returned {}", resp.status()));
244 }
245 resp.json().map_err(|e| e.to_string())
246}
247
248fn daemon_post(path: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
249 let client = reqwest::blocking::Client::builder()
250 .timeout(Duration::from_secs(8))
251 .build()
252 .map_err(|e| e.to_string())?;
253 let url = format!("{}{}", daemon_base_url(), path);
254 let resp = client
255 .post(&url)
256 .json(body)
257 .send()
258 .map_err(|e| e.to_string())?;
259 if !resp.status().is_success() {
260 return Err(format!("daemon POST {path} returned {}", resp.status()));
261 }
262 resp.json().map_err(|e| e.to_string())
263}
264
265fn sync_daemon_bandwidth_policy() {
266 let global = load_bandwidth_policy();
267 let body = serde_json::json!({
268 "global_limit_kbps": global.global_limit_kbps,
269 "metered_mode": global.metered_mode,
270 });
271 let _ = daemon_post("/torrent/policy", &body);
272}
273
274fn register_seed_on_daemon(entry: &OntologyWorkbenchEntry) -> Result<(), String> {
275 let body = serde_json::json!({
276 "info_hash": entry.info_hash_sha1,
277 "file_path": entry.c_q42_path,
278 "display_name": format!("{}.c.q42", entry.ontology_id),
279 "ontology_id": entry.ontology_id,
280 "bandwidth_limit_kbps": entry.torrent.bandwidth_limit_kbps,
281 });
282 daemon_post("/torrent/seed", &body)?;
283 Ok(())
284}
285
286fn unregister_seed_on_daemon(info_hash: &str) -> Result<(), String> {
287 let body = serde_json::json!({ "info_hash": info_hash });
288 let _ = daemon_post("/torrent/unseed", &body)?;
289 Ok(())
290}
291
292fn refresh_entry_magnet(entry: &mut OntologyWorkbenchEntry) {
293 let display = format!("{}.c.q42", entry.ontology_id);
294 entry.magnet_uri = qualia_core_db::webtorrent_seeder::ensure_magnet_webseed(
295 &entry.magnet_uri,
296 &entry.info_hash_sha1,
297 &display,
298 crate::api::get_active_daemon_port(),
299 );
300}
301
302fn load_entries(storage_root: &Path) -> Result<Vec<OntologyWorkbenchEntry>, String> {
303 let path = workbench_path(storage_root);
304 if !path.is_file() {
305 return Ok(vec![]);
306 }
307 let file = fs::File::open(path).map_err(|e| e.to_string())?;
308 let reader = BufReader::new(file);
309 let mut out = Vec::new();
310 for line in reader.lines() {
311 let line = line.map_err(|e| e.to_string())?;
312 if line.trim().is_empty() {
313 continue;
314 }
315 out.push(serde_json::from_str(&line).map_err(|e| e.to_string())?);
316 }
317 Ok(out)
318}
319
320fn write_entries(storage_root: &Path, entries: &[OntologyWorkbenchEntry]) -> Result<(), String> {
321 let path = workbench_path(storage_root);
322 if let Some(parent) = path.parent() {
323 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
324 }
325 let mut file = OpenOptions::new()
326 .create(true)
327 .write(true)
328 .truncate(true)
329 .open(path)
330 .map_err(|e| e.to_string())?;
331 for e in entries {
332 writeln!(
333 file,
334 "{}",
335 serde_json::to_string(e).map_err(|e| e.to_string())?
336 )
337 .map_err(|e| e.to_string())?;
338 }
339 Ok(())
340}
341
342pub fn load_bandwidth_policy() -> TorrentBandwidthGlobal {
343 let path = bandwidth_policy_path();
344 if let Ok(text) = fs::read_to_string(path) {
345 if let Ok(p) = serde_json::from_str(&text) {
346 return p;
347 }
348 }
349 TorrentBandwidthGlobal::default()
350}
351
352pub fn save_bandwidth_policy(policy: &TorrentBandwidthGlobal) -> Result<(), String> {
353 let path = bandwidth_policy_path();
354 if let Some(parent) = path.parent() {
355 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
356 }
357 let text = serde_json::to_string_pretty(policy).map_err(|e| e.to_string())?;
358 fs::write(path, text).map_err(|e| e.to_string())?;
359 sync_daemon_bandwidth_policy();
360 Ok(())
361}
362
363pub fn contact_eligible_for_share(entry: &OntologyWorkbenchEntry, contact: &ChatContact) -> bool {
364 if !entry.torrent.share_enabled {
365 return false;
366 }
367 match entry.torrent.audience {
368 ShareAudience::Private => false,
369 ShareAudience::PublicSeed | ShareAudience::AddressbookAll => true,
370 ShareAudience::SpecificDids => entry
371 .torrent
372 .allowed_contact_dids
373 .iter()
374 .any(|d| d == &contact.did),
375 ShareAudience::AddressbookCategory => entry
376 .torrent
377 .allowed_categories
378 .iter()
379 .any(|cat| contact.categories.iter().any(|c| c == cat)),
380 ShareAudience::ChatSessions => false,
381 }
382}
383
384pub fn session_eligible_for_share(entry: &OntologyWorkbenchEntry, session_did: &str) -> bool {
385 if !entry.torrent.share_enabled || session_did.is_empty() {
386 return false;
387 }
388 match entry.torrent.audience {
389 ShareAudience::Private => false,
390 ShareAudience::PublicSeed | ShareAudience::AddressbookAll => true,
391 ShareAudience::ChatSessions => entry
392 .torrent
393 .allowed_session_dids
394 .iter()
395 .any(|d| d == session_did),
396 ShareAudience::SpecificDids | ShareAudience::AddressbookCategory => false,
397 }
398}
399
400pub fn list_share_cards_for_contact(
401 storage_root: &Path,
402 contact_did: &str,
403) -> Result<Vec<OntologyShareCard>, String> {
404 let contact = crate::social_connect::find_contact_by_did(contact_did)
405 .ok_or_else(|| format!("Contact not found: {contact_did}"))?;
406 let entries = load_entries(storage_root)?;
407 Ok(entries
408 .iter()
409 .filter(|e| contact_eligible_for_share(e, &contact))
410 .map(|e| OntologyShareCard {
411 ontology_id: e.ontology_id.clone(),
412 title: e.title.clone(),
413 domain: e.domain.clone(),
414 magnet_uri: e.magnet_uri.clone(),
415 info_hash_sha1: e.info_hash_sha1.clone(),
416 quin_count: e.quin_count,
417 })
418 .collect())
419}
420
421pub fn list_share_cards_for_session(
422 storage_root: &Path,
423 session_did: &str,
424) -> Result<Vec<OntologyShareCard>, String> {
425 if session_did.trim().is_empty() {
426 return Err("session_did required".to_string());
427 }
428 let entries = load_entries(storage_root)?;
429 Ok(entries
430 .iter()
431 .filter(|e| session_eligible_for_share(e, session_did))
432 .map(|e| OntologyShareCard {
433 ontology_id: e.ontology_id.clone(),
434 title: e.title.clone(),
435 domain: e.domain.clone(),
436 magnet_uri: e.magnet_uri.clone(),
437 info_hash_sha1: e.info_hash_sha1.clone(),
438 quin_count: e.quin_count,
439 })
440 .collect())
441}
442
443pub async fn import_from_uri(
444 storage_root: &Path,
445 uri: String,
446 ontology_id: Option<String>,
447 domain: Option<String>,
448 title: Option<String>,
449) -> Result<WorkbenchImportResult, String> {
450 let uri = uri.trim().to_string();
451 if uri.is_empty() {
452 return Err("URI required".to_string());
453 }
454 if !uri.starts_with("http://") && !uri.starts_with("https://") {
455 return Err("Only http(s) URIs are supported".to_string());
456 }
457
458 let id = ontology_id
459 .map(|s| sanitize_id(&s))
460 .filter(|s| !s.is_empty())
461 .unwrap_or_else(|| derive_ontology_id_from_uri(&uri));
462 let ext = extension_from_uri(&uri);
463 let index = resource_import::index_dir(storage_root);
464 fs::create_dir_all(&index).map_err(|e| e.to_string())?;
465
466 let source_path = index.join(format!("{id}.source.{ext}"));
467 resource_import::stream_download(&uri, &source_path)
468 .await
469 .map_err(|e| e.to_string())?;
470
471 let quin_count = resource_import::ingest_local_rdf(&source_path, &id, storage_root, None)
472 .map_err(|e| e.to_string())?;
473
474 let q42_path = index.join(format!("{id}.q42"));
475 let c_q42_path = index.join(format!("{id}.c.q42"));
476 let stats = q42_compress::finalize_c_q42(&q42_path, &c_q42_path)?;
477
478 let source_removed = fs::remove_file(&source_path).is_ok();
479 let _ = fs::remove_file(&q42_path);
480
481 let sha256 = sha256_file(&c_q42_path)?;
482 let info_hash = sha1_file(&c_q42_path)?;
483 let display = format!("{id}.c.q42");
484 let magnet = build_magnet_uri(&info_hash, &display);
485 let now = unix_now();
486 let domain_label = domain.unwrap_or_else(|| "general".to_string());
487 let title_label = title.unwrap_or_else(|| id.clone());
488
489 let entry = OntologyWorkbenchEntry {
490 ontology_id: id.clone(),
491 title: title_label,
492 source_uri: uri,
493 domain: domain_label,
494 c_q42_path: c_q42_path.to_string_lossy().into_owned(),
495 quin_count,
496 sha256,
497 info_hash_sha1: info_hash,
498 magnet_uri: magnet,
499 imported_at: now,
500 torrent: OntologyTorrentPolicy::default(),
501 seed_active: false,
502 bytes_uploaded_total: 0,
503 bytes_uploaded_today: 0,
504 uploaded_day_epoch: day_epoch(now),
505 };
506
507 let mut entries = load_entries(storage_root)?;
508 entries.retain(|e| e.ontology_id != id);
509 entries.push(entry.clone());
510 write_entries(storage_root, &entries)?;
511
512 let meta_path = index.join(format!("{id}.c.q42.meta.json"));
513 let meta = serde_json::json!({
514 "ontology_id": id,
515 "source_uri": entry.source_uri,
516 "c_q42_path": entry.c_q42_path,
517 "quin_count": quin_count,
518 "sha256": entry.sha256,
519 "magnet_uri": entry.magnet_uri,
520 "imported_at": now,
521 });
522 fs::write(
523 meta_path,
524 serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?,
525 )
526 .map_err(|e| e.to_string())?;
527
528 Ok(WorkbenchImportResult {
529 entry,
530 compress_ratio: stats.ratio,
531 source_removed,
532 })
533}
534
535pub fn list_workbench_entries(storage_root: &Path) -> Result<Vec<OntologyWorkbenchEntry>, String> {
536 load_entries(storage_root)
537}
538
539pub fn set_torrent_policy(
540 storage_root: &Path,
541 ontology_id: &str,
542 policy: OntologyTorrentPolicy,
543) -> Result<OntologyWorkbenchEntry, String> {
544 let mut entries = load_entries(storage_root)?;
545 let idx = entries
546 .iter()
547 .position(|e| e.ontology_id == ontology_id)
548 .ok_or_else(|| format!("Workbench entry not found: {ontology_id}"))?;
549 entries[idx].torrent = policy.clone();
550 if policy.seed_enabled && entries[idx].seed_active {
551 refresh_entry_magnet(&mut entries[idx]);
552 let _ = register_seed_on_daemon(&entries[idx]);
553 }
554 let updated = entries[idx].clone();
555 write_entries(storage_root, &entries)?;
556 Ok(updated)
557}
558
559pub fn set_seed_active(
560 storage_root: &Path,
561 ontology_id: &str,
562 active: bool,
563) -> Result<OntologyWorkbenchEntry, String> {
564 let mut entries = load_entries(storage_root)?;
565 let idx = entries
566 .iter()
567 .position(|e| e.ontology_id == ontology_id)
568 .ok_or_else(|| format!("Workbench entry not found: {ontology_id}"))?;
569 let should_seed = active && entries[idx].torrent.seed_enabled;
570 entries[idx].seed_active = should_seed;
571 refresh_entry_magnet(&mut entries[idx]);
572 if should_seed {
573 register_seed_on_daemon(&entries[idx])?;
574 } else {
575 let _ = unregister_seed_on_daemon(&entries[idx].info_hash_sha1);
576 }
577 let updated = entries[idx].clone();
578 write_entries(storage_root, &entries)?;
579 Ok(updated)
580}
581
582fn effective_limit_kbps(entry: &OntologyWorkbenchEntry, global: &TorrentBandwidthGlobal) -> u32 {
583 let local = entry.torrent.bandwidth_limit_kbps;
584 let global_limit = global.global_limit_kbps;
585 match (local, global_limit) {
586 (0, 0) => 0,
587 (0, g) => g,
588 (l, 0) => l,
589 (l, g) => l.min(g),
590 }
591}
592
593pub fn torrent_telemetry(storage_root: &Path) -> serde_json::Value {
594 if let Ok(remote) = daemon_get("/torrent/telemetry") {
595 return remote;
596 }
597
598 let entries = load_entries(storage_root).unwrap_or_default();
599 let global = load_bandwidth_policy();
600 let active: Vec<_> = entries.iter().filter(|e| e.seed_active).collect();
601 let seeders = active.len();
602 let total_up: u64 = active.iter().map(|e| e.bytes_uploaded_today).sum();
603 let limit = active
604 .first()
605 .map(|e| effective_limit_kbps(e, &global))
606 .unwrap_or(global.global_limit_kbps);
607 let speed = if limit == 0 {
608 "unlimited".to_string()
609 } else {
610 format!("{limit} KiB/s cap")
611 };
612 serde_json::json!({
613 "seeders": seeders,
614 "leechers": 0,
615 "speed": speed,
616 "status": if seeders > 0 { "seeding" } else { "idle" },
617 "uploaded_today_kb": total_up / 1024,
618 "active_ontologies": active.iter().map(|e| &e.ontology_id).collect::<Vec<_>>(),
619 "global_bandwidth_kbps": global.global_limit_kbps,
620 "metered_mode": global.metered_mode,
621 "seeder": "qualia-daemon (unreachable)",
622 })
623}
624
625pub fn sync_workbench_seeds_to_daemon(storage_root: &Path) -> Result<serde_json::Value, String> {
627 sync_daemon_bandwidth_policy();
628 let entries = load_entries(storage_root)?;
629 let mut registered = 0usize;
630 for entry in &entries {
631 if entry.seed_active && entry.torrent.seed_enabled {
632 register_seed_on_daemon(entry)?;
633 registered += 1;
634 }
635 }
636 daemon_post("/torrent/sync", &serde_json::json!({})).map(|v| {
637 serde_json::json!({
638 "registered": registered,
639 "daemon": v,
640 })
641 })
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn derive_id_from_uri() {
650 let id = derive_ontology_id_from_uri("https://example.org/vocab/foaf.ttl");
651 assert!(id.contains("foaf"));
652 }
653
654 #[test]
655 fn magnet_format() {
656 let m = build_magnet_uri("abc123", "wordnet.c.q42");
657 assert!(m.starts_with("magnet:?xt=urn:btih:abc123"));
658 assert!(m.contains("&ws="));
659 assert!(m.contains("webseed"));
660 }
661
662 #[test]
663 fn session_share_eligibility() {
664 let did = "did:qualia:chat:group:abc123";
665 let mut entry = OntologyWorkbenchEntry {
666 ontology_id: "wordnet".into(),
667 title: "WordNet".into(),
668 source_uri: "https://example.org".into(),
669 domain: "lexicon".into(),
670 c_q42_path: "/tmp/wordnet.c.q42".into(),
671 quin_count: 100,
672 sha256: "sha".into(),
673 info_hash_sha1: "ih".into(),
674 magnet_uri: "magnet:?xt=urn:btih:ih".into(),
675 imported_at: 0,
676 torrent: OntologyTorrentPolicy {
677 share_enabled: true,
678 audience: ShareAudience::ChatSessions,
679 allowed_session_dids: vec![did.to_string()],
680 ..OntologyTorrentPolicy::default()
681 },
682 seed_active: false,
683 bytes_uploaded_total: 0,
684 bytes_uploaded_today: 0,
685 uploaded_day_epoch: 0,
686 };
687 assert!(session_eligible_for_share(&entry, did));
688 assert!(!session_eligible_for_share(
689 &entry,
690 "did:qualia:chat:solo:other"
691 ));
692 entry.torrent.share_enabled = false;
693 assert!(!session_eligible_for_share(&entry, did));
694 }
695}