Add incoming webhooks, fix stale service worker and push re-registration

- Webhooks for automations: per-job secret, Gitea/GitHub HMAC signatures,
  payload appended to the instructions; settings on the automations page
- Service worker gets a new file name per build (proxies that cache *.js
  served a stale worker, which broke updates and Android installation);
  /sw.js is a kill switch that removes old workers
- Push: devices re-register their subscription with the server on start and
  before a test; the test reports why a push service rejected a message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:38:04 +01:00
1 parent 6dffcb501a
commit 10d4b64289
16 files changed
+267 -18

No files matched your search

+57 -2
View File
@@ -137,6 +137,7 @@ pub fn router(app: AppState) -> Router {
.route("/jobs", get(list_jobs).post(register_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))
.route("/terminals", get(list_terminals).post(open_terminal))
.route("/terminals/{id}", delete(close_terminal))
.route("/terminals/{id}/ws", get(terminal_ws))
@@ -160,6 +161,7 @@ pub fn router(app: AppState) -> Router {
Router::new()
.nest("/api", api)
.route("/hooks/{id}", post(incoming_webhook))
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
@@ -1254,6 +1256,59 @@ async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct WebhookSettings {
enable: bool,
}
/// Enable (new secret, shown once) or disable the webhook of an automation.
async fn job_webhook_settings(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<WebhookSettings>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
if !b.enable {
app.store.job_set_webhook(&job.id, None)?;
app.audit(Some(&user), "job.webhook.off", &job.path, "");
return Ok(Json(json!({"ok": true})));
}
let secret = crate::auth::random_token(24);
app.store.job_set_webhook(&job.id, Some(&secret))?;
app.audit(Some(&user), "job.webhook.on", &job.path, "");
let base = public_base(&app, &headers);
Ok(Json(json!({"url": format!("{base}/hooks/{}", job.id), "secret": secret})))
}
#[derive(Deserialize)]
struct HookQuery {
secret: Option<String>,
}
/// Public webhook endpoint: starts the automation with the request body as input.
async fn incoming_webhook(State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<HookQuery>, headers: axum::http::HeaderMap, body: bytes::Bytes) -> Response {
let ip = crate::auth::client_ip(&headers);
let Ok(Some(job)) = app.store.job(&id) else { return (StatusCode::NOT_FOUND, "unknown webhook").into_response() };
let Ok(Some(secret)) = app.store.job_webhook_secret(&id) else { return (StatusCode::NOT_FOUND, "unknown webhook").into_response() };
if !crate::jobs::verify_webhook(&secret, &headers, q.secret.as_deref(), &body) {
app.store.audit(None, "", "webhook.denied", &id, &ip);
return (StatusCode::UNAUTHORIZED, "invalid secret or signature").into_response();
}
if !job.enabled {
return (StatusCode::CONFLICT, "the automation is disabled").into_response();
}
if job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
return (StatusCode::CONFLICT, "the automation is still running").into_response();
}
if job.last_run.is_some_and(|t| now_ms() - t < 10_000) {
return (StatusCode::TOO_MANY_REQUESTS, "too many calls – wait a few seconds").into_response();
}
let payload = crate::jobs::payload_text(&headers, &body);
match crate::jobs::run_with(&app, &job, "webhook", Some(payload)).await {
Ok(sid) => {
app.store.audit(None, "", "webhook.run", &id, &ip);
(StatusCode::ACCEPTED, Json(json!({"ok": true, "session_id": sid}))).into_response()
}
Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
}
}
async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
let sid = crate::jobs::run(&app, &job, "manual").await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
@@ -1348,8 +1403,8 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
}
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})))
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})))
}
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {