Files
codecraft/server/src/files.rs
T
Richard ZinkandClaude Opus 5.5 b99f71526a Generated images survive moves and renames
Agents tidy up folders after generating images (sort into subfolders, rename
revisions); the image records kept the old path, so /api/images/<id>/file
answered 404 and only the requesting device still showed them from its cache.

- Generated images get a copy in the image store (images.blob, kept by the
  blob GC); existing ones are copied once on start-up.
- Records follow: after bash commands with mv/rename (missing images of the
  owner from the last two weeks, found by name or by content hash), when moved
  with the file manager (file or folder), and on access (served from the copy,
  relocated in the background; without a copy found by file name).
- Chat gallery falls back to the preview the model saw when the file is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:37:13 +01:00

455 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File manager API: browse, edit, upload, move and delete inside the user's folders.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::store::User;
use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
type AppState = Arc<App>;
const MAX_TEXT: u64 = 2 * 1024 * 1024;
fn bad(msg: impl Into<String>) -> ApiError {
ApiError(StatusCode::BAD_REQUEST, msg.into())
}
/// Resolve a path and make sure it (or, for new files, its parent) lies inside
/// one of the user's folders. Symlinks are resolved before the check.
pub fn checked_path(app: &App, user: &User, raw: &str, must_exist: bool) -> Result<PathBuf, ApiError> {
let roots = app.user_roots(Some(&user.id));
let p = crate::tools::normalize(Path::new(raw.trim()));
if !p.is_absolute() {
return Err(bad("path must be absolute"));
}
let real = if p.exists() {
std::fs::canonicalize(&p).map_err(|e| bad(e.to_string()))?
} else {
if must_exist {
return Err(ApiError(StatusCode::NOT_FOUND, format!("{} does not exist", p.display())));
}
let parent = p.parent().ok_or_else(|| bad("invalid path"))?;
let parent = std::fs::canonicalize(parent).map_err(|_| bad("parent directory does not exist"))?;
parent.join(p.file_name().ok_or_else(|| bad("invalid path"))?)
};
if !roots.iter().any(|r| real.starts_with(r)) {
return Err(ApiError(StatusCode::FORBIDDEN, "path is outside your folders".into()));
}
Ok(real)
}
fn is_root(app: &App, user: &User, p: &Path) -> bool {
app.user_roots(Some(&user.id)).iter().any(|r| r == p)
}
#[derive(Deserialize)]
pub struct PathQuery {
path: String,
#[serde(default)]
hidden: bool,
}
pub async fn list(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
let dir = checked_path(&app, &user, &q.path, true)?;
if !dir.is_dir() {
return Err(bad("not a directory"));
}
let mut entries = Vec::new();
for e in std::fs::read_dir(&dir)?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !q.hidden && name.starts_with('.') {
continue;
}
let meta = e.metadata().ok();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
entries.push(json!({
"name": name,
"path": e.path(),
"is_dir": is_dir,
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
"mtime": meta.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
}));
}
entries.sort_by(|a, b| {
b["is_dir"].as_bool().cmp(&a["is_dir"].as_bool()).then_with(|| a["name"].as_str().unwrap_or("").to_lowercase().cmp(&b["name"].as_str().unwrap_or("").to_lowercase()))
});
let parent = if is_root(&app, &user, &dir) { Value::Null } else { json!(dir.parent()) };
let repo = crate::git::repo_root(&dir);
Ok(Json(json!({"path": dir, "parent": parent, "entries": entries, "repo": repo})))
}
pub async fn read(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let meta = std::fs::metadata(&p)?;
if meta.is_dir() {
return Err(bad("is a directory"));
}
let mtime = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
if meta.len() > MAX_TEXT {
return Ok(Json(json!({"path": p, "binary": true, "too_large": true, "size": meta.len(), "mtime": mtime})));
}
let bytes = tokio::fs::read(&p).await?;
let binary = bytes.iter().take(8000).any(|b| *b == 0) || std::str::from_utf8(&bytes).is_err();
Ok(Json(json!({
"path": p,
"binary": binary,
"size": meta.len(),
"mtime": mtime,
"content": if binary { Value::Null } else { json!(String::from_utf8_lossy(&bytes)) },
})))
}
fn mime_for(p: &Path) -> &'static str {
match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("svg") => "image/svg+xml",
Some("pdf") => "application/pdf",
Some("wav") => "audio/wav",
Some("mp3") => "audio/mpeg",
Some("ogg") | Some("oga") => "audio/ogg",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("glb") => "model/gltf-binary",
Some("gltf") => "model/gltf+json",
Some("stl") => "model/stl",
Some("obj") => "model/obj",
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn mtime_ms(p: &Path) -> u64 {
std::fs::metadata(p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// Cache file for a derived preview (keyed by path, mtime and variant).
fn cache_path(p: &Path, variant: &str, ext: &str) -> PathBuf {
let key = crate::auth::sha256_hex(&format!("{}|{}|{variant}", p.display(), mtime_ms(p)));
let dir = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| std::env::temp_dir()).join(".cache/codecraft/previews");
let _ = std::fs::create_dir_all(&dir);
dir.join(format!("{}.{ext}", &key[..32]))
}
async fn run_tool(cmd: &str, args: &[&str]) -> Result<(), ApiError> {
let out = tokio::time::timeout(std::time::Duration::from_secs(120), tokio::process::Command::new(cmd).args(args).kill_on_drop(true).output())
.await
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, format!("{cmd} timed out")))?
.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, format!("'{cmd}' is not installed")))?;
if !out.status.success() {
return Err(bad(format!("{cmd}: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(())
}
#[derive(Deserialize)]
pub struct PreviewQuery {
path: String,
page: Option<u32>,
}
/// Page count of a PDF.
pub async fn pdf_info(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let out = tokio::process::Command::new("pdfinfo").arg(&p).output().await.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, "pdfinfo is not installed".into()))?;
let text = String::from_utf8_lossy(&out.stdout);
let pages: u32 = text.lines().find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1))).unwrap_or(1);
Ok(Json(json!({"pages": pages})))
}
/// One PDF page rendered as PNG (cached).
pub async fn pdf_page(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let page = q.page.unwrap_or(1).max(1);
let out = cache_path(&p, &format!("page{page}"), "png");
if !out.exists() {
let base = out.with_extension("");
let n = page.to_string();
run_tool("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", &p.to_string_lossy(), &base.to_string_lossy()]).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
/// Office documents as text (docx, pptx) or tables (xlsx, xls, ods).
pub async fn doc_preview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if matches!(ext.as_str(), "xlsx" | "xlsm" | "xls" | "ods") {
let path = p.clone();
let sheets = tokio::task::spawn_blocking(move || -> anyhow::Result<Value> {
use calamine::Reader;
let mut wb = calamine::open_workbook_auto(&path)?;
let mut out = Vec::new();
for name in wb.sheet_names() {
let range = wb.worksheet_range(&name)?;
let rows: Vec<Vec<String>> = range.rows().take(500).map(|r| r.iter().take(50).map(|c| c.to_string()).collect()).collect();
out.push(json!({"name": name, "rows": rows, "total_rows": range.get_size().0}));
}
Ok(json!(out))
})
.await??;
return Ok(Json(json!({"kind": "table", "sheets": sheets})));
}
let (text, _) = crate::docs::extract(&p, None, None).await?;
Ok(Json(json!({"kind": "text", "text": text})))
}
/// Tracker modules (xm, mod, s3m, it) rendered to WAV with openmpt123 (cached).
pub async fn module_audio(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let out = cache_path(&p, "audio", "wav");
if !out.exists() {
// openmpt123 writes <input>.wav next to the input; render into the cache via a copy.
let tmp_in = out.with_extension(p.extension().and_then(|e| e.to_str()).unwrap_or("xm"));
tokio::fs::copy(&p, &tmp_in).await?;
let res = run_tool("openmpt123", &["--quiet", "--batch", "--force", "--render", &tmp_in.to_string_lossy()]).await;
let rendered = PathBuf::from(format!("{}.wav", tmp_in.display()));
let _ = tokio::fs::remove_file(&tmp_in).await;
res?;
tokio::fs::rename(&rendered, &out).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "audio/wav"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
#[derive(Deserialize)]
pub struct RawQuery {
path: String,
#[serde(default)]
download: bool,
}
/// File bytes for previews and downloads. SVGs are served as attachments only,
/// so scripts inside them never run in the app's origin.
pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<RawQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
if p.is_dir() {
return Err(bad("is a directory"));
}
let bytes = tokio::fs::read(&p).await?;
let mime = mime_for(&p);
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("file").replace('"', "");
let disposition = if q.download || mime == "image/svg+xml" || mime == "application/octet-stream" {
format!("attachment; filename=\"{name}\"")
} else {
format!("inline; filename=\"{name}\"")
};
// Raw files never run scripts in the app's origin; PDFs keep the browser viewer.
let csp = if mime == "application/pdf" { "sandbox" } else { "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'" };
Ok((
[
(header::CONTENT_TYPE, mime.to_string()),
(header::CONTENT_DISPOSITION, disposition),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
(header::CONTENT_SECURITY_POLICY, csp.to_string()),
],
bytes,
)
.into_response())
}
#[derive(Deserialize)]
pub struct StatQuery {
path: String,
}
/// Size, type and modification time of a file (captions in the chat).
pub async fn stat(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let m = tokio::fs::metadata(&p).await?;
let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Ok(Json(json!({"size": m.len(), "dir": m.is_dir(), "mime": mime_for(&p), "mtime": mtime})))
}
#[derive(Deserialize)]
pub struct WriteBody {
path: String,
content: String,
/// mtime the client loaded; a newer file on disk is a conflict.
expected_mtime: Option<u64>,
#[serde(default)]
force: bool,
}
pub async fn write(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<WriteBody>) -> ApiResult {
let p = checked_path(&app, &user, &b.path, false)?;
if p.is_dir() {
return Err(bad("is a directory"));
}
if let (Some(expected), false) = (b.expected_mtime, b.force) {
let current = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
if current.is_some_and(|c| c > expected) {
return Err(ApiError(StatusCode::CONFLICT, "the file was changed on disk since you opened it".into()));
}
}
tokio::fs::write(&p, b.content.as_bytes()).await?;
let mtime = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Ok(Json(json!({"ok": true, "mtime": mtime})))
}
#[derive(Deserialize)]
pub struct CreateBody {
path: String,
#[serde(default)]
dir: bool,
}
pub async fn create(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateBody>) -> ApiResult {
let p = checked_path(&app, &user, &b.path, false)?;
if p.exists() {
return Err(ApiError(StatusCode::CONFLICT, "already exists".into()));
}
if b.dir {
tokio::fs::create_dir(&p).await?;
} else {
tokio::fs::write(&p, b"").await?;
}
Ok(Json(json!({"ok": true, "path": p})))
}
#[derive(Deserialize)]
pub struct MoveBody {
from: String,
to: String,
#[serde(default)]
copy: bool,
}
fn copy_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
let e = e?;
copy_recursive(&e.path(), &to.join(e.file_name()))?;
}
Ok(())
} else {
std::fs::copy(from, to).map(|_| ())
}
}
pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MoveBody>) -> ApiResult {
let from = checked_path(&app, &user, &b.from, true)?;
let to = checked_path(&app, &user, &b.to, false)?;
if is_root(&app, &user, &from) {
return Err(bad("a root folder cannot be moved"));
}
if to.exists() {
return Err(ApiError(StatusCode::CONFLICT, "target already exists".into()));
}
if to.starts_with(&from) {
return Err(bad("cannot move a folder into itself"));
}
let (f, t) = (from.clone(), to.clone());
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
if !b.copy {
// Generated images in the moved file or folder keep their entries.
app.store.move_image_paths(&from.display().to_string(), &to.display().to_string())?;
}
Ok(Json(json!({"ok": true, "path": to})))
}
pub async fn remove(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
if is_root(&app, &user, &p) {
return Err(bad("a root folder cannot be deleted"));
}
if p.is_dir() {
tokio::fs::remove_dir_all(&p).await?;
} else {
tokio::fs::remove_file(&p).await?;
}
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
pub struct UploadQuery {
/// Target file path (absolute).
path: String,
/// Chunked upload: byte offset of this chunk (0 = first chunk, creates the file).
offset: Option<u64>,
}
/// Upload a file as the raw request body – whole, or in chunks with `offset`
/// (large files: each chunk stays below the reverse proxy's body limit).
pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
let p = checked_path(&app, &user, &q.path, false)?;
if p.is_dir() {
return Err(bad("is a directory"));
}
let max = app.config().server.max_upload_mb.saturating_mul(1024 * 1024);
let offset = q.offset.unwrap_or(0);
if offset + body.len() as u64 > max {
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("file is larger than {} MB", max / 1024 / 1024)));
}
if offset == 0 {
tokio::fs::write(&p, &body).await?;
} else {
use tokio::io::AsyncWriteExt;
let current = tokio::fs::metadata(&p).await.map(|m| m.len()).unwrap_or(0);
if current != offset {
return Err(ApiError(StatusCode::CONFLICT, format!("chunk offset {offset} does not match the uploaded size {current}")));
}
let mut f = tokio::fs::OpenOptions::new().append(true).open(&p).await?;
f.write_all(&body).await?;
f.flush().await?;
}
Ok(Json(json!({"ok": true, "path": p, "size": offset + body.len() as u64})))
}
#[derive(Deserialize)]
pub struct SearchQuery {
path: String,
q: String,
}
/// File name search below a folder (respects .gitignore).
pub async fn search(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<SearchQuery>) -> ApiResult {
let root = checked_path(&app, &user, &q.path, true)?;
let needle = q.q.to_lowercase();
if needle.trim().is_empty() {
return Ok(Json(json!([])));
}
let hits = tokio::task::spawn_blocking(move || {
// Matches on the name or the relative path; name prefix matches first.
let mut found: Vec<(u8, usize, Value)> = Vec::new();
for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() {
if e.depth() == 0 {
continue;
}
let name = e.file_name().to_string_lossy().to_lowercase();
let rel = e.path().strip_prefix(&root).unwrap_or(e.path()).to_string_lossy().to_string();
let rel_l = rel.to_lowercase();
let rank = if name.starts_with(&needle) {
0
} else if name.contains(&needle) {
1
} else if rel_l.contains(&needle) {
2
} else {
continue;
};
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
found.push((rank, rel.len(), json!({"path": e.path(), "rel": rel, "is_dir": is_dir})));
if found.len() >= 2000 {
break;
}
}
found.sort_by_key(|(r, l, _)| (*r, *l));
found.into_iter().take(200).map(|(_, _, v)| v).collect::<Vec<_>>()
})
.await?;
Ok(Json(json!(hits)))
}