use super::{AppState, client_key_from_request};
use axum::{
extract::{ConnectInfo, Multipart, Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use tracing::{debug, error, warn};
const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
const IMAGE_MIME_PREFIXES: &[&str] = &["image/"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentMeta {
pub file_id: String,
pub filename: String,
pub size: u64,
pub mime: String,
pub session_id: String,
pub created_at: DateTime<Utc>,
#[serde(skip)]
pub path: PathBuf,
}
impl AttachmentMeta {
pub fn is_image(&self) -> bool {
IMAGE_MIME_PREFIXES
.iter()
.any(|prefix| self.mime.starts_with(prefix))
}
}
fn session_dir(workspace_dir: &Path, session_id: &str) -> PathBuf {
workspace_dir.join("attachments").join(session_id)
}
pub async fn handle_upload(
State(state): State<AppState>,
AxumPath(session_id): AxumPath<String>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> impl IntoResponse {
let _client_key = client_key_from_request(Some(addr), &headers, state.trust_forwarded_headers);
let workspace_dir = state.config.lock().workspace_dir.clone();
let dir = session_dir(&workspace_dir, &session_id);
if let Err(err) = tokio::fs::create_dir_all(&dir).await {
error!(err = %err, dir = %dir.display(), "failed to create attachment dir");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to create attachment storage" })),
)
.into_response();
}
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() != Some("file") {
continue;
}
let filename = field
.file_name()
.map(|s| sanitize_filename(s))
.unwrap_or_else(|| "attachment".to_string());
let mime = field
.content_type()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = match field.bytes().await {
Ok(b) => b,
Err(err) => {
warn!(err = %err, "multipart field read failed");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "failed to read upload body" })),
)
.into_response();
}
};
if bytes.len() > MAX_ATTACHMENT_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!(
"attachment exceeds {} byte limit (received {} bytes)",
MAX_ATTACHMENT_BYTES,
bytes.len(),
),
})),
)
.into_response();
}
if bytes.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "empty file" })),
)
.into_response();
}
let file_id = uuid::Uuid::new_v4().to_string();
let file_path = dir.join(&file_id);
let meta_path = dir.join(format!("{file_id}.json"));
if let Err(err) = tokio::fs::write(&file_path, &bytes).await {
error!(err = %err, "failed to persist attachment bytes");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to persist file" })),
)
.into_response();
}
let meta = AttachmentMeta {
file_id: file_id.clone(),
filename,
size: bytes.len() as u64,
mime,
session_id: session_id.clone(),
created_at: Utc::now(),
path: file_path,
};
let serialized = match serde_json::to_string(&meta) {
Ok(s) => s,
Err(err) => {
error!(err = %err, "failed to serialize attachment metadata");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to serialize metadata" })),
)
.into_response();
}
};
if let Err(err) = tokio::fs::write(&meta_path, serialized).await {
error!(err = %err, "failed to persist attachment metadata");
let _ = tokio::fs::remove_file(&meta.path).await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to persist metadata" })),
)
.into_response();
}
debug!(
session_id = %session_id,
file_id = %meta.file_id,
filename = %meta.filename,
size = meta.size,
mime = %meta.mime,
"attachment stored",
);
return (
StatusCode::CREATED,
Json(serde_json::json!({
"file_id": meta.file_id,
"filename": meta.filename,
"size": meta.size,
"mime": meta.mime,
"session_id": meta.session_id,
"created_at": meta.created_at,
})),
)
.into_response();
}
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "missing 'file' field in multipart upload",
})),
)
.into_response()
}
pub async fn resolve_for_session(
workspace_dir: &Path,
session_id: &str,
file_ids: &[String],
) -> Vec<AttachmentMeta> {
let dir = session_dir(workspace_dir, session_id);
let mut out = Vec::with_capacity(file_ids.len());
for file_id in file_ids {
if file_id.contains('/') || file_id.contains('\\') || file_id.contains("..") {
warn!(
session_id,
file_id, "rejecting attachment id with path separators"
);
continue;
}
let meta_path = dir.join(format!("{file_id}.json"));
let raw = match tokio::fs::read(&meta_path).await {
Ok(r) => r,
Err(err) => {
warn!(err = %err, path = %meta_path.display(), "attachment metadata not found");
continue;
}
};
let mut meta: AttachmentMeta = match serde_json::from_slice(&raw) {
Ok(m) => m,
Err(err) => {
warn!(err = %err, "attachment metadata parse failed");
continue;
}
};
meta.path = dir.join(file_id);
if !meta.path.exists() {
warn!(file_id = %meta.file_id, "attachment bytes missing for metadata");
continue;
}
out.push(meta);
}
out
}
fn sanitize_filename(name: &str) -> String {
let basename = std::path::Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(name);
let cleaned: String = basename
.chars()
.filter(|c| !c.is_control())
.take(255)
.collect();
if cleaned.trim().is_empty() {
"attachment".to_string()
} else {
cleaned
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_paths_and_control_chars() {
assert_eq!(sanitize_filename("../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/foo.txt"), "foo.txt");
assert_eq!(sanitize_filename("hello\x00world.png"), "helloworld.png");
assert_eq!(sanitize_filename(" "), "attachment");
assert_eq!(sanitize_filename(""), "attachment");
}
#[test]
fn sanitize_clamps_length() {
let long = "a".repeat(1000);
assert_eq!(sanitize_filename(&long).len(), 255);
}
#[test]
fn meta_classifies_image_vs_document() {
let meta = AttachmentMeta {
file_id: "x".into(),
filename: "x".into(),
size: 1,
mime: "image/png".into(),
session_id: "s".into(),
created_at: Utc::now(),
path: PathBuf::new(),
};
assert!(meta.is_image());
let meta = AttachmentMeta {
mime: "application/pdf".into(),
..meta
};
assert!(!meta.is_image());
}
#[tokio::test]
async fn resolve_skips_path_traversal_ids() {
let tmp = tempfile::tempdir().unwrap();
let out = resolve_for_session(tmp.path(), "session-1", &["../escape".to_string()]).await;
assert!(out.is_empty());
}
#[tokio::test]
async fn resolve_returns_meta_for_valid_id() {
let tmp = tempfile::tempdir().unwrap();
let session_id = "session-1";
let dir = session_dir(tmp.path(), session_id);
tokio::fs::create_dir_all(&dir).await.unwrap();
let file_id = "00000000-0000-0000-0000-000000000001";
tokio::fs::write(dir.join(file_id), b"hello").await.unwrap();
let meta = AttachmentMeta {
file_id: file_id.to_string(),
filename: "hello.txt".into(),
size: 5,
mime: "text/plain".into(),
session_id: session_id.into(),
created_at: Utc::now(),
path: PathBuf::new(),
};
tokio::fs::write(
dir.join(format!("{file_id}.json")),
serde_json::to_string(&meta).unwrap(),
)
.await
.unwrap();
let resolved = resolve_for_session(tmp.path(), session_id, &[file_id.to_string()]).await;
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].filename, "hello.txt");
assert_eq!(resolved[0].mime, "text/plain");
assert!(resolved[0].path.exists());
}
}