//! 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; const MAX_TEXT: u64 = 2 * 1024 * 1024; fn bad(msg: impl Into) -> 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 { 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, State(app): State, Query(q): Query) -> 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, State(app): State, Query(q): Query) -> 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, } /// Page count of a PDF. pub async fn pdf_info(Extension(user): Extension, State(app): State, Query(q): Query) -> 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, State(app): State, Query(q): Query) -> Result { 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, State(app): State, Query(q): Query) -> 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 { 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> = 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, State(app): State, Query(q): Query) -> Result { let p = checked_path(&app, &user, &q.path, true)?; let out = cache_path(&p, "audio", "wav"); if !out.exists() { // openmpt123 writes .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, State(app): State, Query(q): Query) -> Result { 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, State(app): State, Query(q): Query) -> 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, #[serde(default)] force: bool, } pub async fn write(Extension(user): Extension, State(app): State, Json(b): Json) -> 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, State(app): State, Json(b): Json) -> 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, State(app): State, Json(b): Json) -> 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, State(app): State, Query(q): Query) -> 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, } /// 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, State(app): State, Query(q): Query, 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, State(app): State, Query(q): Query) -> 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::>() }) .await?; Ok(Json(json!(hits))) }