- 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>
451 lines
18 KiB
Rust
451 lines
18 KiB
Rust
//! 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)))
|
||
}
|