1use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Read, Write};
5use std::path::{Path, PathBuf};
6
7use qualia_core_db::{q_hash, wal::WriteAheadLog, NQuin};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::chat_session::{self, SessionKind};
12
13const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
14const MAX_EXTRACTED_CHARS: usize = 512_000;
15const PREVIEW_CHARS: usize = 400;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum FileVisibility {
20 OwnerOnly,
21 SessionParticipants,
22 SpecificDids,
23 PublicInSession,
24}
25
26impl FileVisibility {
27 pub fn as_str(&self) -> &'static str {
28 match self {
29 FileVisibility::OwnerOnly => "owner_only",
30 FileVisibility::SessionParticipants => "session_participants",
31 FileVisibility::SpecificDids => "specific_dids",
32 FileVisibility::PublicInSession => "public_in_session",
33 }
34 }
35
36 pub fn from_str(s: &str) -> Result<Self, String> {
37 match s {
38 "owner_only" => Ok(FileVisibility::OwnerOnly),
39 "session_participants" => Ok(FileVisibility::SessionParticipants),
40 "specific_dids" => Ok(FileVisibility::SpecificDids),
41 "public_in_session" => Ok(FileVisibility::PublicInSession),
42 _ => Err(format!("unknown file visibility: {s}")),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct ChatFileSharing {
49 pub visibility: FileVisibility,
50 pub allow_download: bool,
51 pub allow_llm_context: bool,
52 pub allow_relay_sync: bool,
53 #[serde(default = "default_chat_file_sensitivity")]
54 pub sensitivity_level: u8,
55 #[serde(default)]
56 pub allowed_dids: Vec<String>,
57 #[serde(default)]
58 pub expires_at: Option<u64>,
59}
60
61impl Default for ChatFileSharing {
62 fn default() -> Self {
63 Self {
64 visibility: FileVisibility::SessionParticipants,
65 allow_download: true,
66 allow_llm_context: true,
67 allow_relay_sync: false,
68 sensitivity_level: NQuin::SENSITIVITY_RESTRICTED,
69 allowed_dids: vec![],
70 expires_at: None,
71 }
72 }
73}
74
75pub fn default_sharing_for_session(kind: SessionKind) -> ChatFileSharing {
76 match kind {
77 SessionKind::Solo => ChatFileSharing {
78 visibility: FileVisibility::OwnerOnly,
79 allow_download: true,
80 allow_llm_context: true,
81 allow_relay_sync: false,
82 sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
83 allowed_dids: vec![],
84 expires_at: None,
85 },
86 SessionKind::Group => ChatFileSharing::default(),
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PdfPageExtract {
92 pub page_index: u32,
93 pub text: String,
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
97#[serde(rename_all = "snake_case")]
98pub enum MediaKind {
99 #[default]
100 Document,
101 Image,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ParsedDocument {
106 pub media_kind: MediaKind,
107 pub mime_type: String,
108 pub extension: String,
109 pub page_count: Option<u32>,
110 pub image_width: Option<u32>,
111 pub image_height: Option<u32>,
112 pub full_text: String,
113 pub pages: Vec<PdfPageExtract>,
114 pub parse_status: String,
115 pub parse_error: Option<String>,
116 #[serde(default)]
117 pub thumbnail_rel_path: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ChatFileRecord {
122 pub file_id: String,
123 pub original_name: String,
124 pub mime_type: String,
125 pub extension: String,
126 pub sha256: String,
127 pub byte_size: u64,
128 pub page_count: Option<u32>,
129 pub text_preview: String,
130 pub storage_rel_path: String,
131 pub text_rel_path: String,
132 pub author_did: String,
133 pub author_name: Option<String>,
134 pub message_lamport: Option<u64>,
135 pub attached_at: u64,
136 pub sharing: ChatFileSharing,
137 pub parse_status: String,
138 pub parse_error: Option<String>,
139 #[serde(default = "default_chat_file_sensitivity")]
140 pub sensitivity_level: u8,
141 #[serde(default)]
142 pub media_kind: MediaKind,
143 #[serde(default)]
144 pub image_width: Option<u32>,
145 #[serde(default)]
146 pub image_height: Option<u32>,
147 #[serde(default)]
148 pub thumbnail_rel_path: Option<String>,
149 #[serde(default)]
150 pub vision_lexicon_id: Option<String>,
151 #[serde(default)]
152 pub vision_facet: Option<String>,
153 #[serde(default)]
154 pub vision_status: Option<String>,
155}
156
157fn default_chat_file_sensitivity() -> u8 {
158 NQuin::SENSITIVITY_PUBLIC
159}
160
161fn infer_sensitivity_from_sharing(sharing: &ChatFileSharing) -> u8 {
162 match sharing.visibility {
163 FileVisibility::PublicInSession => NQuin::SENSITIVITY_PUBLIC,
164 FileVisibility::SessionParticipants | FileVisibility::SpecificDids => {
165 NQuin::SENSITIVITY_RESTRICTED
166 }
167 FileVisibility::OwnerOnly => NQuin::SENSITIVITY_CLASSIFIED,
168 }
169}
170
171fn clamp_sensitivity_level(level: u8) -> u8 {
172 level.min(NQuin::SENSITIVITY_CLASSIFIED)
173}
174
175fn effective_sensitivity_from_sharing(sharing: &ChatFileSharing) -> u8 {
176 let inferred = infer_sensitivity_from_sharing(sharing);
177 clamp_sensitivity_level(sharing.sensitivity_level).max(inferred)
178}
179
180fn normalize_chat_file_sensitivity(record: &mut ChatFileRecord) {
181 let effective = effective_sensitivity_from_sharing(&record.sharing);
182 record.sharing.sensitivity_level = effective;
183 if record.sensitivity_level > NQuin::SENSITIVITY_CLASSIFIED {
184 record.sensitivity_level = effective;
185 return;
186 }
187 record.sensitivity_level = clamp_sensitivity_level(record.sensitivity_level).max(effective);
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AttachChatFileResult {
192 pub file: ChatFileRecord,
193 pub message_lamport: u64,
194}
195
196fn files_index_path(storage_root: &Path, session_id: &str) -> PathBuf {
197 chat_session::chats_dir(storage_root)
198 .join(session_id)
199 .join("files.jsonl")
200}
201
202fn files_dir(storage_root: &Path, session_id: &str) -> PathBuf {
203 chat_session::chats_dir(storage_root)
204 .join(session_id)
205 .join("files")
206}
207
208fn unix_now() -> u64 {
209 std::time::SystemTime::now()
210 .duration_since(std::time::UNIX_EPOCH)
211 .unwrap_or_default()
212 .as_secs()
213}
214
215fn sha256_hex(bytes: &[u8]) -> String {
216 let digest = Sha256::digest(bytes);
217 digest.iter().map(|b| format!("{b:02x}")).collect()
218}
219
220fn extension_of(name: &str) -> String {
221 Path::new(name)
222 .extension()
223 .and_then(|s| s.to_str())
224 .unwrap_or("")
225 .to_lowercase()
226}
227
228fn mime_for_extension(ext: &str) -> &'static str {
229 match ext {
230 "pdf" => "application/pdf",
231 "txt" => "text/plain",
232 "md" | "markdown" => "text/markdown",
233 "png" => "image/png",
234 "jpg" | "jpeg" => "image/jpeg",
235 "webp" => "image/webp",
236 "gif" => "image/gif",
237 _ => "application/octet-stream",
238 }
239}
240
241pub fn is_image_extension(ext: &str) -> bool {
242 matches!(ext, "png" | "jpg" | "jpeg" | "webp" | "gif")
243}
244
245pub fn is_image_record(record: &ChatFileRecord) -> bool {
246 record.media_kind == MediaKind::Image || is_image_extension(&record.extension)
247}
248
249pub fn parse_document_bytes(name: &str, bytes: &[u8]) -> ParsedDocument {
250 parse_document_bytes_with_dir(name, bytes, None)
251}
252
253pub fn parse_document_bytes_with_dir(
254 name: &str,
255 bytes: &[u8],
256 thumb_dir: Option<&Path>,
257) -> ParsedDocument {
258 let ext = extension_of(name);
259 let mime_type = mime_for_extension(&ext).to_string();
260
261 if is_image_extension(&ext) {
262 return parse_image_bytes(bytes, mime_type, ext, thumb_dir);
263 }
264
265 if ext == "pdf" {
266 return parse_pdf_bytes(bytes, mime_type, ext);
267 }
268
269 if ext == "txt" || ext == "md" || ext == "markdown" {
270 let text = String::from_utf8_lossy(bytes).into_owned();
271 let truncated = truncate_text(&text);
272 return ParsedDocument {
273 media_kind: MediaKind::Document,
274 mime_type,
275 extension: ext,
276 page_count: None,
277 image_width: None,
278 image_height: None,
279 full_text: truncated.clone(),
280 pages: vec![],
281 parse_status: "ok".to_string(),
282 parse_error: None,
283 thumbnail_rel_path: None,
284 };
285 }
286
287 let ext_copy = ext.clone();
288 ParsedDocument {
289 media_kind: MediaKind::Document,
290 mime_type,
291 extension: ext,
292 page_count: None,
293 image_width: None,
294 image_height: None,
295 full_text: String::new(),
296 pages: vec![],
297 parse_status: "unsupported".to_string(),
298 parse_error: Some(format!("Unsupported extension for chat file: {ext_copy}")),
299 thumbnail_rel_path: None,
300 }
301}
302
303fn parse_image_bytes(
304 bytes: &[u8],
305 mime_type: String,
306 ext: String,
307 thumb_dir: Option<&Path>,
308) -> ParsedDocument {
309 match image::load_from_memory(bytes) {
310 Ok(img) => {
311 let (w, h) = (img.width(), img.height());
312 let thumb_rel = None;
313 let _ = thumb_dir;
314 let facet = format!(
315 "image attachment {w}x{h} {mime_type} sha256:{}",
316 &sha256_hex(bytes)[..16]
317 );
318 let full_text = format!(
319 "[Image attachment]\nfilename_extension: {ext}\nmime: {mime_type}\ndimensions: {w}x{h}\nsha256: {}\nvision_facet: {facet}",
320 sha256_hex(bytes)
321 );
322 ParsedDocument {
323 media_kind: MediaKind::Image,
324 mime_type,
325 extension: ext,
326 page_count: None,
327 image_width: Some(w),
328 image_height: Some(h),
329 full_text,
330 pages: vec![],
331 parse_status: "ok".to_string(),
332 parse_error: None,
333 thumbnail_rel_path: thumb_rel,
334 }
335 }
336 Err(e) => ParsedDocument {
337 media_kind: MediaKind::Image,
338 mime_type,
339 extension: ext,
340 page_count: None,
341 image_width: None,
342 image_height: None,
343 full_text: String::new(),
344 pages: vec![],
345 parse_status: "failed".to_string(),
346 parse_error: Some(format!("Image decode failed: {e}")),
347 thumbnail_rel_path: None,
348 },
349 }
350}
351
352fn write_thumbnail(dir: &Path, file_id: &str, img: &image::DynamicImage) -> Option<String> {
353 let name = format!("{file_id}_thumb.jpg");
354 let path = dir.join(&name);
355 let thumb = img.thumbnail(320, 320);
356 thumb
357 .save_with_format(&path, image::ImageFormat::Jpeg)
358 .ok()?;
359 Some(format!("files/{name}"))
360}
361
362fn parse_pdf_bytes(bytes: &[u8], mime_type: String, ext: String) -> ParsedDocument {
363 match pdf_extract::extract_text_from_mem_by_pages(bytes) {
364 Ok(pages) => {
365 let page_count = pages.len() as u32;
366 let mut full = String::new();
367 let mut page_extracts = Vec::with_capacity(pages.len());
368 for (i, page_text) in pages.into_iter().enumerate() {
369 if !full.is_empty() {
370 full.push_str("\n\n");
371 }
372 full.push_str(&format!("--- Page {} ---\n{}", i + 1, page_text));
373 page_extracts.push(PdfPageExtract {
374 page_index: i as u32,
375 text: page_text,
376 });
377 }
378 let truncated = truncate_text(&full);
379 let status = if truncated.trim().is_empty() {
380 "partial"
381 } else {
382 "ok"
383 };
384 ParsedDocument {
385 media_kind: MediaKind::Document,
386 mime_type,
387 extension: ext,
388 page_count: Some(page_count),
389 image_width: None,
390 image_height: None,
391 full_text: truncated,
392 pages: page_extracts,
393 parse_status: status.to_string(),
394 parse_error: if status == "partial" {
395 Some("PDF parsed but no extractable text (scanned image PDF)".to_string())
396 } else {
397 None
398 },
399 thumbnail_rel_path: None,
400 }
401 }
402 Err(e) => ParsedDocument {
403 media_kind: MediaKind::Document,
404 mime_type,
405 extension: ext,
406 page_count: None,
407 image_width: None,
408 image_height: None,
409 full_text: String::new(),
410 pages: vec![],
411 parse_status: "failed".to_string(),
412 parse_error: Some(format!("{e}")),
413 thumbnail_rel_path: None,
414 },
415 }
416}
417
418fn try_vision_bind(
419 storage_root: &Path,
420 source_path: &Path,
421) -> (Option<String>, Option<String>, String) {
422 let active = crate::api::load_active_model_record_from_disk();
423 match crate::vision_ingest::ingest_image_with_active_record(
424 storage_root,
425 active,
426 source_path,
427 "ChatShare",
428 ) {
429 Ok(result) => (
430 Some(result.lexicon_id),
431 Some(result.facet),
432 "ok".to_string(),
433 ),
434 Err(e) => (None, None, format!("skipped:{e}")),
435 }
436}
437
438fn truncate_text(text: &str) -> String {
439 if text.len() <= MAX_EXTRACTED_CHARS {
440 return text.to_string();
441 }
442 let mut end = MAX_EXTRACTED_CHARS;
443 while end > 0 && !text.is_char_boundary(end) {
444 end -= 1;
445 }
446 format!("{}…", &text[..end])
447}
448
449fn preview_text(text: &str) -> String {
450 let flat = text.replace('\n', " ");
451 if flat.chars().count() <= PREVIEW_CHARS {
452 return flat;
453 }
454 flat.chars().take(PREVIEW_CHARS).collect::<String>() + "…"
455}
456
457fn load_all_records(storage_root: &Path, session_id: &str) -> Result<Vec<ChatFileRecord>, String> {
458 let path = files_index_path(storage_root, session_id);
459 if !path.is_file() {
460 return Ok(vec![]);
461 }
462 let file = File::open(path).map_err(|e| e.to_string())?;
463 let reader = BufReader::new(file);
464 let mut out = Vec::new();
465 for line in reader.lines() {
466 let line = line.map_err(|e| e.to_string())?;
467 if line.trim().is_empty() {
468 continue;
469 }
470 let mut record: ChatFileRecord = serde_json::from_str(&line).map_err(|e| e.to_string())?;
471 normalize_chat_file_sensitivity(&mut record);
472 out.push(record);
473 }
474 Ok(out)
475}
476
477fn write_all_records(
478 storage_root: &Path,
479 session_id: &str,
480 records: &[ChatFileRecord],
481) -> Result<(), String> {
482 let path = files_index_path(storage_root, session_id);
483 if let Some(parent) = path.parent() {
484 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
485 }
486 let mut file = OpenOptions::new()
487 .create(true)
488 .write(true)
489 .truncate(true)
490 .open(path)
491 .map_err(|e| e.to_string())?;
492 for r in records {
493 writeln!(
494 file,
495 "{}",
496 serde_json::to_string(r).map_err(|e| e.to_string())?
497 )
498 .map_err(|e| e.to_string())?;
499 }
500 Ok(())
501}
502
503fn session_participant_dids(storage_root: &Path, session_id: &str) -> Vec<String> {
504 let Ok(session) = chat_session::load_session(storage_root, session_id) else {
505 return vec![];
506 };
507 session
508 .environment
509 .participants
510 .iter()
511 .map(|p| p.did.clone())
512 .collect()
513}
514
515pub fn can_view_file(
516 record: &ChatFileRecord,
517 viewer_did: &str,
518 participant_dids: &[String],
519) -> bool {
520 if record
521 .sharing
522 .expires_at
523 .is_some_and(|exp| unix_now() > exp)
524 {
525 return false;
526 }
527 if record.author_did == viewer_did {
528 return true;
529 }
530 match record.sharing.visibility {
531 FileVisibility::OwnerOnly => false,
532 FileVisibility::SessionParticipants | FileVisibility::PublicInSession => {
533 participant_dids.iter().any(|d| d == viewer_did) || participant_dids.is_empty()
534 }
535 FileVisibility::SpecificDids => record.sharing.allowed_dids.iter().any(|d| d == viewer_did),
536 }
537}
538
539pub fn can_use_in_llm_context(
540 record: &ChatFileRecord,
541 viewer_did: &str,
542 participants: &[String],
543) -> bool {
544 record.sharing.allow_llm_context && can_view_file(record, viewer_did, participants)
545}
546
547pub fn attach_chat_file(
548 storage_root: &Path,
549 session_id: &str,
550 source_path: &Path,
551 sharing: ChatFileSharing,
552) -> Result<AttachChatFileResult, String> {
553 if !source_path.is_file() {
554 return Err(format!("File not found: {}", source_path.display()));
555 }
556
557 let ext = extension_of(
558 source_path
559 .file_name()
560 .and_then(|s| s.to_str())
561 .unwrap_or(""),
562 );
563 if ext != "pdf" && ext != "txt" && ext != "md" && ext != "markdown" && !is_image_extension(&ext)
564 {
565 return Err(format!(
566 "Unsupported file type '.{ext}' — attach PDF, TXT, Markdown, or image (PNG/JPEG/WebP/GIF)"
567 ));
568 }
569
570 let mut bytes = Vec::new();
571 File::open(source_path)
572 .and_then(|mut f| f.read_to_end(&mut bytes))
573 .map_err(|e| e.to_string())?;
574
575 let sha = sha256_hex(&bytes);
576 let file_id = format!("{:016x}", q_hash(&format!("chatfile:{session_id}:{sha}")));
577 let original_name = source_path
578 .file_name()
579 .and_then(|s| s.to_str())
580 .unwrap_or("attachment")
581 .to_string();
582
583 let dir = files_dir(storage_root, session_id);
584 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
585
586 let mut parsed = parse_document_bytes(&original_name, &bytes);
587 if parsed.media_kind == MediaKind::Image {
588 if let Ok(img) = image::load_from_memory(&bytes) {
589 parsed.thumbnail_rel_path = write_thumbnail(&dir, &file_id, &img);
590 }
591 }
592
593 let (vision_lexicon_id, vision_facet, vision_status) = if parsed.media_kind == MediaKind::Image
594 {
595 let (lex, facet, status) = try_vision_bind(storage_root, source_path);
596 if let Some(ref f) = facet {
597 parsed.full_text.push_str(&format!("\nvision_ingest: {f}"));
598 }
599 (lex, facet, status)
600 } else {
601 (None, None, String::new())
602 };
603
604 let bin_name = format!("{file_id}.bin");
605 let txt_name = format!("{file_id}.txt");
606 fs::write(dir.join(&bin_name), &bytes).map_err(|e| e.to_string())?;
607 fs::write(dir.join(&txt_name), &parsed.full_text).map_err(|e| e.to_string())?;
608
609 let profile = crate::user_profile::load_profile();
610 let author_name = profile
611 .sharing
612 .share_display_name
613 .then(|| profile.display_name.clone());
614
615 let text_preview = if parsed.media_kind == MediaKind::Image {
616 match (parsed.image_width, parsed.image_height) {
617 (Some(w), Some(h)) => format!("{w}×{h} {}", parsed.mime_type),
618 _ => parsed.mime_type.clone(),
619 }
620 } else {
621 preview_text(&parsed.full_text)
622 };
623
624 let mut sharing = sharing;
625 let sensitivity_level = effective_sensitivity_from_sharing(&sharing);
626 sharing.sensitivity_level = sensitivity_level;
627 let record = ChatFileRecord {
628 file_id: file_id.clone(),
629 original_name: original_name.clone(),
630 mime_type: parsed.mime_type,
631 extension: parsed.extension,
632 sha256: sha,
633 byte_size: bytes.len() as u64,
634 page_count: parsed.page_count,
635 text_preview,
636 storage_rel_path: format!("files/{bin_name}"),
637 text_rel_path: format!("files/{txt_name}"),
638 author_did: profile.public_did.clone(),
639 author_name,
640 message_lamport: None,
641 attached_at: unix_now(),
642 sharing,
643 parse_status: parsed.parse_status,
644 parse_error: parsed.parse_error,
645 sensitivity_level,
646 media_kind: parsed.media_kind,
647 image_width: parsed.image_width,
648 image_height: parsed.image_height,
649 thumbnail_rel_path: parsed.thumbnail_rel_path.clone(),
650 vision_lexicon_id: vision_lexicon_id.clone(),
651 vision_facet: vision_facet.clone(),
652 vision_status: if vision_status.is_empty() {
653 None
654 } else {
655 Some(vision_status)
656 },
657 };
658
659 let size_note = if record.media_kind == MediaKind::Image {
660 match (record.image_width, record.image_height) {
661 (Some(w), Some(h)) => format!(" ({w}×{h})"),
662 _ => String::new(),
663 }
664 } else {
665 record
666 .page_count
667 .map(|n| format!(" ({n} pages)"))
668 .unwrap_or_default()
669 };
670 let icon = if record.media_kind == MediaKind::Image {
671 "🖼️"
672 } else {
673 "📎"
674 };
675 let msg_content = format!(
676 "{icon} Attached {}: {original_name}{size_note} [{}]",
677 if record.media_kind == MediaKind::Image {
678 "image"
679 } else {
680 "file"
681 },
682 record.sharing.visibility.as_str()
683 );
684 let lamport = chat_session::append_message_with_author(
685 storage_root,
686 session_id,
687 chat_session::Role::User,
688 &msg_content,
689 None,
690 Some("chat_file".to_string()),
691 Some(profile.public_did.clone()),
692 record.author_name.clone(),
693 None,
694 )
695 .map_err(|e| e.to_string())?;
696
697 let mut record = record;
698 record.message_lamport = Some(lamport);
699
700 let mut records = load_all_records(storage_root, session_id)?;
701 records.push(record.clone());
702 write_all_records(storage_root, session_id, &records)?;
703
704 append_file_wal_quin(storage_root, session_id, &record)?;
705
706 Ok(AttachChatFileResult {
707 file: record,
708 message_lamport: lamport,
709 })
710}
711
712fn append_file_wal_quin(
713 storage_root: &Path,
714 session_id: &str,
715 record: &ChatFileRecord,
716) -> Result<(), String> {
717 let wal_path = chat_session::chats_dir(storage_root)
718 .join(session_id)
719 .join("chat.wal");
720 if !wal_path.is_file() {
721 return Ok(());
722 }
723 let subject = q_hash(&format!("chat:session:{session_id}"));
724 let predicate = q_hash("chat:hasFile");
725 let object = u64::from_str_radix(&record.file_id, 16).unwrap_or(0) & OBJECT_HASH_MASK;
726 let context = q_hash(&record.author_did);
727 let metadata = (record.byte_size.min(0x1FFF_FFFF)) << 32;
728 let parity = subject ^ predicate ^ object ^ context ^ metadata;
729 let mut quin = NQuin {
730 subject,
731 predicate,
732 object,
733 context,
734 metadata,
735 parity,
736 };
737 quin.set_sensitivity_byte(record.sensitivity_level);
738 if let Ok(mut wal) = WriteAheadLog::open(&wal_path) {
739 wal.append_mutation(&quin).map_err(|e| e.to_string())?;
740 }
741 Ok(())
742}
743
744pub fn set_chat_file_sharing(
745 storage_root: &Path,
746 session_id: &str,
747 file_id: &str,
748 sharing: ChatFileSharing,
749) -> Result<ChatFileRecord, String> {
750 let profile = crate::user_profile::load_profile();
751 let mut records = load_all_records(storage_root, session_id)?;
752 let idx = records
753 .iter()
754 .position(|r| r.file_id == file_id)
755 .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
756 if records[idx].author_did != profile.public_did {
757 return Err("Only the file owner can change sharing permissions".to_string());
758 }
759 let mut sharing = sharing;
760 let sensitivity_level = effective_sensitivity_from_sharing(&sharing);
761 sharing.sensitivity_level = sensitivity_level;
762 records[idx].sharing = sharing;
763 records[idx].sensitivity_level = sensitivity_level;
764 let updated = records[idx].clone();
765 write_all_records(storage_root, session_id, &records)?;
766 Ok(updated)
767}
768
769pub fn list_chat_files(
770 storage_root: &Path,
771 session_id: &str,
772 viewer_did: Option<&str>,
773) -> Result<Vec<ChatFileRecord>, String> {
774 let viewer = viewer_did
775 .map(|s| s.to_string())
776 .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
777 let participants = session_participant_dids(storage_root, session_id);
778 let records = load_all_records(storage_root, session_id)?;
779 Ok(records
780 .into_iter()
781 .filter(|r| can_view_file(r, &viewer, &participants))
782 .collect())
783}
784
785pub fn read_file_text(
786 storage_root: &Path,
787 session_id: &str,
788 file_id: &str,
789 viewer_did: Option<&str>,
790) -> Result<String, String> {
791 let viewer = viewer_did
792 .map(|s| s.to_string())
793 .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
794 let participants = session_participant_dids(storage_root, session_id);
795 let records = load_all_records(storage_root, session_id)?;
796 let record = records
797 .iter()
798 .find(|r| r.file_id == file_id)
799 .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
800 if !can_view_file(record, &viewer, &participants) {
801 return Err("You do not have permission to view this file".to_string());
802 }
803 let text_path = chat_session::chats_dir(storage_root)
804 .join(session_id)
805 .join(&record.text_rel_path);
806 fs::read_to_string(text_path).map_err(|e| e.to_string())
807}
808
809pub fn build_chat_files_context_block(
810 storage_root: &Path,
811 session_id: &str,
812 max_chars: usize,
813) -> String {
814 let profile = crate::user_profile::load_profile();
815 let participants = session_participant_dids(storage_root, session_id);
816 let Ok(files) = list_chat_files(storage_root, session_id, Some(&profile.public_did)) else {
817 return String::new();
818 };
819
820 let mut lines = vec!["[Chat attached files]".to_string()];
821 let mut used = 0usize;
822
823 for f in &files {
824 if !can_use_in_llm_context(f, &profile.public_did, &participants) {
825 continue;
826 }
827 let dim = match (f.image_width, f.image_height) {
828 (Some(w), Some(h)) => format!(", {w}x{h}"),
829 _ => String::new(),
830 };
831 let header = format!(
832 "- {} ({}{}{} bytes, visibility={})",
833 f.original_name,
834 f.mime_type,
835 dim,
836 f.byte_size,
837 f.sharing.visibility.as_str()
838 );
839 used += header.len();
840 lines.push(header);
841
842 if let Some(ref facet) = f.vision_facet {
843 let line = format!(" vision_facet: {facet}");
844 used += line.len();
845 lines.push(line);
846 }
847
848 if used >= max_chars {
849 lines.push(" … (truncated)".to_string());
850 break;
851 }
852
853 if is_image_record(f) {
854 lines.push(
855 " note: multimodal image — use active VLM mmproj when vision_status=ok"
856 .to_string(),
857 );
858 continue;
859 }
860
861 if let Ok(text) = read_file_text(
862 storage_root,
863 session_id,
864 &f.file_id,
865 Some(&profile.public_did),
866 ) {
867 let budget = max_chars.saturating_sub(used);
868 let excerpt = if text.len() <= budget {
869 text
870 } else {
871 let mut end = budget;
872 while end > 0 && !text.is_char_boundary(end) {
873 end -= 1;
874 }
875 format!("{}…", &text[..end])
876 };
877 used += excerpt.len();
878 lines.push(format!(" excerpt: {excerpt}"));
879 }
880 }
881
882 if lines.len() == 1 {
883 return String::new();
884 }
885 lines.join("\n")
886}
887
888pub fn resolve_chat_file_path(
889 storage_root: &Path,
890 session_id: &str,
891 file_id: &str,
892 variant: &str,
893 viewer_did: Option<&str>,
894) -> Result<PathBuf, String> {
895 let viewer = viewer_did
896 .map(|s| s.to_string())
897 .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
898 let participants = session_participant_dids(storage_root, session_id);
899 let records = load_all_records(storage_root, session_id)?;
900 let record = records
901 .iter()
902 .find(|r| r.file_id == file_id)
903 .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
904 if !can_view_file(record, &viewer, &participants) {
905 return Err("You do not have permission to view this file".to_string());
906 }
907 let rel = match variant {
908 "thumbnail" => record
909 .thumbnail_rel_path
910 .as_deref()
911 .unwrap_or(&record.storage_rel_path),
912 _ => &record.storage_rel_path,
913 };
914 Ok(chat_session::chats_dir(storage_root)
915 .join(session_id)
916 .join(rel))
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use std::env;
923
924 #[test]
925 fn default_sharing_differs_by_session_kind() {
926 let solo = default_sharing_for_session(SessionKind::Solo);
927 assert_eq!(solo.visibility, FileVisibility::OwnerOnly);
928 assert_eq!(solo.sensitivity_level, NQuin::SENSITIVITY_CLASSIFIED);
929 let group = default_sharing_for_session(SessionKind::Group);
930 assert_eq!(group.visibility, FileVisibility::SessionParticipants);
931 assert_eq!(group.sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
932 }
933
934 #[test]
935 fn parse_png_image() {
936 let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
937 let dyn_img = image::DynamicImage::ImageRgba8(img);
938 let mut buf = Vec::new();
939 dyn_img
940 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
941 .expect("encode png");
942 let parsed = parse_document_bytes("snap.png", &buf);
943 assert_eq!(parsed.media_kind, MediaKind::Image);
944 assert_eq!(parsed.image_width, Some(2));
945 assert_eq!(parsed.parse_status, "ok");
946 }
947
948 #[test]
949 fn parse_txt_document() {
950 let parsed = parse_document_bytes("notes.txt", b"hello chat files");
951 assert_eq!(parsed.parse_status, "ok");
952 assert!(parsed.full_text.contains("hello"));
953 }
954
955 #[test]
956 fn sharing_permission_gate() {
957 let record = ChatFileRecord {
958 file_id: "abc".to_string(),
959 original_name: "x.pdf".to_string(),
960 mime_type: "application/pdf".to_string(),
961 extension: "pdf".to_string(),
962 sha256: "00".to_string(),
963 byte_size: 10,
964 page_count: Some(1),
965 text_preview: "p".to_string(),
966 storage_rel_path: "files/a.bin".to_string(),
967 text_rel_path: "files/a.txt".to_string(),
968 author_did: "did:owner".to_string(),
969 author_name: None,
970 message_lamport: Some(1),
971 attached_at: 0,
972 sharing: ChatFileSharing {
973 visibility: FileVisibility::OwnerOnly,
974 allow_download: false,
975 allow_llm_context: true,
976 allow_relay_sync: false,
977 sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
978 allowed_dids: vec![],
979 expires_at: None,
980 },
981 parse_status: "ok".to_string(),
982 parse_error: None,
983 sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
984 media_kind: MediaKind::Document,
985 image_width: None,
986 image_height: None,
987 thumbnail_rel_path: None,
988 vision_lexicon_id: None,
989 vision_facet: None,
990 vision_status: None,
991 };
992 assert!(can_view_file(&record, "did:owner", &[]));
993 assert!(!can_view_file(&record, "did:other", &[]));
994 }
995
996 #[test]
997 fn attach_and_list_round_trip() {
998 let mut storage = env::temp_dir();
999 storage.push(format!("qualia-chat-files-{}", rand::random::<u32>()));
1000 let session_id =
1001 chat_session::create_session(&storage, Some("Files test".to_string()), None)
1002 .expect("create session");
1003
1004 let src = storage.join("sample.md");
1005 fs::write(&src, "# Title\n\nBody text for chat.").unwrap();
1006
1007 let sharing = default_sharing_for_session(SessionKind::Group);
1008 let attached = attach_chat_file(&storage, &session_id, &src, sharing).expect("attach");
1009 assert_eq!(attached.file.extension, "md");
1010 assert!(attached.message_lamport > 0);
1011
1012 let owner_did = attached.file.author_did.clone();
1013 let listed = list_chat_files(&storage, &session_id, Some(&owner_did)).unwrap();
1014 assert_eq!(listed.len(), 1);
1015 assert_eq!(listed[0].sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
1016
1017 let updated = set_chat_file_sharing(
1018 &storage,
1019 &session_id,
1020 &attached.file.file_id,
1021 ChatFileSharing {
1022 visibility: FileVisibility::SpecificDids,
1023 allow_download: true,
1024 allow_llm_context: false,
1025 allow_relay_sync: false,
1026 sensitivity_level: NQuin::SENSITIVITY_PUBLIC,
1027 allowed_dids: vec!["did:friend".to_string()],
1028 expires_at: None,
1029 },
1030 )
1031 .expect("set sharing");
1032 assert_eq!(updated.sharing.visibility, FileVisibility::SpecificDids);
1033 assert_eq!(updated.sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
1034 assert_eq!(
1035 updated.sharing.sensitivity_level,
1036 NQuin::SENSITIVITY_RESTRICTED
1037 );
1038 }
1039
1040 #[test]
1041 fn explicit_sensitivity_can_raise_but_not_lower_visibility_floor() {
1042 let restricted = ChatFileSharing {
1043 visibility: FileVisibility::SessionParticipants,
1044 allow_download: true,
1045 allow_llm_context: true,
1046 allow_relay_sync: false,
1047 sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
1048 allowed_dids: vec![],
1049 expires_at: None,
1050 };
1051 assert_eq!(
1052 effective_sensitivity_from_sharing(&restricted),
1053 NQuin::SENSITIVITY_CLASSIFIED
1054 );
1055
1056 let owner_only = ChatFileSharing {
1057 visibility: FileVisibility::OwnerOnly,
1058 allow_download: true,
1059 allow_llm_context: true,
1060 allow_relay_sync: false,
1061 sensitivity_level: NQuin::SENSITIVITY_PUBLIC,
1062 allowed_dids: vec![],
1063 expires_at: None,
1064 };
1065 assert_eq!(
1066 effective_sensitivity_from_sharing(&owner_only),
1067 NQuin::SENSITIVITY_CLASSIFIED
1068 );
1069 }
1070}