feat(media): multimodal attachments — image/video inlining per model capabilities
Adds media.rs for per-turn attachment routing, message_builder partitioning by resolved model capabilities, controller endpoints for uploads, and server routing for /data/* behind session auth. See CLAUDE.md §Multimodal attachments for full design.
This commit is contained in:
@@ -210,6 +210,10 @@ impl ApiError {
|
||||
pub fn forbidden(msg: impl Into<String>) -> Self {
|
||||
Self { status: StatusCode::FORBIDDEN, message: msg.into() }
|
||||
}
|
||||
|
||||
pub fn payload_too_large(msg: impl Into<String>) -> Self {
|
||||
Self { status: StatusCode::PAYLOAD_TOO_LARGE, message: msg.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
|
||||
|
||||
@@ -9,17 +9,25 @@ use tokio::io::AsyncWriteExt;
|
||||
|
||||
use core_api::message_meta::Attachment;
|
||||
|
||||
use skald_core::session::handler::media::sniff_mime;
|
||||
use skald_core::skald::Skald;
|
||||
use skald_core::tools::fs as fs_tools;
|
||||
use super::{ApiError, guard::AuthUser, require_context};
|
||||
use super::sessions::SourcePath;
|
||||
|
||||
/// Max bytes accepted for a single uploaded file; anything larger is cut off
|
||||
/// mid-stream, the partial file removed, and the request answered 413.
|
||||
const MAX_UPLOAD_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// `POST /api/{source}/uploads`
|
||||
///
|
||||
/// Accepts a `multipart/form-data` body with one or more file fields and saves
|
||||
/// each under `data/uploads/{session_id}/`. Bytes are streamed straight to disk
|
||||
/// (`field.chunk()` → file), never buffered whole in RAM, so arbitrarily large
|
||||
/// files are fine — the route disables the default body-size limit (see router).
|
||||
/// each under `data/uploads/{user_id}/{session_id}/` (per-user namespaced, so
|
||||
/// colliding session ids across users never share a directory). Bytes are
|
||||
/// streamed straight to disk (`field.chunk()` → file), never buffered whole in
|
||||
/// RAM — the route disables the default body-size limit (see router) and
|
||||
/// enforces [`MAX_UPLOAD_BYTES`] itself. When the magic bytes are recognized,
|
||||
/// the sniffed MIME wins over the client-supplied `Content-Type`.
|
||||
///
|
||||
/// Returns the saved [`Attachment`]s (project-root-relative path, name, MIME,
|
||||
/// size) so the client can show chips and echo them back when sending the message.
|
||||
@@ -34,7 +42,7 @@ pub async fn upload(
|
||||
// directory the message will reference.
|
||||
let session_id = ctx.chat_hub.session_handler(&p.source).await?.session_id;
|
||||
|
||||
let dir_rel = format!("data/uploads/{session_id}");
|
||||
let dir_rel = format!("data/uploads/{}/{session_id}", auth.user_id);
|
||||
let dir_abs = fs_tools::resolve(&dir_rel)?;
|
||||
tokio::fs::create_dir_all(&dir_abs).await?;
|
||||
|
||||
@@ -54,13 +62,30 @@ pub async fn upload(
|
||||
.map_err(|e| ApiError::from(anyhow::anyhow!("cannot create {}: {e}", abs_path.display())))?;
|
||||
|
||||
let mut size: u64 = 0;
|
||||
let mut too_large = false;
|
||||
while let Some(chunk) = field.chunk().await
|
||||
.map_err(|e| ApiError::bad_request(format!("upload read error: {e}")))?
|
||||
{
|
||||
file.write_all(&chunk).await?;
|
||||
size += chunk.len() as u64;
|
||||
if size > MAX_UPLOAD_BYTES {
|
||||
too_large = true;
|
||||
break;
|
||||
}
|
||||
file.write_all(&chunk).await?;
|
||||
}
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
if too_large {
|
||||
let _ = tokio::fs::remove_file(&abs_path).await;
|
||||
return Err(ApiError::payload_too_large(format!(
|
||||
"'{final_name}' exceeds the {} MiB upload limit",
|
||||
MAX_UPLOAD_BYTES / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
// The sniffed type wins over the client claim when we recognize the bytes.
|
||||
let mimetype = sniff_head(&abs_path).await.map(String::from).or(mimetype);
|
||||
|
||||
saved.push(Attachment {
|
||||
path: format!("{dir_rel}/{final_name}"),
|
||||
@@ -73,6 +98,14 @@ pub async fn upload(
|
||||
Ok(Json(saved))
|
||||
}
|
||||
|
||||
/// Reads the first bytes of a saved upload and sniffs its real media type.
|
||||
async fn sniff_head(path: &StdPath) -> Option<&'static str> {
|
||||
let mut file = tokio::fs::File::open(path).await.ok()?;
|
||||
let mut head = [0u8; 16];
|
||||
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
|
||||
sniff_mime(&head[..n])
|
||||
}
|
||||
|
||||
/// Reduces an arbitrary client filename to a safe basename: directory components
|
||||
/// are dropped and an empty/`.`/`..` result falls back to `"file"`.
|
||||
fn sanitize_filename(raw: &str) -> String {
|
||||
|
||||
+14
-2
@@ -78,6 +78,7 @@ impl WebServer {
|
||||
Arc::clone(&skald),
|
||||
api::guard::require_auth,
|
||||
));
|
||||
let skald_for_data = Arc::clone(&skald);
|
||||
|
||||
// Resolve the app state first so the resulting `Router<()>` can host the
|
||||
// stateless plugin routers via `nest`.
|
||||
@@ -87,7 +88,8 @@ impl WebServer {
|
||||
for (id, plugin_router) in plugin_routers {
|
||||
router = router.nest(&format!("/api/plugin/{id}"), plugin_router);
|
||||
}
|
||||
// Serve the data/ directory under /data/ (accessible via URL).
|
||||
// Serve the data/ directory under /data/ (accessible via URL), behind the
|
||||
// same session-cookie gate as /api — uploads are private user content.
|
||||
let data_dir = Path::new(static_dir).parent().unwrap_or(Path::new(".")).join("data");
|
||||
// Static responses (SPA assets + /data) get `Cache-Control: no-cache`:
|
||||
// the browser may store them but MUST revalidate before use, so after a
|
||||
@@ -98,7 +100,17 @@ impl WebServer {
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
));
|
||||
router = router.nest_service("/data", static_assets().service(ServeDir::new(&data_dir)));
|
||||
let data_service = ServiceBuilder::new()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
skald_for_data,
|
||||
api::guard::require_auth,
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.service(ServeDir::new(&data_dir));
|
||||
router = router.nest_service("/data", data_service);
|
||||
router = router.fallback_service(static_assets().service(ServeDir::new(static_dir)));
|
||||
// Negotiated gzip/brotli compression (Accept-Encoding). Matters most for
|
||||
// the mobile WebView, whose HTTP traffic is reverse-proxied byte-for-byte
|
||||
|
||||
Reference in New Issue
Block a user