Add backups with S3, daily cost push, automation templates and a browser tool

- Daily backups (database + config) into <data_dir>/backups, keep N,
  optional upload to any S3-compatible bucket (SigV4, bucket pruning);
  settings panel with run-now, list and download
- Daily cost summary push at 20:00 (opt-in per user, preview button)
- Automations from templates (dependency check, security review, test
  health, daily summary, TODO report, docs freshness) via POST /jobs/create
- browser tool: headless Chromium over CDP – click, type, keys, waits,
  JavaScript, navigation, screenshots, console errors; requests to private
  network addresses are blocked (sub-resources and redirects included)
- Secrets masked in the config view: S3 secret key, metrics token
- Replace the remaining text arrows/refresh glyphs with SVG icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 06:07:03 +01:00
1 parent 873447ed17
commit 01c1484033
25 files changed
+1720 -47

No files matched your search

+91 -20
View File
@@ -117,6 +117,8 @@ pub fn router(app: AppState) -> Router {
.route("/budget", get(budget))
.route("/audit", get(audit_log))
.route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
@@ -138,6 +140,7 @@ pub fn router(app: AppState) -> Router {
.route("/account/tokens", get(auth::list_api_tokens).post(auth::create_api_token))
.route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/create", post(create_job))
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
.route("/jobs/{id}/run", post(run_job))
.route("/jobs/{id}/webhook", post(job_webhook_settings))
@@ -147,6 +150,7 @@ pub fn router(app: AppState) -> Router {
.route("/push/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test))
.route("/push/digest", get(get_digest).put(put_digest).post(send_digest))
.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.
@@ -1069,26 +1073,7 @@ async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>
/// 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??;
let zip_bytes = crate::backup::create_zip(&app).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((
@@ -1101,6 +1086,46 @@ async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -
.into_response())
}
/// Automatic backups: stored files, last run, settings summary.
async fn list_backups(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let cfg = app.config().backup.clone();
Ok(Json(json!({
"files": crate::backup::list(&app),
"dir": crate::backup::backup_dir(&app).display().to_string(),
"last": *app.backup.last.lock().unwrap(),
"enabled": cfg.enabled,
"hour": cfg.hour,
"keep": cfg.keep,
"s3": !cfg.s3.endpoint.trim().is_empty(),
})))
}
async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let res = crate::backup::run(&app).await?;
app.audit(Some(&user), "backup.run", &res.file, "");
*app.backup.last.lock().unwrap() = Some(res.clone());
Ok(Json(json!(res)))
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
app.audit(Some(&user), "backup.download", &name, "");
Ok((
[
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
],
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": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
@@ -1278,6 +1303,30 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
Ok(Json(crate::jobs::describe(&job)))
}
#[derive(Deserialize)]
struct CreateJob {
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
cwd: String,
#[serde(flatten)]
job: crate::jobs::NewJob,
}
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
}
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
// Inside the checked folder: the file name is a slug of the name.
let path = crate::jobs::new_job_path(&dir, &b.job.name);
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let job = app.store.job_register(&user.id, &path.display().to_string())?;
app.audit(Some(&user), "job.create", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
#[derive(Deserialize)]
struct UpdateJob {
enabled: Option<bool>,
@@ -1445,6 +1494,28 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!({"ok": true})))
}
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
Ok(Json(json!({"enabled": on})))
}
#[derive(Deserialize)]
struct DigestBody {
enabled: bool,
}
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult {
app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?;
Ok(Json(json!({"enabled": b.enabled})))
}
/// Send today's summary right away (preview).
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"}));
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
}
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let (sent, errors) = 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, "errors": errors})))