Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
//! 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("svg") => "image/svg+xml",
Some("pdf") => "application/pdf",
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
#[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}\"")
};
Ok((
[
(header::CONTENT_TYPE, mime.to_string()),
(header::CONTENT_DISPOSITION, disposition),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()),
],
bytes,
)
.into_response())
}
#[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,
}
/// Upload one file as the raw request body.
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"));
}
tokio::fs::write(&p, &body).await?;
Ok(Json(json!({"ok": true, "path": p, "size": body.len()})))
}
#[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 || {
let mut out = 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();
if name.contains(&needle) {
out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())}));
if out.len() >= 200 {
break;
}
}
}
out
})
.await?;
Ok(Json(json!(hits)))
}