package api import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "log" "net/http" "time" "github.com/go-chi/chi/v5" ) // issueAckToken mints the secret behind one notification's Acknowledge button // and returns the raw value to embed in its URL. Only the hash is stored, the // same way api_keys works. // // A fresh token per delivery rather than one per incident: the raw value only // exists for as long as it takes to build the message, so there is nothing to // look up and reuse later, and a reminder that supersedes an earlier page // carries its own credential. func issueAckToken(ctx context.Context, q querier, incidentID, userID int64) (string, error) { raw, hash, err := randomToken() if err != nil { return "", err } now := time.Now() if _, err := q.ExecContext(ctx, ` INSERT INTO incident_ack_tokens (token_hash, incident_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, hash, incidentID, userID, now.Unix(), now.Add(ackTokenTTL).Unix()); err != nil { return "", err } return raw, nil } // handleNotifyAck acknowledges an incident from the Acknowledge button in a // push notification. // // It is deliberately outside AuthMiddleware: the caller is a phone acting on a // notification, not a client holding an API key. What stands in for the key is // the token in the path — 256 bits of entropy, valid for one incident, one // action, and one day. It must stay publicly reachable for the button to work // when the responder is off the cluster network. func handleNotifyAck(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h := sha256.Sum256([]byte(chi.URLParam(r, "token"))) hash := hex.EncodeToString(h[:]) var incidentID, userID int64 err := db.QueryRowContext(r.Context(), ` SELECT incident_id, user_id FROM incident_ack_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&incidentID, &userID) if err != nil { // Unknown and expired get the same answer, so the endpoint cannot be // used to probe which tokens once existed. respond(w, http.StatusNotFound, errResp("invalid or expired token")) return } acked, err := acknowledgeIncident(r.Context(), db, incidentID, userID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if !acked { // The incident closed between the page and the tap. Nothing to do, // and nothing the responder did wrong — report the state, not an error, // so ntfy shows a success toast rather than a failure. respond(w, http.StatusOK, map[string]any{ "incident_id": incidentID, "status": "resolved", }) return } respond(w, http.StatusOK, map[string]any{ "incident_id": incidentID, "status": "acknowledged", }) } } // purgeAckTokens drops tokens whose notifications are long past. Nothing else // deletes them: incidents are archived rather than removed, so the cascade never // fires in practice. func purgeAckTokens(ctx context.Context, db *sql.DB) { res, err := db.ExecContext(ctx, "DELETE FROM incident_ack_tokens WHERE expires_at < ?", time.Now().Unix()) if err != nil { log.Printf("sweeper: purge ack tokens: %v", err) return } if n, _ := res.RowsAffected(); n > 0 { log.Printf("sweeper: purged %d expired ack token(s)", n) } }