Files
codecraft/server/src/files.rs
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

451 lines
18 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??;
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)))
}