//! HTTP + WebSocket API. Every route except the public auth endpoints requires a //! signed-in user; admin-only routes check the role. 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, State}; use axum::http::StatusCode; use axum::middleware; use axum::response::{IntoResponse, Response}; 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; use std::sync::Arc; type AppState = Arc; pub struct ApiError(pub StatusCode, pub String); impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.0, Json(json!({"error": self.1}))).into_response() } } impl> From for ApiError { fn from(e: E) -> Self { ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into())) } } pub type ApiResult> = Result; 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() // 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)) .route("/models", get(models)) .route("/agents", get(agents)) .route("/commands", get(commands)) .route("/sessions", get(list_sessions).post(create_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/abort", post(abort)) .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::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"] .iter() .map(PathBuf::from) .find(|p| p.join("index.html").exists()) .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)); Router::new().nest("/api", api).fallback_service(static_files).with_state(app) } /// Load a session the user may at least read. fn readable(app: &App, user: &User, id: &str) -> Result { let s = app.store.get_session(id)?.ok_or_else(|| not_found("session"))?; if app.access(user, id) == Access::None { return Err(forbidden()); } Ok(s) } /// Load a session the user may control (owner or admin). fn writable(app: &App, user: &User, id: &str) -> Result { 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) } // --- config (admin) ----------------------------------------------------------- async fn get_config(Extension(user): Extension, State(app): State) -> 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": default_model, "currency": cfg.server.currency, }))) } #[derive(Deserialize)] struct PatchBody { patches: Vec, } async fn patch_config(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { require_admin(&user)?; // Masked secrets coming back from the UI must not overwrite real values. let patches: Vec = 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(Extension(user): Extension, State(app): State) -> ApiResult { require_admin(&user)?; Ok(Json(json!({"text": app.config_text()?}))) } #[derive(Deserialize)] struct RawBody { text: String, } async fn put_config_raw(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { require_admin(&user)?; app.save_config(&body.text).await?; Ok(Json(json!({"ok": true}))) } async fn reload_providers(Extension(user): Extension, State(app): State) -> ApiResult { require_admin(&user)?; app.reload_providers().await; Ok(Json(app.registry().models_json())) } async fn models(State(app): State) -> Json { Json(app.registry().models_json()) } #[derive(Deserialize)] struct CwdQuery { cwd: Option, } /// Root and cwd for listing agents/commands; falls back to the user's first root. fn scope(app: &App, user: &User, cwd: Option) -> (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(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { let (root, cwd) = scope(&app, &user, q.cwd); Json(json!(prompts::agents(&app, &root, &cwd))) } async fn commands(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { let (root, cwd) = scope(&app, &user, q.cwd); Json(json!({ "commands": prompts::commands(&app, &root, &cwd), "skills": prompts::skills(&app, &root, &cwd), })) } // --- shared folders (admin) --------------------------------------------------- async fn list_folders(Extension(user): Extension, State(app): State) -> 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, } async fn create_folder(Extension(user): Extension, State(app): State, Json(body): Json) -> 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, State(app): State, Path(id): Path) -> 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, } async fn set_folder_access(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { require_admin(&user)?; app.store.set_folder_access(&id, &body.users)?; Ok(Json(json!({"ok": true}))) } // --- sessions --------------------------------------------------------------- async fn list_sessions(Extension(user): Extension, State(app): State) -> ApiResult { let filter = if user.is_admin() { None } else { Some(user.id.as_str()) }; let list: Vec = 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(); Ok(Json(json!(list))) } #[derive(Deserialize)] struct CreateSession { cwd: Option, model: Option, agent: Option, permission_mode: Option, } /// Resolve a directory and check it lies inside one of the user's roots. fn validate_cwd(app: &App, user: &User, cwd: Option) -> Result { 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) => 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.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(Extension(user): Extension, State(app): State, Json(body): Json) -> 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 – 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(), title: String::new(), cwd: cwd.display().to_string(), model, agent: body.agent.unwrap_or_else(|| "build".into()), permission_mode: mode, parent_id: None, owner_id: Some(user.id.clone()), created_at: now, updated_at: now, todos: Value::Null, last_usage: None, }; let s = app.save_session(s)?; Ok(Json(json!(s))) } async fn get_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { let s = readable(&app, &user, &id)?; let messages = app.store.messages(&id)?; let busy = app.is_busy(&id); 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)] struct UpdateSession { title: Option, model: Option, agent: Option, permission_mode: Option, } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { let mut s = writable(&app, &user, &id)?; if let Some(t) = body.title { s.title = t; } if let Some(m) = body.model { app.registry().resolve(&m)?; s.model = m; } if let Some(a) = body.agent { s.agent = a; } if let Some(p) = body.permission_mode { if !crate::permission::MODES.contains(&p.as_str()) { return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); } s.permission_mode = p; } Ok(Json(json!(app.save_session(s)?))) } async fn delete_session(Extension(user): Extension, State(app): State, Path(id): Path) -> 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)?; Ok(Json(json!({"ok": true}))) } #[derive(Deserialize)] struct PromptBody { text: String, #[serde(default)] images: Vec, } async fn prompt(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { let session = writable(&app, &user, &id)?; let text = body.text.trim().to_string(); // Built-in commands. if text == "/compact" { if app.is_busy(&id) { return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); } tokio::spawn(do_compact(app.clone(), id.clone())); 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, &root, &cwd, &text) { prompt_text = expanded; if let Some(a) = agent { let mut s = session.clone(); s.agent = a; app.save_session(s)?; } } } let mut parts = vec![Part::Text { text: prompt_text }]; for image in body.images { parts.push(Part::Image { image }); } 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, State(app): State, Path(id): Path) -> ApiResult { readable(&app, &user, &id)?; Ok(Json(json!(app.store.queue_list(&id)?))) } async fn delete_queue_item(Extension(user): Extension, State(app): State, 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, State(app): State, Path(id): Path) -> 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())); app.emit(Event::SessionStatus { session_id: id.clone(), busy: true }); if let Err(e) = runner::compact(app.clone(), &id, cancel).await { app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") }); } app.with_runtime(&id, |r| r.cancel = None); app.emit(Event::SessionStatus { session_id: id, busy: false }); } async fn abort(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { writable(&app, &user, &id)?; runner::abort(&app, &id); Ok(Json(json!({"ok": true}))) } async fn compact(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { writable(&app, &user, &id)?; if app.is_busy(&id) { return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); } tokio::spawn(do_compact(app.clone(), id)); Ok(Json(json!({"ok": true}))) } #[derive(Deserialize)] struct RewindBody { message_id: String, } /// Remove a message and everything after it (edit & resend). Files are not restored. async fn rewind(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> 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)?; s.last_usage = None; app.save_session(s)?; Ok(Json(json!({"ok": true}))) } async fn session_stats(Extension(user): Extension, State(app): State, Path(id): Path) -> 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, State(app): State, Path(id): Path) -> ApiResult { writable(&app, &user, &id)?; Ok(Json(json!(app.store.shares(&id)?))) } async fn put_shares(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> 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 = 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, } async fn reply_permission(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { check_pending(&app, &user, &id)?; app.reply_permission(&id, body.reply, body.message)?; Ok(Json(json!({"ok": true}))) } #[derive(Deserialize)] struct QuestionBody { /// question text → answer (string or array of strings); null = dismissed answers: Option>, } async fn answer_question(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { check_pending(&app, &user, &id)?; app.answer_question(&id, body.answers)?; Ok(Json(json!({"ok": true}))) } async fn pending(Extension(user): Extension, State(app): State) -> Json { let list: Vec = app.pending_events().into_iter().filter(|e| app.event_visible(&user, e)).collect(); Json(json!(list)) } async fn mcp_status(Extension(user): Extension, State(app): State) -> ApiResult { require_admin(&user)?; Ok(Json(app.mcp.status_json())) } async fn mcp_reload(Extension(user): Extension, State(app): State) -> 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; 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, State(app): State) -> Json { let ws = app.workspace(); let roots: Vec = 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, } /// Subdirectories for the project picker (inside the user's roots only). async fn list_dirs(Extension(user): Extension, State(app): State, Query(q): Query) -> 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(); if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } 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 root = app.root_for(Some(&user.id), &dir); Ok(Json(json!({ "path": dir, "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, State(app): State) -> ApiResult { require_admin(&user)?; Ok(Json(crate::budget::overview(&app).await?)) } async fn provider_prices(Extension(user): Extension, State(app): State, Path(name): Path) -> ApiResult { require_admin(&user)?; Ok(Json(crate::budget::discover_prices(&app, &name).await?)) } // --- images ------------------------------------------------------------------- async fn images_config(State(app): State) -> Json { 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::>(), "currency": cfg.server.currency, })) } async fn list_images(Extension(user): Extension, State(app): State) -> ApiResult { Ok(Json(json!(app.store.images(Some(&user.id), 200)?))) } #[derive(Deserialize)] struct ImageBody { prompt: String, model: Option, size: Option, #[serde(default = "one")] count: u32, /// Target directory (absolute inside the user's folders); default: first folder/output_dir. directory: Option, } fn one() -> u32 { 1 } async fn create_images(Extension(user): Extension, State(app): State, Json(body): Json) -> 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 { 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, State(app): State, Path(id): Path) -> Result { 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, State(app): State, Path(id): Path) -> 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)] struct StatsQuery { from: Option, to: Option, /// Minutes east of UTC (JS: -new Date().getTimezoneOffset()). tz: Option, /// Admins: user id to filter by (omitted = all users). user: Option, } async fn stats(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { let to = q.to.unwrap_or_else(|| now_ms() + 1); let from = q.from.unwrap_or(to - 30 * 86_400_000); 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); Ok(Json(v)) } // --- websocket -------------------------------------------------------------- async fn ws(Extension(user): Extension, State(app): State, upgrade: WebSocketUpgrade) -> Response { upgrade.on_upgrade(move |socket| ws_loop(app, user, socket)) } async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) { let mut rx = app.subscribe(); for ev in app.pending_events() { 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 app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { // Client fell behind: tell it to resync. let msg = json!({"type": "resync", "skipped": n}).to_string(); if socket.send(WsMessage::Text(msg.into())).await.is_err() { break; } } Err(_) => break, }, msg = socket.recv() => match msg { Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break, _ => {} }, _ = ping.tick() => { if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() { break; } } _ = recheck.tick() => { if !matches!(app.store.user(&user.id), Ok(Some(u)) if !u.disabled) { break; } } } } } async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> { let text = serde_json::to_string(ev).unwrap_or_default(); socket.send(WsMessage::Text(text.into())).await }