uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s
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:
@@ -4,6 +4,7 @@ use std::sync::{Arc, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use core_api::message_meta::Attachment;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -234,6 +235,32 @@ impl ChatHub {
|
||||
self.session_mgr.get_or_create_handler(session_id).await
|
||||
}
|
||||
|
||||
/// Persist an uploaded file for `source_id` into the owner's home
|
||||
/// (`~/uploads/{session}/`) and return its [`Attachment`]. The single entry
|
||||
/// point every surface (web handler, channel plugins) routes through, so
|
||||
/// uploads can't drift on placement or on the recorded agent path — see
|
||||
/// [`crate::uploads::save_to_home`]. Resolves the source's active session (so
|
||||
/// the upload shares the directory the following message references).
|
||||
pub async fn save_upload(
|
||||
&self,
|
||||
source_id: &str,
|
||||
file_name: &str,
|
||||
client_mime: Option<String>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<Attachment> {
|
||||
let handler = self.session_handler(source_id).await?;
|
||||
let fs = handler.user_fs();
|
||||
let att = crate::uploads::save_to_home(
|
||||
&fs,
|
||||
handler.session_id,
|
||||
file_name,
|
||||
client_mime,
|
||||
bytes,
|
||||
)
|
||||
.await?;
|
||||
Ok(att)
|
||||
}
|
||||
|
||||
/// Returns the handler for a specific `session_id`, creating one lazily if needed.
|
||||
/// Used to resolve a pending tool against the session that actually owns it,
|
||||
/// independent of any source's "active" session.
|
||||
@@ -781,6 +808,16 @@ impl ChatHubApi for ChatHub {
|
||||
self.send_message(source_id, prompt, opts).await
|
||||
}
|
||||
|
||||
async fn save_upload(
|
||||
&self,
|
||||
source_id: &str,
|
||||
file_name: &str,
|
||||
client_mime: Option<String>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<Attachment> {
|
||||
self.save_upload(source_id, file_name, client_mime, bytes).await
|
||||
}
|
||||
|
||||
async fn clear(&self, source_id: &str) -> anyhow::Result<i64> {
|
||||
self.clear(source_id).await
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ pub mod tool_discovery;
|
||||
pub mod tools;
|
||||
pub mod transcribe;
|
||||
pub mod tts;
|
||||
pub mod uploads;
|
||||
pub mod users;
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
//! Promotion is deliberately strict: an attachment is inlined only when ALL of
|
||||
//! these hold —
|
||||
//! - the model has the modality's capability;
|
||||
//! - the file lives under `data/uploads/`, canonicalized (attachments saved
|
||||
//! anywhere else, e.g. by the Telegram plugin, stay textual);
|
||||
//! - the file lives under the caller's `~/uploads/` (where the upload handler
|
||||
//! saves it), resolved through their per-user filesystem — attachments stored
|
||||
//! anywhere else stay textual;
|
||||
//! - the sniffed magic bytes match an allowed MIME — the client-supplied
|
||||
//! `mimetype` is never trusted;
|
||||
//! - the per-file and per-turn byte/count budgets are not exhausted.
|
||||
@@ -26,7 +27,7 @@ use tracing::debug;
|
||||
|
||||
use core_api::message_meta::Attachment;
|
||||
use core_api::tool::MediaRef;
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::{UserFs, UPLOADS_SUBDIR};
|
||||
|
||||
/// Max media parts inlined per turn.
|
||||
const MAX_MEDIA_PER_TURN: usize = 4;
|
||||
@@ -108,22 +109,21 @@ pub struct MediaPartition {
|
||||
}
|
||||
|
||||
/// Splits a message's attachments into inline media parts and leftovers.
|
||||
/// Files are resolved against the process working directory.
|
||||
pub async fn partition(attachments: &[Attachment], capabilities: &[String]) -> MediaPartition {
|
||||
let base = std::env::current_dir().unwrap_or_default();
|
||||
partition_under(attachments, capabilities, &base).await
|
||||
}
|
||||
|
||||
/// [`partition`] with an explicit base directory (tests).
|
||||
pub async fn partition_under(
|
||||
///
|
||||
/// Each attachment path is resolved through the caller's per-user [`UserFs`] —
|
||||
/// the same resolver the fs-tools use, fail-closed on traversal / workspace
|
||||
/// escape — and inlined only when it lands under their `~/uploads/` directory,
|
||||
/// where the upload handler saves them. Attachments stored anywhere else (a
|
||||
/// path outside the home, or another surface's directory) stay textual.
|
||||
pub async fn partition(
|
||||
attachments: &[Attachment],
|
||||
capabilities: &[String],
|
||||
base: &Path,
|
||||
fs: &UserFs,
|
||||
) -> MediaPartition {
|
||||
let capable = MODALITIES
|
||||
.iter()
|
||||
.any(|m| capabilities.iter().any(|c| c == m.capability));
|
||||
let root = std::fs::canonicalize(base.join("data").join("uploads")).ok();
|
||||
let root = std::fs::canonicalize(fs.home_host.join(UPLOADS_SUBDIR)).ok();
|
||||
if !capable || root.is_none() {
|
||||
return MediaPartition { parts: Vec::new(), rest: attachments.to_vec() };
|
||||
}
|
||||
@@ -138,7 +138,7 @@ pub async fn partition_under(
|
||||
rest.push(a.clone());
|
||||
continue;
|
||||
}
|
||||
match try_inline(a, capabilities, base, &root, total).await {
|
||||
match try_inline(a, capabilities, fs, &root, total).await {
|
||||
Some((part, bytes)) => {
|
||||
total += bytes;
|
||||
parts.push(part);
|
||||
@@ -150,16 +150,17 @@ pub async fn partition_under(
|
||||
}
|
||||
|
||||
/// Promotes one uploaded attachment to a content part, or `None` when any check
|
||||
/// fails (logged at debug level; the caller keeps it on the textual path).
|
||||
/// Containment is against the uploads `root`; the rest is [`promote`].
|
||||
/// fails (logged at debug level; the caller keeps it on the textual path). The
|
||||
/// agent path is resolved through the per-user filesystem (fail-closed) and then
|
||||
/// re-checked to land under the uploads `root`; the rest is [`promote`].
|
||||
async fn try_inline(
|
||||
a: &Attachment,
|
||||
capabilities: &[String],
|
||||
base: &Path,
|
||||
fs: &UserFs,
|
||||
root: &Path,
|
||||
used_total: u64,
|
||||
) -> Option<(Value, u64)> {
|
||||
let abs = tokio::fs::canonicalize(base.join(&a.path)).await.ok()?;
|
||||
let abs = crate::tools::fs::resolve_host_path(fs, &a.path).ok()?;
|
||||
if !abs.starts_with(root) {
|
||||
debug!(path = %a.path, "media not inlined: outside the uploads root");
|
||||
return None;
|
||||
@@ -205,7 +206,7 @@ async fn promote(
|
||||
}
|
||||
|
||||
/// Inline media a tool produced (e.g. `read_file` on an image) as content parts,
|
||||
/// for the current turn only. Mirrors [`partition_under`] but contains against the
|
||||
/// for the current turn only. Mirrors [`partition`] but contains against the
|
||||
/// caller's **workspace roots** (home + shared + projects + docs) rather than the
|
||||
/// uploads dir — the tool already resolved + contained the path, so this is a
|
||||
/// fail-closed re-check against a symlink swap since the read (§6). Same per-file,
|
||||
@@ -395,11 +396,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn partition_inlines_png_for_vision_model() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let dir = tmp.join("data/uploads/u/1");
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
let p = partition_under(&[att("data/uploads/u/1/a.png")], &caps(&["vision"]), &tmp).await;
|
||||
let p = partition(&[att("uploads/1/a.png")], &caps(&["vision"]), &fs).await;
|
||||
assert!(p.rest.is_empty());
|
||||
assert_eq!(p.parts.len(), 1);
|
||||
let url = p.parts[0]["image_url"]["url"].as_str().unwrap();
|
||||
@@ -411,27 +414,30 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn partition_gates_on_capability_and_containment() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let dir = tmp.join("data/uploads/u/1");
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
|
||||
tokio::fs::write(tmp.join("secret.png"), png_bytes()).await.unwrap();
|
||||
// A real image inside the home but OUTSIDE the uploads dir.
|
||||
tokio::fs::write(home.join("secret.png"), png_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
// No capability → everything stays textual.
|
||||
let p = partition_under(&[att("data/uploads/u/1/a.png")], &caps(&[]), &tmp).await;
|
||||
let p = partition(&[att("uploads/1/a.png")], &caps(&[]), &fs).await;
|
||||
assert_eq!(p.rest.len(), 1);
|
||||
assert!(p.parts.is_empty());
|
||||
|
||||
// vision capability does not unlock video parts.
|
||||
let p = partition_under(&[att("data/uploads/u/1/a.png")], &caps(&["video"]), &tmp).await;
|
||||
let p = partition(&[att("uploads/1/a.png")], &caps(&["video"]), &fs).await;
|
||||
assert_eq!(p.rest.len(), 1);
|
||||
|
||||
// A real image outside the uploads root is never read inline.
|
||||
let p = partition_under(&[att("secret.png")], &caps(&["vision"]), &tmp).await;
|
||||
// A real image in the home but outside the uploads dir is never inlined.
|
||||
let p = partition(&[att("secret.png")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(p.rest.len(), 1);
|
||||
assert!(p.parts.is_empty());
|
||||
|
||||
// Traversal out of the root is rejected.
|
||||
let p = partition_under(&[att("data/uploads/../../secret.png")], &caps(&["vision"]), &tmp).await;
|
||||
// Traversal out of the workspace is rejected fail-closed.
|
||||
let p = partition(&[att("uploads/../../secret.png")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(p.rest.len(), 1);
|
||||
assert!(p.parts.is_empty());
|
||||
|
||||
@@ -441,15 +447,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn partition_enforces_count_budget() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let dir = tmp.join("data/uploads/u/1");
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
let mut atts = Vec::new();
|
||||
for i in 0..(MAX_MEDIA_PER_TURN + 2) {
|
||||
let rel = format!("data/uploads/u/1/{i}.png");
|
||||
tokio::fs::write(dir.join(format!("{i}.png")), png_bytes()).await.unwrap();
|
||||
atts.push(att(&rel));
|
||||
atts.push(att(&format!("uploads/1/{i}.png")));
|
||||
}
|
||||
let p = partition_under(&atts, &caps(&["vision"]), &tmp).await;
|
||||
let fs = fs_home(&home);
|
||||
let p = partition(&atts, &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(p.parts.len(), MAX_MEDIA_PER_TURN);
|
||||
assert_eq!(p.rest.len(), 2);
|
||||
|
||||
@@ -465,12 +472,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn partition_inlines_pdf_as_file_part_for_document_model() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let dir = tmp.join("data/uploads/u/1");
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.pdf"), pdf_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
// A document-capable model inlines the PDF as the OpenAI `file` part shape.
|
||||
let p = partition_under(&[att("data/uploads/u/1/a.pdf")], &caps(&["document"]), &tmp).await;
|
||||
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["document"]), &fs).await;
|
||||
assert!(p.rest.is_empty());
|
||||
assert_eq!(p.parts.len(), 1);
|
||||
assert_eq!(p.parts[0]["type"], "file");
|
||||
@@ -479,7 +488,7 @@ mod tests {
|
||||
assert!(fd.starts_with("data:application/pdf;base64,"), "{fd}");
|
||||
|
||||
// vision alone does not unlock PDFs.
|
||||
let p = partition_under(&[att("data/uploads/u/1/a.pdf")], &caps(&["vision"]), &tmp).await;
|
||||
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(p.rest.len(), 1);
|
||||
assert!(p.parts.is_empty());
|
||||
|
||||
|
||||
@@ -258,8 +258,14 @@ impl MessageBuilder {
|
||||
// textual path block, generated on the fly and never
|
||||
// persisted as content.
|
||||
let (text, media) = match &entry.metadata {
|
||||
Some(meta) if !meta.attachments.is_empty() && idx >= media_turn_start => {
|
||||
let partition = super::media::partition(&meta.attachments, capabilities).await;
|
||||
Some(meta)
|
||||
if !meta.attachments.is_empty()
|
||||
&& idx >= media_turn_start
|
||||
&& self.fs.is_some() =>
|
||||
{
|
||||
let fs = self.fs.as_deref().expect("guarded by is_some()");
|
||||
let partition =
|
||||
super::media::partition(&meta.attachments, capabilities, fs).await;
|
||||
(
|
||||
format!(
|
||||
"{}{}",
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::config::DatetimeConfig;
|
||||
use crate::db::{chat_history, chat_sessions_stack};
|
||||
use crate::events::ServerEvent;
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use core_api::user_fs::SharedFs;
|
||||
use core_api::user_fs::{SharedFs, UserFs};
|
||||
use crate::llm::LlmManager;
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
@@ -414,6 +414,13 @@ impl ChatSessionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller's current filesystem snapshot (home + shared folders + projects
|
||||
/// + docs). Cheap — clones an `Arc`. Used by upload persistence to place files
|
||||
/// in the owner's home.
|
||||
pub fn user_fs(&self) -> Arc<UserFs> {
|
||||
self.fs.load()
|
||||
}
|
||||
|
||||
/// Override the session used for scratchpad reads/writes.
|
||||
/// Called by the cron runner for async tasks so they share the parent's scratchpad.
|
||||
pub fn set_scratchpad_session_id(&self, id: i64) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Centralized upload persistence.
|
||||
//!
|
||||
//! The single place any surface — the web `POST /uploads` handler, a channel
|
||||
//! plugin like Telegram, or a future one — turns received file bytes into a
|
||||
//! saved [`Attachment`]. Keeping placement + naming + metadata here means no two
|
||||
//! callers can drift on *where* an upload lands or *what* path is recorded (the
|
||||
//! bug this fixes: uploads that the agent then couldn't reach).
|
||||
//!
|
||||
//! Files are written into the user's private container home under
|
||||
//! `uploads/{session_id}/…`. That single agent path is resolved identically by
|
||||
//! every consumer — the fs-tools, `execute_cmd` (the home is bind-mounted at
|
||||
//! `/root`), the file viewer (`/api/file`) and the media inliner — through the
|
||||
//! same per-user [`UserFs`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use core_api::message_meta::Attachment;
|
||||
use core_api::user_fs::{UserFs, UPLOADS_SUBDIR};
|
||||
|
||||
use crate::session::handler::media::sniff_mime;
|
||||
|
||||
/// Persist `bytes` as a file named `file_name` into the user's
|
||||
/// `~/uploads/{session_id}/`, returning the resulting [`Attachment`] whose `path`
|
||||
/// is the home-relative agent path. The recognized magic-byte MIME wins over the
|
||||
/// caller-claimed `client_mime`.
|
||||
///
|
||||
/// The caller owns byte transport (streaming a multipart body, downloading from
|
||||
/// an API) and any size cap; this owns placement, collision-safe naming, MIME
|
||||
/// sniffing and the metadata shape.
|
||||
pub async fn save_to_home(
|
||||
fs: &UserFs,
|
||||
session_id: i64,
|
||||
file_name: &str,
|
||||
client_mime: Option<String>,
|
||||
bytes: &[u8],
|
||||
) -> std::io::Result<Attachment> {
|
||||
let dir_host = fs
|
||||
.home_host
|
||||
.join(UPLOADS_SUBDIR)
|
||||
.join(session_id.to_string());
|
||||
tokio::fs::create_dir_all(&dir_host).await?;
|
||||
|
||||
let (abs_path, final_name) = unique_target(&dir_host, &sanitize_filename(file_name));
|
||||
tokio::fs::write(&abs_path, bytes).await?;
|
||||
|
||||
// The sniffed type wins over the client claim when we recognize the bytes.
|
||||
let mimetype = sniff_mime(&bytes[..bytes.len().min(16)])
|
||||
.map(str::to_string)
|
||||
.or(client_mime);
|
||||
|
||||
Ok(Attachment {
|
||||
path: format!("{UPLOADS_SUBDIR}/{session_id}/{final_name}"),
|
||||
name: final_name,
|
||||
mimetype,
|
||||
filesize: Some(bytes.len() as u64),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 = Path::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: &Path, name: &str) -> (PathBuf, String) {
|
||||
let candidate = dir.join(name);
|
||||
if !candidate.exists() {
|
||||
return (candidate, name.to_string());
|
||||
}
|
||||
let path = Path::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")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throwaway [`UserFs`] whose private home is `home`.
|
||||
fn fs_home(home: &Path) -> UserFs {
|
||||
UserFs::new(
|
||||
"u1",
|
||||
home.to_path_buf(),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn pdf_bytes() -> Vec<u8> {
|
||||
let mut v = b"%PDF-1.7\n".to_vec();
|
||||
v.extend_from_slice(&[0x00; 32]);
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_into_home_uploads_with_agent_path_and_sniffs_mime() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-upload-{}", uuid::Uuid::new_v4()));
|
||||
let home = tmp.join("homes/u1");
|
||||
tokio::fs::create_dir_all(&home).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
// A wrong client MIME is overridden by the sniffed PDF signature.
|
||||
let att = save_to_home(&fs, 7, "cv.pdf", Some("application/octet-stream".into()), &pdf_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(att.path, "uploads/7/cv.pdf");
|
||||
assert_eq!(att.name, "cv.pdf");
|
||||
assert_eq!(att.mimetype.as_deref(), Some("application/pdf"));
|
||||
assert_eq!(att.filesize, Some(pdf_bytes().len() as u64));
|
||||
// Physically lands under the home's uploads dir (reachable by the agent).
|
||||
assert!(home.join("uploads/7/cv.pdf").exists());
|
||||
|
||||
// A second upload of the same name never overwrites — it is de-duped.
|
||||
let att2 = save_to_home(&fs, 7, "cv.pdf", None, &pdf_bytes()).await.unwrap();
|
||||
assert_eq!(att2.path, "uploads/7/cv_1.pdf");
|
||||
assert!(home.join("uploads/7/cv_1.pdf").exists());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_directory_components() {
|
||||
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
|
||||
assert_eq!(sanitize_filename("a/b/c.txt"), "c.txt");
|
||||
assert_eq!(sanitize_filename(".."), "file");
|
||||
assert_eq!(sanitize_filename(""), "file");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user