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:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -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)))
|
||||
}
|
||||
Reference in new issue
Block a user