Files
codecraft/server/src/api.rs
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- 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>
2026-11-08 21:03:26 +01:00

1125 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<App>;
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<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into()))
}
}
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()
// 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/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session))
.route("/sessions/{id}/title", post(suggest_title))
.route("/sessions/{id}/export", get(export_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}/changes", get(changed_files))
.route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
.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/stat", get(crate::files::stat))
.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("/files/preview/pdf-info", get(crate::files::pdf_info))
.route("/files/preview/pdf-page", get(crate::files::pdf_page))
.route("/files/preview/doc", get(crate::files::doc_preview))
.route("/files/preview/module", get(crate::files::module_audio))
.route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats))
.route("/budget", get(budget))
.route("/audit", get(audit_log))
.route("/backup", get(backup))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.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("/push/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test))
.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<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());
}
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)
}
// --- config (admin) -----------------------------------------------------------
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, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
}
Ok(Json(json!({
"config": config::redacted_json(&cfg),
"workspace": app.workspace(),
"default_model": default_model,
"small_model": cfg.server.small_model,
"currency": cfg.server.currency,
})))
}
#[derive(Deserialize)]
struct PatchBody {
patches: Vec<ConfigPatch>,
}
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();
let paths: Vec<String> = patches.iter().map(|p| p.path.join(".")).collect();
app.patch_config(&patches).await?;
app.audit(Some(&user), "config.patch", paths.join(", "), "");
Ok(Json(json!({"ok": true})))
}
async fn get_config_raw(Extension(user): Extension<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?;
app.save_config(&body.text).await?;
app.audit(Some(&user), "config.raw", format!("{} bytes", body.text.len()), "");
Ok(Json(json!({"ok": true})))
}
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()))
}
async fn models(State(app): State<AppState>) -> Json<Value> {
Json(app.registry().models_json())
}
#[derive(Deserialize)]
struct CwdQuery {
cwd: Option<String>,
}
/// 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(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(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, &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.audit(Some(&user), "folder.create", 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)?;
app.audit(Some(&user), "folder.access", format!("{id}: {} user(s)", body.users.len()), "");
Ok(Json(json!({"ok": true})))
}
// --- 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();
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
struct CreateSession {
cwd: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
clean_room: Option<bool>,
}
/// 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) => 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<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 – 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,
clean_room: body.clean_room,
phase: None,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
}
#[derive(Deserialize)]
struct SearchQ {
q: String,
}
async fn search_sessions(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<SearchQ>) -> ApiResult {
if q.q.trim().len() < 2 {
return Ok(Json(json!([])));
}
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let hits: Vec<Value> = app
.store
.search_sessions(filter, q.q.trim())?
.into_iter()
.map(|(s, snippet)| json!({"id": s.id, "title": s.title, "cwd": s.cwd, "agent": s.agent, "updated_at": s.updated_at, "snippet": snippet}))
.collect();
Ok(Json(json!(hits)))
}
#[derive(Deserialize, Default)]
struct TitleBody {
model: Option<String>,
}
/// Generate a title suggestion with a chosen model (not saved).
async fn suggest_title(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<TitleBody>>) -> ApiResult {
writable(&app, &user, &id)?;
let model = body.and_then(|b| b.0.model);
let title = crate::runner::suggest_title(app.clone(), &id, model).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"title": title})))
}
#[derive(Deserialize, Default)]
struct ForkBody {
/// Last message to keep (inclusive); all messages when missing.
message_id: Option<String>,
}
/// Copy a session (up to a message) into a new one to try another path.
async fn fork_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ForkBody>>) -> ApiResult {
let src = writable(&app, &user, &id)?;
let body = body.map(|b| b.0).unwrap_or_default();
let messages = app.store.messages(&id)?;
let end = match &body.message_id {
Some(mid) => messages.iter().position(|m| &m.id == mid).ok_or_else(|| not_found("message"))? + 1,
None => messages.len(),
};
let now = now_ms();
let title = if src.title.is_empty() { "Fork".to_string() } else { format!("{} (fork)", src.title) };
let fork = Session {
id: new_id(),
title,
parent_id: None,
owner_id: Some(user.id.clone()),
created_at: now,
updated_at: now,
last_usage: None,
phase: None,
..src.clone()
};
let fork = app.save_session(fork)?;
for m in &messages[..end] {
let mut m = m.clone();
m.id = new_id();
app.store.add_message(&fork.id, &m)?;
}
app.audit(Some(&user), "session.fork", &format!("{id} -> {}", fork.id), "");
Ok(Json(json!(fork)))
}
#[derive(Deserialize)]
struct ExportQ {
#[serde(default)]
format: String,
}
/// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" {
(serde_json::to_string_pretty(&json!({"session": s, "messages": messages}))?, "application/json", "json")
} else {
(crate::export::markdown(&s, &messages), "text/markdown; charset=utf-8", "md")
};
Ok((
[
(axum::http::header::CONTENT_TYPE, mime.to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{base}.{ext}\"")),
],
body,
)
.into_response())
}
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);
let can_write = app.access(&user, &id) == Access::Full;
let queue = app.store.queue_list(&id)?;
let step = app.with_runtime(&id, |r| r.step.clone());
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
}
#[derive(Deserialize)]
struct UpdateSession {
title: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
clean_room: Option<bool>,
}
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;
}
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(c) = body.clean_room {
s.clean_room = Some(c);
}
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<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);
crate::shells::kill_session(&app, &id);
app.store.delete_session(&id)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct PromptBody {
text: String,
#[serde(default)]
images: Vec<ImageData>,
}
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" {
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, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
prompt_text = expanded;
if agent.is_some() || clean_room.is_some() {
let mut s = session.clone();
if let Some(a) = agent {
s.agent = a;
}
if clean_room.is_some() {
s.clean_room = clean_room;
}
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<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()));
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<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
runner::abort(&app, &id);
Ok(Json(json!({"ok": true})))
}
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()));
}
tokio::spawn(do_compact(app.clone(), id));
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct RewindBody {
message_id: String,
/// Also restore files the agent changed since that message.
#[serde(default)]
restore_files: bool,
}
/// Remove a message and everything after it (edit & resend). Files are not restored.
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()));
}
let since = app.store.message_created_at(&id, &body.message_id)?.ok_or_else(|| not_found("message"))?;
let mut restored = Vec::new();
if body.restore_files {
let roots = app.user_roots(s.owner_id.as_deref());
for (path, content) in app.store.checkpoints_since(&id, since)? {
let p = PathBuf::from(&path);
// Only inside the owner's folders (they may have changed since).
if !roots.iter().any(|r| p.starts_with(r)) {
continue;
}
match content {
Some(bytes) => {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(&p, bytes)?;
}
None => {
let _ = std::fs::remove_file(&p);
}
}
restored.push(path);
}
}
app.store.delete_checkpoints_since(&id, since)?;
app.store.truncate_from(&id, &body.message_id)?;
s.last_usage = None;
app.save_session(s)?;
Ok(Json(json!({"ok": true, "restored": restored})))
}
/// Background shells of a session (and its subagents).
async fn list_shells(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let root = app.root_session_id(&id);
let list: Vec<Value> = app
.shells
.lock()
.unwrap()
.values()
.filter(|s| app.root_session_id(&s.session_id) == root)
.map(|s| s.json(20_000))
.collect();
Ok(Json(json!(list)))
}
async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, shell)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
let root = app.root_session_id(&id);
let s = app.shells.lock().unwrap().get(&shell).cloned().filter(|s| app.root_session_id(&s.session_id) == root).ok_or_else(|| not_found("shell"))?;
s.kill();
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct ChangesQuery {
since_message: String,
}
/// Files the agent changed since a message (for the rewind dialog).
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
readable(&app, &user, &id)?;
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
Ok(Json(json!(files)))
}
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)?;
app.audit(Some(&user), "session.share", format!("{id}: {} user(s)", users.len()), "");
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(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
check_pending(&app, &user, &id)?;
let what = app.pending_title(&id).unwrap_or_default();
app.reply_permission(&id, body.reply, body.message)?;
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed
answers: Option<Map<String, Value>>,
}
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(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(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
Ok(Json(app.mcp.status_json()))
}
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;
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 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();
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,
})))
}
// --- audit & backup -------------------------------------------------------------
#[derive(Deserialize)]
struct AuditQuery {
limit: Option<i64>,
action: Option<String>,
}
async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<AuditQuery>) -> ApiResult {
require_admin(&user)?;
Ok(Json(json!(app.store.audit_list(q.limit.unwrap_or(300).min(5000), q.action.as_deref().filter(|a| !a.is_empty()))?)))
}
/// ZIP with config.toml and a consistent database snapshot.
async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> {
require_admin(&user)?;
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", new_id()));
app.store.snapshot(&tmp)?;
let config = app.config_text()?;
let db = tokio::fs::read(&tmp).await?;
let _ = tokio::fs::remove_file(&tmp).await;
let zip_bytes = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
use std::io::Write;
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("config.toml", opts)?;
z.write_all(config.as_bytes())?;
z.start_file("codecraft.db", opts)?;
z.write_all(&db)?;
z.finish()?;
}
Ok(buf.into_inner())
})
.await??;
app.audit(Some(&user), "backup.download", format!("{} bytes", zip_bytes.len()), "");
let name = format!("codecraft-backup-{}.zip", chrono::Local::now().format("%Y%m%d-%H%M"));
Ok((
[
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
],
zip_bytes,
)
.into_response())
}
/// Liveness for monitoring (no auth needed – see auth::is_public).
async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
// --- 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)]
struct StatsQuery {
from: Option<i64>,
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(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 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 --------------------------------------------------------------
// --- web push -------------------------------------------------------------------
async fn push_key(State(app): State<AppState>) -> ApiResult {
Ok(Json(json!({"key": app.push.public_key()})))
}
#[derive(Deserialize)]
struct PushKeys {
p256dh: String,
auth: String,
}
#[derive(Deserialize)]
struct PushSubscription {
endpoint: String,
keys: PushKeys,
}
async fn push_subscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(sub): Json<PushSubscription>) -> ApiResult {
if !sub.endpoint.starts_with("https://") {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid push endpoint".into()));
}
app.store.push_add(&user.id, &sub.endpoint, &sub.keys.p256dh, &sub.keys.auth)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct PushEndpoint {
endpoint: String,
}
async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<PushEndpoint>) -> ApiResult {
// Only the owner's own subscription.
if app.store.push_subscriptions(&user.id)?.iter().any(|s| s.endpoint == b.endpoint) {
app.store.push_remove(&b.endpoint)?;
}
Ok(Json(json!({"ok": true})))
}
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
Ok(Json(json!({"sent": sent})))
}
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, 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));
let mut visible = false;
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,
Some(Ok(WsMessage::Text(t))) => {
// The client reports whether its window is visible (push only when none is).
if let Ok(v) = serde_json::from_str::<Value>(&t) {
if v["type"] == "visibility" {
let now = v["visible"].as_bool().unwrap_or(false);
if now != visible {
app.push.set_visible(&user.id, if now { 1 } else { -1 });
visible = now;
}
}
}
}
_ => {}
},
_ = 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;
}
}
}
}
if visible {
app.push.set_visible(&user.id, -1);
}
}
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
}