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

+405 -122
View File
@@ -1,17 +1,20 @@
//! HTTP + WebSocket API.
//! HTTP + WebSocket API. Every route except the public auth endpoints requires a
//! signed-in user; admin-only routes check the role.
use crate::app::{App, PermissionReply};
use crate::app::{Access, App, PermissionReply};
use crate::auth::{self, require_admin};
use crate::config::{self, ConfigPatch};
use crate::prompts;
use crate::runner;
use crate::store::User;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use axum::routing::{delete, get, post, put};
use axum::{Extension, Json, Router};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
@@ -19,7 +22,7 @@ use std::sync::Arc;
type AppState = Arc<App>;
pub struct ApiError(StatusCode, String);
pub struct ApiError(pub StatusCode, pub String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
@@ -33,15 +36,33 @@ impl<E: Into<anyhow::Error>> From<E> for ApiError {
}
}
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
pub type ApiResult<T = Json<Value>> = Result<T, ApiError>;
fn not_found(what: &str) -> ApiError {
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
}
fn forbidden() -> ApiError {
ApiError(StatusCode::FORBIDDEN, "no access to this session".into())
}
pub fn router(app: AppState) -> Router {
let api = Router::new()
.route("/info", get(info))
// public
.route("/info", get(auth::info))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/setup", post(auth::setup))
.route("/auth/oidc/start", get(auth::oidc_start))
.route("/auth/oidc/callback", get(auth::oidc_callback))
// signed in
.route("/auth/password", post(auth::change_password))
.route("/users", get(auth::list_users).post(auth::create_user))
.route("/users/directory", get(auth::directory))
.route("/users/{id}", axum::routing::patch(auth::update_user).delete(auth::delete_user))
.route("/folders", get(list_folders).post(create_folder))
.route("/folders/{id}", delete(delete_folder))
.route("/folders/{id}/access", put(set_folder_access))
.route("/config", get(get_config).patch(patch_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw))
.route("/providers/reload", post(reload_providers))
@@ -55,15 +76,38 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/stats", get(session_stats))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue))
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
.route("/permissions/{id}", post(reply_permission))
.route("/questions/{id}", post(answer_question))
.route("/pending", get(pending))
.route("/mcp", get(mcp_status))
.route("/mcp/reload", post(mcp_reload))
.route("/fs/roots", get(list_roots))
.route("/files/list", get(crate::files::list))
.route("/files/read", get(crate::files::read))
.route("/files/raw", get(crate::files::raw))
.route("/files/write", put(crate::files::write))
.route("/files/create", post(crate::files::create))
.route("/files/move", post(crate::files::move_or_copy))
.route("/files/delete", delete(crate::files::remove))
.route("/files/upload", put(crate::files::upload))
.route("/files/search", get(crate::files::search))
.route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats))
.route("/budget", get(budget))
.route("/providers/{name}/prices", get(provider_prices))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/ws", get(ws))
.layer(middleware::from_fn_with_state(app.clone(), auth));
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
// Prompts may carry images (base64); the default limit is 2 MB.
.layer(axum::extract::DefaultBodyLimit::max(50 * 1024 * 1024));
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
["../web/dist", "./web/dist", "/app/web"]
@@ -73,64 +117,42 @@ pub fn router(app: AppState) -> Router {
.unwrap_or_else(|| PathBuf::from("./web/dist"))
});
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir)
.fallback(tower_http::services::ServeFile::new(index));
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
}
/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie.
async fn auth(State(app): State<AppState>, req: Request, next: Next) -> Response {
let expected = app.config().auth_token();
if expected.is_empty() || req.uri().path().ends_with("/info") {
return next.run(req).await;
/// Load a session the user may at least read.
fn readable(app: &App, user: &User, id: &str) -> Result<Session, ApiError> {
let s = app.store.get_session(id)?.ok_or_else(|| not_found("session"))?;
if app.access(user, id) == Access::None {
return Err(forbidden());
}
let header_token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(String::from);
let query_token = req.uri().query().and_then(|q| {
q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from)
});
let cookie_token = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from));
let ok = [header_token, query_token, cookie_token]
.into_iter()
.flatten()
.any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()));
if ok {
next.run(req).await
} else {
ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response()
Ok(s)
}
/// Load a session the user may control (owner or admin).
fn writable(app: &App, user: &User, id: &str) -> Result<Session, ApiError> {
let s = readable(app, user, id)?;
if app.access(user, id) != Access::Full {
return Err(ApiError(StatusCode::FORBIDDEN, "this session is shared with you read-only".into()));
}
Ok(s)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// --- config (admin) -----------------------------------------------------------
// --- info & config ----------------------------------------------------------
async fn info(State(app): State<AppState>) -> Json<Value> {
let cfg = app.config();
Json(json!({
"name": "CodeCraft",
"version": env!("CARGO_PKG_VERSION"),
"auth_required": !cfg.auth_token().is_empty(),
}))
}
async fn get_config(State(app): State<AppState>) -> ApiResult {
async fn get_config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
if !user.is_admin() {
// Users only need defaults, not the configuration.
return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency})));
}
Ok(Json(json!({
"config": config::redacted_json(&cfg),
"workspace": app.workspace(),
"default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) },
"default_model": default_model,
"currency": cfg.server.currency,
})))
}
@@ -140,14 +162,16 @@ struct PatchBody {
patches: Vec<ConfigPatch>,
}
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
async fn patch_config(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
require_admin(&user)?;
// Masked secrets coming back from the UI must not overwrite real values.
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
app.patch_config(&patches).await?;
Ok(Json(json!({"ok": true})))
}
async fn get_config_raw(State(app): State<AppState>) -> ApiResult {
async fn get_config_raw(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
Ok(Json(json!({"text": app.config_text()?})))
}
@@ -156,12 +180,14 @@ struct RawBody {
text: String,
}
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?;
app.save_config(&body.text).await?;
Ok(Json(json!({"ok": true})))
}
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
async fn reload_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
app.reload_providers().await;
Ok(Json(app.registry().models_json()))
}
@@ -175,33 +201,92 @@ struct CwdQuery {
cwd: Option<String>,
}
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
/// Root and cwd for listing agents/commands; falls back to the user's first root.
fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
let owner = Some(user.id.as_str());
let roots = app.user_roots(owner);
let cwd = cwd.map(PathBuf::from).filter(|c| roots.iter().any(|r| c.starts_with(r))).unwrap_or_else(|| roots[0].clone());
(app.root_for(owner, &cwd), cwd)
}
async fn agents(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
Json(json!(prompts::agents(&app, &cwd)))
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!(prompts::agents(&app, &root, &cwd)))
}
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
async fn commands(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!({
"commands": prompts::commands(&app, &cwd),
"skills": prompts::skills(&app, &cwd),
"commands": prompts::commands(&app, &root, &cwd),
"skills": prompts::skills(&app, &root, &cwd),
}))
}
// --- shared folders (admin) ---------------------------------------------------
async fn list_folders(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
Ok(Json(json!({"workspace": app.workspace(), "folders": app.store.folders()?})))
}
#[derive(Deserialize)]
struct FolderBody {
/// Path relative to the workspace (or absolute inside it). Created if missing.
path: String,
#[serde(default)]
users: Vec<String>,
}
async fn create_folder(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<FolderBody>) -> ApiResult {
require_admin(&user)?;
let ws = app.workspace();
let rel = body.path.trim().trim_start_matches(ws.to_str().unwrap_or("")).trim_matches('/');
if rel.is_empty() || rel.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with('.')) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid folder path".into()));
}
let path = ws.join(rel);
std::fs::create_dir_all(&path)?;
let path = std::fs::canonicalize(&path)?;
if !path.starts_with(&ws) || path == ws {
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
}
let id = app.store.add_folder(&path.display().to_string())?;
app.store.set_folder_access(&id, &body.users)?;
Ok(Json(json!({"id": id, "path": path})))
}
async fn delete_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
require_admin(&user)?;
// Only the share is removed; the directory and its files stay.
app.store.delete_folder(&id)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct AccessBody {
users: Vec<String>,
}
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
require_admin(&user)?;
app.store.set_folder_access(&id, &body.users)?;
Ok(Json(json!({"ok": true})))
}
// --- sessions ---------------------------------------------------------------
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
let sessions = app.store.list_sessions()?;
let list: Vec<Value> = sessions
async fn list_sessions(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let list: Vec<Value> = app
.store
.list_sessions(filter)?
.into_iter()
.map(|s| {
let busy = app.is_busy(&s.id);
let shared = s.owner_id.as_deref() != Some(user.id.as_str());
let mut v = json!(s);
v["busy"] = json!(busy);
v["shared"] = json!(shared);
v
})
.collect();
@@ -216,29 +301,34 @@ struct CreateSession {
permission_mode: Option<String>,
}
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
let ws = app.workspace();
/// Resolve a directory and check it lies inside one of the user's roots.
fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf, ApiError> {
let roots = app.user_roots(Some(&user.id));
let p = match cwd.filter(|c| !c.trim().is_empty()) {
Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c),
Some(c) => ws.join(c),
None => ws.clone(),
Some(c) => roots[0].join(c),
None => roots[0].clone(),
};
let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?;
if !p.starts_with(&ws) || !p.is_dir() {
return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into()));
if !p.is_dir() || !roots.iter().any(|r| p.starts_with(r)) {
return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into()));
}
Ok(p)
}
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, body.cwd)?;
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, &user, body.cwd)?;
let cfg = app.config();
let model = body
.model
.filter(|m| !m.is_empty())
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?;
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
let mode = body.permission_mode.unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
let now = now_ms();
let s = Session {
id: new_id(),
@@ -246,8 +336,9 @@ async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSess
cwd: cwd.display().to_string(),
model,
agent: body.agent.unwrap_or_else(|| "build".into()),
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
permission_mode: mode,
parent_id: None,
owner_id: Some(user.id.clone()),
created_at: now,
updated_at: now,
todos: Value::Null,
@@ -257,11 +348,13 @@ async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSess
Ok(Json(json!(s)))
}
async fn get_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
let busy = app.is_busy(&id);
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
let can_write = app.access(&user, &id) == Access::Full;
let queue = app.store.queue_list(&id)?;
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue})))
}
#[derive(Deserialize)]
@@ -272,8 +365,8 @@ struct UpdateSession {
permission_mode: Option<String>,
}
async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
if let Some(t) = body.title {
s.title = t;
}
@@ -285,7 +378,7 @@ async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Jso
s.agent = a;
}
if let Some(p) = body.permission_mode {
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
if !crate::permission::MODES.contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
s.permission_mode = p;
@@ -293,10 +386,12 @@ async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Jso
Ok(Json(json!(app.save_session(s)?)))
}
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
// Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id);
app.store.delete_session(&id)?;
app.emit(Event::SessionDeleted { session_id: id });
Ok(Json(json!({"ok": true})))
}
@@ -307,8 +402,8 @@ struct PromptBody {
images: Vec<ImageData>,
}
async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let text = body.text.trim().to_string();
// Built-in commands.
if text == "/compact" {
@@ -319,9 +414,10 @@ async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body):
return Ok(Json(json!({"ok": true, "command": "compact"})));
}
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone();
if text.starts_with('/') {
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) {
prompt_text = expanded;
if let Some(a) = agent {
let mut s = session.clone();
@@ -334,10 +430,32 @@ async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body):
for image in body.images {
parts.push(Part::Image { image });
}
runner::start_prompt(app.clone(), id, parts)?;
match runner::submit(app.clone(), id, parts, "user")? {
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
}
}
async fn get_queue(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
Ok(Json(json!(app.store.queue_list(&id)?)))
}
async fn delete_queue_item(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, item)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
if !app.store.queue_remove(&id, &item)? {
return Err(ApiError(StatusCode::CONFLICT, "already delivered to the agent".into()));
}
let items = app.store.queue_list(&id)?;
app.emit(Event::QueueUpdated { session_id: id, items: json!(items) });
Ok(Json(json!({"ok": true})))
}
async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
}
async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
@@ -349,12 +467,14 @@ async fn do_compact(app: AppState, id: String) {
app.emit(Event::SessionStatus { session_id: id, busy: false });
}
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
async fn abort(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
runner::abort(&app, &id);
Json(json!({"ok": true}))
Ok(Json(json!({"ok": true})))
}
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
async fn compact(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
@@ -368,33 +488,58 @@ struct RewindBody {
}
/// Remove a message and everything after it (edit & resend). Files are not restored.
async fn rewind(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
async fn rewind(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
app.store.truncate_from(&id, &body.message_id)?;
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let mut s = s;
s.last_usage = None;
app.save_session(s)?;
Ok(Json(json!({"ok": true})))
}
async fn session_stats(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let mut v = app.store.session_stats(&id)?;
v["currency"] = json!(app.config().server.currency);
Ok(Json(v))
}
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
Ok(Json(json!(app.store.shares(&id)?)))
}
async fn put_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
let s = writable(&app, &user, &id)?;
if s.parent_id.is_some() {
return Err(ApiError(StatusCode::BAD_REQUEST, "share the parent session instead".into()));
}
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
app.set_shares(&id, &users)?;
Ok(Json(json!({"ok": true})))
}
// --- interaction ------------------------------------------------------------
/// Only users who control the session may answer its permission requests and questions.
fn check_pending(app: &App, user: &User, id: &str) -> Result<(), ApiError> {
let sid = app.pending_session(id).ok_or_else(|| not_found("request"))?;
if app.access(user, &sid) != Access::Full {
return Err(forbidden());
}
Ok(())
}
#[derive(Deserialize)]
struct PermissionBody {
reply: PermissionReply,
message: Option<String>,
}
async fn reply_permission(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
check_pending(&app, &user, &id)?;
app.reply_permission(&id, body.reply, body.message)?;
Ok(Json(json!({"ok": true})))
}
@@ -405,37 +550,55 @@ struct QuestionBody {
answers: Option<Map<String, Value>>,
}
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
async fn answer_question(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
check_pending(&app, &user, &id)?;
app.answer_question(&id, body.answers)?;
Ok(Json(json!({"ok": true})))
}
async fn pending(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.pending_events()))
async fn pending(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let list: Vec<Event> = app.pending_events().into_iter().filter(|e| app.event_visible(&user, e)).collect();
Json(json!(list))
}
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
Json(app.mcp.status_json())
async fn mcp_status(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
Ok(Json(app.mcp.status_json()))
}
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
async fn mcp_reload(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let mcp = app.mcp.clone();
let servers = app.config().mcp.clone();
let weak = Arc::downgrade(&app);
mcp.start_all(weak, servers).await;
Json(app.mcp.status_json())
Ok(Json(app.mcp.status_json()))
}
// --- files ------------------------------------------------------------------
/// The directories the user may work in (own folder, shared folders).
async fn list_roots(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let ws = app.workspace();
let roots: Vec<Value> = app
.user_roots(Some(&user.id))
.into_iter()
.map(|r| {
let rel = r.strip_prefix(&ws).map(|p| p.display().to_string()).unwrap_or_default();
json!({"path": r, "name": if rel.is_empty() { "/".to_string() } else { rel }})
})
.collect();
Json(json!(roots))
}
#[derive(Deserialize)]
struct DirQuery {
path: Option<String>,
}
/// Subdirectories for the project picker (inside the workspace only).
async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
let dir = validate_cwd(&app, q.path)?;
/// Subdirectories for the project picker (inside the user's roots only).
async fn list_dirs(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
let dir = validate_cwd(&app, &user, q.path)?;
let mut dirs = Vec::new();
for e in std::fs::read_dir(&dir)?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
@@ -445,15 +608,125 @@ async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> Ap
dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()}));
}
dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let ws = app.workspace();
let root = app.root_for(Some(&user.id), &dir);
Ok(Json(json!({
"path": dir,
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
"workspace": ws,
"parent": if dir == root { Value::Null } else { json!(dir.parent()) },
"root": root,
"workspace": app.workspace(),
"dirs": dirs,
})))
}
// --- budget -------------------------------------------------------------------
/// Budgets concern the whole installation: admins only.
async fn budget(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
Ok(Json(crate::budget::overview(&app).await?))
}
async fn provider_prices(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
require_admin(&user)?;
Ok(Json(crate::budget::discover_prices(&app, &name).await?))
}
// --- images -------------------------------------------------------------------
async fn images_config(State(app): State<AppState>) -> Json<Value> {
let cfg = app.config();
let ic = &cfg.images;
Json(json!({
"enabled": ic.enabled && !crate::config::expand_env(&ic.api_key).is_empty(),
"default_model": ic.default_model,
"output_dir": ic.output_dir,
"sizes": crate::images::SIZES,
"models": ic.models.iter().map(|m| json!({"id": m.id, "name": m.name.clone().unwrap_or_else(|| m.id.clone()), "price": m.price})).collect::<Vec<_>>(),
"currency": cfg.server.currency,
}))
}
async fn list_images(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(app.store.images(Some(&user.id), 200)?)))
}
#[derive(Deserialize)]
struct ImageBody {
prompt: String,
model: Option<String>,
size: Option<String>,
#[serde(default = "one")]
count: u32,
/// Target directory (absolute inside the user's folders); default: first folder/output_dir.
directory: Option<String>,
}
fn one() -> u32 {
1
}
async fn create_images(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<ImageBody>) -> ApiResult {
if body.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt is empty".into()));
}
let roots = app.user_roots(Some(&user.id));
let dir = match body.directory.filter(|d| !d.trim().is_empty()) {
Some(d) => {
let p = crate::tools::normalize(&PathBuf::from(d));
if !roots.iter().any(|r| p.starts_with(r)) {
return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into()));
}
p
}
None => roots[0].join(&app.config().images.output_dir),
};
let records = crate::images::generate(
&app,
crate::images::GenerateRequest {
prompt: body.prompt.trim().to_string(),
model: body.model,
size: body.size,
count: body.count,
dir,
user_id: Some(user.id.clone()),
session_id: None,
},
)
.await?;
Ok(Json(json!(records)))
}
fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageRecord, ApiError> {
let img = app.store.image(id)?.ok_or_else(|| not_found("image"))?;
if !user.is_admin() && img.user_id.as_deref() != Some(user.id.as_str()) {
return Err(ApiError(StatusCode::FORBIDDEN, "not your image".into()));
}
Ok(img)
}
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
let img = own_image(&app, &user, &id)?;
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response())
}
async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let img = own_image(&app, &user, &id)?;
// Only delete files inside the owner's folders.
let roots = app.user_roots(img.user_id.as_deref());
let p = PathBuf::from(&img.path);
if roots.iter().any(|r| p.starts_with(r)) {
let _ = tokio::fs::remove_file(&p).await;
}
app.store.delete_image(&id)?;
Ok(Json(json!({"ok": true})))
}
// --- statistics -------------------------------------------------------------
#[derive(Deserialize)]
@@ -462,12 +735,15 @@ struct StatsQuery {
to: Option<i64>,
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
tz: Option<i64>,
/// Admins: user id to filter by (omitted = all users).
user: Option<String>,
}
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?;
let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) };
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?;
v["currency"] = json!(app.config().server.currency);
v["from"] = json!(from);
v["to"] = json!(to);
@@ -476,23 +752,25 @@ async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiR
// --- websocket --------------------------------------------------------------
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
}
async fn ws_loop(app: AppState, mut socket: WebSocket) {
async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
let mut rx = app.subscribe();
for ev in app.pending_events() {
if send_event(&mut socket, &ev).await.is_err() {
if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() {
return;
}
}
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
// Re-check the login regularly so disabled users lose their live connection.
let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tokio::select! {
ev = rx.recv() => match ev {
Ok(ev) => {
if send_event(&mut socket, &ev).await.is_err() {
if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() {
break;
}
}
@@ -514,6 +792,11 @@ async fn ws_loop(app: AppState, mut socket: WebSocket) {
break;
}
}
_ = recheck.tick() => {
if !matches!(app.store.user(&user.id), Ok(Some(u)) if !u.disabled) {
break;
}
}
}
}
}