Socle #154 : entité attachment, store, contrat DTO et routage session. Modules : domain/chat_attachment, application/chat_attachments, infrastructure/chat_attachments, app-tauri (commands/lib), backend dto.
174 lines
5.4 KiB
Rust
174 lines
5.4 KiB
Rust
//! Agent/chat attachment use cases.
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{ChatAttachmentImport, ChatAttachmentStore};
|
|
use domain::{
|
|
AgentId, ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind, Clock, IdGenerator,
|
|
LocalPath, Project, SessionId,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
|
|
/// Maximum raw chat attachment size.
|
|
pub const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
|
|
|
/// Input for [`ImportChatAttachments::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImportChatAttachmentsInput {
|
|
/// Project owning the chat session.
|
|
pub project: Project,
|
|
/// Target agent.
|
|
pub agent_id: AgentId,
|
|
/// Structured/chat session id.
|
|
pub session_id: SessionId,
|
|
/// Attachments to import.
|
|
pub attachments: Vec<ImportChatAttachmentItem>,
|
|
}
|
|
|
|
/// One source attachment import request.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImportChatAttachmentItem {
|
|
/// Local source path supplied by the driving adapter.
|
|
pub path: String,
|
|
/// Optional MIME type supplied by the driving adapter.
|
|
pub mime: Option<String>,
|
|
/// Origin kind.
|
|
pub source_kind: ChatAttachmentSourceKind,
|
|
}
|
|
|
|
/// Output of [`ImportChatAttachments::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImportChatAttachmentsOutput {
|
|
/// Imported durable attachments.
|
|
pub attachments: Vec<ChatAttachment>,
|
|
}
|
|
|
|
/// Imports user-supplied files into durable, sandbox-readable chat attachment storage.
|
|
pub struct ImportChatAttachments {
|
|
store: Arc<dyn ChatAttachmentStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
}
|
|
|
|
impl ImportChatAttachments {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ChatAttachmentStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
) -> Self {
|
|
Self { store, ids, clock }
|
|
}
|
|
|
|
/// Executes chat attachment imports.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ImportChatAttachmentsInput,
|
|
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
|
let mut imported = Vec::with_capacity(input.attachments.len());
|
|
for item in input.attachments {
|
|
let filename = filename_from_path(&item.path)?;
|
|
validate_attachment_filename(&filename)?;
|
|
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
|
|
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
let attachment = self
|
|
.store
|
|
.import_from_path(
|
|
&input.project.root,
|
|
input.project.id,
|
|
input.agent_id,
|
|
input.session_id,
|
|
&LocalPath::new(item.path),
|
|
ChatAttachmentImport {
|
|
id,
|
|
filename,
|
|
mime,
|
|
source_kind: item.source_kind,
|
|
created_at: now(&self.clock),
|
|
},
|
|
)
|
|
.await?;
|
|
imported.push(attachment);
|
|
}
|
|
Ok(ImportChatAttachmentsOutput {
|
|
attachments: imported,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn now(clock: &Arc<dyn Clock>) -> u64 {
|
|
u64::try_from(clock.now_millis()).unwrap_or(0)
|
|
}
|
|
|
|
fn filename_from_path(path: &str) -> Result<String, AppError> {
|
|
Path::new(path)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned()))
|
|
}
|
|
|
|
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
|
|
let lowered = filename.to_ascii_lowercase();
|
|
let blocked = [
|
|
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
|
|
"deb", "rpm",
|
|
];
|
|
if lowered.trim().is_empty()
|
|
|| lowered.contains('/')
|
|
|| lowered.contains('\\')
|
|
|| lowered == "."
|
|
|| lowered == ".."
|
|
{
|
|
return Err(AppError::Invalid("invalid attachment filename".to_owned()));
|
|
}
|
|
if lowered
|
|
.rsplit_once('.')
|
|
.is_some_and(|(_, ext)| blocked.contains(&ext))
|
|
{
|
|
return Err(AppError::Invalid(
|
|
"executable attachments are not allowed".to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result<String, AppError> {
|
|
let mime = raw
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| infer_mime(filename).to_owned());
|
|
if mime.eq_ignore_ascii_case("application/x-msdownload")
|
|
|| mime.eq_ignore_ascii_case("application/x-sh")
|
|
|| mime.eq_ignore_ascii_case("application/x-executable")
|
|
{
|
|
return Err(AppError::Invalid(
|
|
"executable attachments are not allowed".to_owned(),
|
|
));
|
|
}
|
|
Ok(mime)
|
|
}
|
|
|
|
fn infer_mime(filename: &str) -> &'static str {
|
|
match filename
|
|
.rsplit_once('.')
|
|
.map(|(_, ext)| ext.to_ascii_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("txt" | "md" | "log") => "text/plain",
|
|
Some("json") => "application/json",
|
|
Some("xml") => "application/xml",
|
|
Some("jpg" | "jpeg") => "image/jpeg",
|
|
Some("png") => "image/png",
|
|
Some("gif") => "image/gif",
|
|
Some("webp") => "image/webp",
|
|
Some("pdf") => "application/pdf",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|