Files
IdeA/crates/application/src/chat_attachments.rs
Blomios 790c4755be feat(chat): #155 paste image depuis presse-papier dans le composer custom (QA verte)
- frontend: onPaste sur CustomAgentChatView, détection MIME image, chip/preview d'attachment, envoi via le contrat #154 (adapters/agent, ports)

- backend: import d'image par bytes dans le store attachments (commands, chat_attachments app+infra, dto, ports) + tests
2026-08-06 11:01:50 +02:00

211 lines
6.7 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 {
/// Source payload supplied by the driving adapter.
pub source: ImportChatAttachmentSource,
/// Optional MIME type supplied by the driving adapter.
pub mime: Option<String>,
/// Origin kind.
pub source_kind: ChatAttachmentSourceKind,
}
/// Source payload for a chat attachment import.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportChatAttachmentSource {
/// Local source path supplied by the driving adapter.
Path(String),
/// In-memory bytes supplied by the driving adapter.
Bytes {
/// Display filename to persist in metadata and storage name.
filename: String,
/// Raw content bytes.
bytes: Vec<u8>,
},
}
/// 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_source(&item.source)?;
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 import = ChatAttachmentImport {
id,
filename,
mime,
source_kind: item.source_kind,
created_at: now(&self.clock),
};
let attachment = match item.source {
ImportChatAttachmentSource::Path(path) => {
self.store
.import_from_path(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&LocalPath::new(path),
import,
)
.await?
}
ImportChatAttachmentSource::Bytes { bytes, .. } => {
self.store
.import_from_bytes(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&bytes,
import,
)
.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 filename_from_source(source: &ImportChatAttachmentSource) -> Result<String, AppError> {
match source {
ImportChatAttachmentSource::Path(path) => filename_from_path(path),
ImportChatAttachmentSource::Bytes { filename, .. } => Ok(filename.clone()),
}
}
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",
}
}