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:
1 parent
873447ed17
commit
01c1484033
25 files changed
+1720
-47
No files matched your search
+91
-20
@@ -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})))
|
||||
|
||||
Reference in new issue
Block a user