uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s

Extract shared upload seam in skald-core, move Telegram and web
handlers to use it. Simplify media attachment routing. Clean up
unused deps and dead code.
This commit is contained in:
2026-07-22 19:19:29 +01:00
parent e70c4a90f3
commit 6f0461f7f5
15 changed files with 350 additions and 214 deletions
+27 -101
View File
@@ -1,36 +1,36 @@
use std::path::{Path as StdPath, PathBuf};
use std::sync::Arc;
use axum::{
Json, Extension,
extract::{Multipart, Path, State},
};
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.
/// Max bytes accepted for a single uploaded file; anything larger is refused 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/{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`.
/// Accepts a `multipart/form-data` body with one or more file fields and persists
/// each through the shared upload seam ([`skald_core::chat_hub::ChatHub::save_upload`]),
/// which saves into the caller's container home under `uploads/{session_id}/` —
/// so a single agent path (`uploads/{session_id}/…`) is reachable by the fs-tools,
/// by `execute_cmd` (the home is bind-mounted at `/root`), and by the file viewer
/// alike. The web handler and every channel plugin go through that one seam, so no
/// two surfaces can drift on *where* an upload lands.
///
/// 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.
/// Each field is read with the [`MAX_UPLOAD_BYTES`] cap enforced during accumulation
/// (an over-cap field is refused before anything is written); the route disables the
/// default body-size limit (see router). The seam sniffs the magic bytes and prefers
/// them over the client-supplied `Content-Type`.
///
/// Returns the saved [`Attachment`]s (home-relative agent path, name, MIME, size) so
/// the client can show chips and echo them back when sending the message.
pub async fn upload(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
@@ -38,13 +38,6 @@ pub async fn upload(
mut multipart: Multipart,
) -> Result<Json<Vec<Attachment>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve (creating if needed) the source's session so uploads land in the
// 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}", auth.user_id);
let dir_abs = fs_tools::resolve(&dir_rel)?;
tokio::fs::create_dir_all(&dir_abs).await?;
let mut saved: Vec<Attachment> = Vec::new();
@@ -53,93 +46,26 @@ pub async fn upload(
{
// Only fields carrying a filename are file uploads; skip plain text fields.
let Some(orig_name) = field.file_name().map(str::to_string) else { continue };
let mimetype = field.content_type().map(str::to_string);
let client_mime = field.content_type().map(str::to_string);
let base_name = sanitize_filename(&orig_name);
let (abs_path, final_name) = unique_target(&dir_abs, &base_name);
let mut file = tokio::fs::File::create(&abs_path).await
.map_err(|e| ApiError::from(anyhow::anyhow!("cannot create {}: {e}", abs_path.display())))?;
let mut size: u64 = 0;
let mut too_large = false;
// Buffer the field, enforcing the size cap as we read so an over-limit
// upload is refused before any bytes are handed to the store.
let mut bytes: Vec<u8> = Vec::new();
while let Some(chunk) = field.chunk().await
.map_err(|e| ApiError::bad_request(format!("upload read error: {e}")))?
{
size += chunk.len() as u64;
if size > MAX_UPLOAD_BYTES {
too_large = true;
break;
if bytes.len() as u64 + chunk.len() as u64 > MAX_UPLOAD_BYTES {
return Err(ApiError::payload_too_large(format!(
"'{orig_name}' exceeds the {} MiB upload limit",
MAX_UPLOAD_BYTES / 1024 / 1024
)));
}
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
)));
bytes.extend_from_slice(&chunk);
}
// 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}"),
name: final_name,
mimetype,
filesize: Some(size),
});
let att = ctx.chat_hub.save_upload(&p.source, &orig_name, client_mime, &bytes).await?;
saved.push(att);
}
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 {
let base = StdPath::new(raw)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.trim();
if base.is_empty() || base == "." || base == ".." {
"file".to_string()
} else {
base.to_string()
}
}
/// Returns a non-colliding `(absolute_path, final_name)` inside `dir`. If `name`
/// already exists, inserts `_1`, `_2`, … before the extension.
fn unique_target(dir: &StdPath, name: &str) -> (PathBuf, String) {
let candidate = dir.join(name);
if !candidate.exists() {
return (candidate, name.to_string());
}
let path = StdPath::new(name);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let ext = path.extension().and_then(|s| s.to_str());
for n in 1.. {
let next = match ext {
Some(ext) => format!("{stem}_{n}.{ext}"),
None => format!("{stem}_{n}"),
};
let candidate = dir.join(&next);
if !candidate.exists() {
return (candidate, next);
}
}
unreachable!("unique_target loop always returns")
}
+9 -19
View File
@@ -1,4 +1,3 @@
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
@@ -78,7 +77,6 @@ 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`.
@@ -108,29 +106,21 @@ impl WebServer {
));
router = router.nest(&format!("/api/plugin/{id}"), gated);
}
// 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
// self-rewrite/restart the client never serves a stale asset (no heuristic
// User files are never served as static content: chat uploads live in the
// caller's container home and are fetched, per-user and access-checked,
// through `/api/file`. (The former `/data` static mount was removed — it
// was gated by `require_auth` only, not ownership, so it also exposed
// internal server state under `data/`.)
//
// Static responses (the SPA assets) get `Cache-Control: no-cache`: the
// browser may store them but MUST revalidate before use, so after a
// rebuild/restart the client never serves a stale asset (no heuristic
// caching). Revalidation yields cheap 304s (the body is already on disk).
// `/api` is deliberately left without this header (dynamic, not cached).
let static_assets = || ServiceBuilder::new().layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
));
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