This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"encoding/json"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid"
)
// ---- gates ------------------------------------------------------------------
// requireOwner verifies the access token, resolves the alert and checks that
// the caller is its admin. It writes the refusal itself.
func requireOwner(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) {
p, ok := principal(w, r, action)
if !ok {
return nil, nil, false
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return nil, nil, false
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return nil, nil, false
}
if alert.AdminUserID != p.ID {
Warn(action, "auth", "user=%s is not the admin of alert=%s", p.ID, alert.ID)
writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin")
return nil, nil, false
}
return alert, p, true
}
// requireAdminToken is requireOwner plus verification of the alert's admin
// token (the second factor for live and destructive commands).
func requireAdminToken(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) {
alert, p, ok := requireOwner(w, r, action, payload)
if !ok {
return nil, nil, false
}
token := adminTokenFrom(r, payload)
if token == "" {
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "adminToken is required")
return nil, nil, false
}
if !alarmtoken.EqualHash(alert.AdminTokenHash, token) {
Warn(action, "auth", "admin token mismatch alert=%s", alert.ID)
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid admin token")
return nil, nil, false
}
return alert, p, true
}
// isAdminOrSubscriber is the read gate for getAlert/alarmStatus/qrImage.
func isAdminOrSubscriber(r *http.Request, alert *db.Alert, userID uuid.UUID) (bool, error) {
if alert.AdminUserID == userID {
return true, nil
}
return db.IsSubscriber(r.Context(), alert.ID, userID)
}
// ---- payload extraction -----------------------------------------------------
// lenientString pulls one string field out of an arbitrary payload object
// without rejecting the other fields. It is used for the credentials that ride
// the payload alongside the action's own arguments.
func lenientString(payload any, field string) string {
if payload == nil {
return ""
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
s, _ := m[field].(string)
return strings.TrimSpace(s)
}
func adminTokenFrom(r *http.Request, payload any) string {
if v := strings.TrimSpace(r.URL.Query().Get("adminToken")); v != "" {
return v
}
return lenientString(payload, "adminToken")
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// ---- views ------------------------------------------------------------------
type eventView struct {
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
}
type alertView struct {
AlertID string `json:"alertId"`
Name string `json:"name"`
Message string `json:"message"`
Status string `json:"status"`
Active bool `json:"active"`
AutoStopSeconds int `json:"autoStopSeconds"`
TokenVersion int `json:"tokenVersion"`
Created string `json:"created"`
Modified string `json:"modified"`
TriggeredAt *string `json:"triggeredAt,omitempty"`
DisarmedAt *string `json:"disarmedAt,omitempty"`
QRPayload string `json:"qrPayload"`
IsAdmin bool `json:"isAdmin"`
SubscriptionCount int `json:"subscriptionCount"`
LastEvent *eventView `json:"lastEvent,omitempty"`
}
func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.Time) alertView {
v := alertView{
AlertID: a.ID.String(),
Name: a.Name,
Message: deref(a.Message),
Status: a.Status,
Active: alertstate.IsActive(a.Status, a.TriggeredAt, a.AutoStopSeconds, now),
AutoStopSeconds: a.AutoStopSeconds,
TokenVersion: a.TokenVersion,
Created: rfc3339(a.Created),
Modified: rfc3339(a.Modified),
TriggeredAt: rfc3339Ptr(a.TriggeredAt),
DisarmedAt: rfc3339Ptr(a.DisarmedAt),
QRPayload: qrpayload.Build(a.ID, a.SubscribeToken),
IsAdmin: isAdmin,
SubscriptionCount: count,
}
if ev != nil {
v.LastEvent = &eventView{
EventID: ev.ID.String(),
Action: ev.Kind,
Message: deref(ev.Message),
ServerTime: rfc3339(ev.CreatedAt),
}
}
return v
}
// ---- createAlert ------------------------------------------------------------
type createAlertRequest struct {
Name string `json:"name"`
Message string `json:"message"`
AutoStopSeconds *int `json:"autoStopSeconds"`
}
type createAlertResponse struct {
AlertID string `json:"alertId"`
Name string `json:"name"`
Message string `json:"message"`
Status string `json:"status"`
Active bool `json:"active"`
AdminToken string `json:"adminToken"`
SubscribeToken string `json:"subscribeToken"`
QRPayload string `json:"qrPayload"`
AutoStopSeconds int `json:"autoStopSeconds"`
TokenVersion int `json:"tokenVersion"`
Created string `json:"created"`
}
// CreateAlert implements POST /action/createAlert.
//
// The admin token is generated here, returned in this response and never
// stored: only its SHA-256 digest reaches the database.
func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "createAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req createAlertRequest
if err := decodePayload(payload, &req); err != nil {
validationError(w, "invalid payload: %v", err)
return
}
name, err := validateName(req.Name)
if err != nil {
validationError(w, "%v", err)
return
}
message, err := validateMessage(req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
autoStop := 0
if req.AutoStopSeconds != nil {
autoStop = *req.AutoStopSeconds
}
if err := validateAutoStop(autoStop); err != nil {
validationError(w, "%v", err)
return
}
adminToken, err := alarmtoken.NewAdminToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials")
return
}
subscribeToken, err := alarmtoken.NewSubscribeToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials")
return
}
var msgPtr *string
if strings.TrimSpace(message) != "" {
msgPtr = &message
}
alert, err := db.CreateAlert(r.Context(), &db.Alert{
AdminUserID: p.ID,
Name: name,
Message: msgPtr,
AdminTokenHash: alarmtoken.Hash(adminToken),
SubscribeToken: subscribeToken,
AutoStopSeconds: autoStop,
})
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s admin=%s autoStop=%d", alert.ID, p.ID, autoStop)
writeJSON(w, http.StatusOK, createAlertResponse{
AlertID: alert.ID.String(),
Name: alert.Name,
Message: deref(alert.Message),
Status: alert.Status,
Active: false,
AdminToken: adminToken,
SubscribeToken: alert.SubscribeToken,
QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken),
AutoStopSeconds: alert.AutoStopSeconds,
TokenVersion: alert.TokenVersion,
Created: rfc3339(alert.Created),
})
}
// ---- getAlert ---------------------------------------------------------------
// GetAlert implements GET/POST /action/getAlert. It is readable by the alert's
// admin and by any subscribed user.
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "getAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
isAdmin := res.Alert.AdminUserID == p.ID
if !isAdmin {
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
count, err := db.CountSubscriptions(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event, time.Now().UTC()))
}
// ---- deleteAlert ------------------------------------------------------------
// DeleteAlert implements POST/DELETE /action/deleteAlert. Subscriptions and
// events cascade. A live alarm must be disarmed first: deleting it would leave
// every ringing client with no way to learn the alarm is over.
func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "deleteAlert"
alert, _, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
now := time.Now().UTC()
if !alertstate.CanDelete(alertstate.IsActive(alert.Status, alert.TriggeredAt, alert.AutoStopSeconds, now)) {
writeError(w, http.StatusConflict, CodeAlertState, "disarm the alert before deleting it")
return
}
if err := db.DeleteAlert(r.Context(), alert.ID); err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s deleted", alert.ID)
writeJSON(w, http.StatusOK, map[string]any{
"alertId": alert.ID.String(),
"deleted": true,
})
}
// ---- rotateSubscribeToken ---------------------------------------------------
type rotateResponse struct {
AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"`
QRPayload string `json:"qrPayload"`
TokenVersion int `json:"tokenVersion"`
}
// RotateSubscribeToken implements POST /action/rotateSubscribeToken. Existing
// subscriptions survive: rotation invalidates the QR/join path, not the people
// who already joined.
func RotateSubscribeToken(w http.ResponseWriter, r *http.Request, payload any) {
const action = "rotateSubscribeToken"
alert, _, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
token, err := alarmtoken.NewSubscribeToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate a token")
return
}
version, err := db.RotateSubscribeToken(r.Context(), alert.ID, token)
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s token rotated to version %d", alert.ID, version)
writeJSON(w, http.StatusOK, rotateResponse{
AlertID: alert.ID.String(),
SubscribeToken: token,
QRPayload: qrpayload.Build(alert.ID, token),
TokenVersion: version,
})
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"net/http"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
)
// Login proxies the Maestro-mode action to /auth/login. The {payload} envelope
// was already unwrapped by the dispatcher; the inner body is forwarded raw and
// the auth response is relayed unwrapped.
func Login(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "login", payload)
}
// Register proxies the Maestro-mode action to /auth/register.
func Register(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "register", payload)
}
// Logout proxies the logout action: POST forwards {refreshToken} so the auth
// service revokes the session (M5 client logout); GET stays a bodyless no-op
// for the legacy transition.
func Logout(w http.ResponseWriter, r *http.Request, payload interface{}) {
if r.Method == http.MethodPost {
authproxy.ProxyPayload(w, r, "logout", payload)
return
}
authproxy.ProxyEmpty(w, r, "logout")
}
// Me proxies the GET action variant to /auth/me. The X-Access-Token header is
// forwarded unchanged so the auth service resolves the caller (boot
// revalidation, M5).
func Me(w http.ResponseWriter, r *http.Request) {
authproxy.Proxy(w, r, "me")
}
// Refresh proxies the Maestro-mode refresh action (M5 auto-refresh path).
func Refresh(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "refresh", payload)
}
// Delete proxies the Maestro-mode delete action to /auth/delete; the payload
// carries the password only and the X-Access-Token header is forwarded.
func Delete(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "delete", payload)
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package actions implements the Alarm service's /action/* surface.
//
// Every handler follows the same shape: verify the caller's X-Access-Token
// (authclient.Authorize), validate the {payload} envelope, do the work through
// the db package, and answer with a plain JSON object. Failures use the
// platform envelope {"error":{"code","message"}} with the frozen v1 codes.
package actions
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid"
)
// Frozen v1 error codes, aliased from internal/apierr so the WebSocket layer
// (which cannot import this package) uses the same strings.
const (
CodeAlertNotFound = apierr.AlertNotFound
CodeAlertTokenInvalid = apierr.AlertTokenInvalid
CodeAlertNotAdmin = apierr.AlertNotAdmin
CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined
CodeAlertNotSubscribed = apierr.AlertNotSubscribed
CodeAlertState = apierr.AlertState
CodeValidation = apierr.Validation
CodeUnauthorized = apierr.Unauthorized
CodeInternal = apierr.Internal
)
// maxMessageBytes / maxNameChars bound the free-text fields.
const (
maxNameChars = 255
maxMessageBytes = 2000
maxDeviceIDLen = 128
maxLabelLen = 128
maxPlatformLen = 32
)
// errorEnvelope is the platform error body.
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// writeError emits {"error":{"code","message"}}.
func writeError(w http.ResponseWriter, status int, code, message string) {
var body errorEnvelope
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// writeJSON emits a success body.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// validationError reports a client-side input problem.
func validationError(w http.ResponseWriter, format string, v ...any) {
writeError(w, http.StatusBadRequest, CodeValidation, fmt.Sprintf(format, v...))
}
// dbError maps the db package's sentinels onto wire codes. It returns true when
// it wrote a response.
func dbError(w http.ResponseWriter, action string, err error) bool {
switch {
case errors.Is(err, db.ErrNotFound):
writeError(w, http.StatusNotFound, CodeAlertNotFound, "alert not found")
case errors.Is(err, db.ErrNotAdmin):
writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin")
case errors.Is(err, db.ErrState):
writeError(w, http.StatusConflict, CodeAlertState, "operation not allowed in the current alert state")
default:
Error(action, "error", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "internal error")
}
return true
}
// principal verifies the X-Access-Token header. On failure it writes the 401
// envelope and returns false.
func principal(w http.ResponseWriter, r *http.Request, action string) (*authclient.Principal, bool) {
p, err := authclient.Authorize(r)
if err != nil {
Warn(action, "auth", "rejected: %v", err)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "missing or invalid access token")
return nil, false
}
return p, true
}
// decodePayload unmarshals the {payload} wrapper's inner value into dst.
//
// Unknown fields are deliberately ignored: the clients are shipped separately
// from the service, so a newer client sending an extra field must not be
// rejected with a 400 by an older server.
func decodePayload(payload any, dst any) error {
if payload == nil {
return errors.New("missing payload")
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
return json.NewDecoder(bytes.NewReader(raw)).Decode(dst)
}
// alertIDFrom extracts an alert id from the query string (GET form) or from the
// action payload (POST form). Accepts `alertId` and the shorthand `id`.
func alertIDFrom(r *http.Request, payload any) (uuid.UUID, error) {
raw := strings.TrimSpace(r.URL.Query().Get("alertId"))
if raw == "" {
raw = strings.TrimSpace(r.URL.Query().Get("id"))
}
if raw == "" && payload != nil {
// Lenient read: only these two keys matter, everything else in the
// payload is ignored rather than treated as a validation failure.
raw = strings.TrimSpace(lenientString(payload, "alertId"))
if raw == "" {
raw = strings.TrimSpace(lenientString(payload, "id"))
}
}
if raw == "" {
return uuid.Nil, errors.New("alertId is required")
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, errors.New("alertId is not a valid uuid")
}
return id, nil
}
// ---- input validation -------------------------------------------------------
func validateName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", errors.New("name is required")
}
if len([]rune(name)) > maxNameChars {
return "", fmt.Errorf("name must be at most %d characters", maxNameChars)
}
return name, nil
}
func validateMessage(raw string) (string, error) {
if len(raw) > maxMessageBytes {
return "", fmt.Errorf("message must be at most %d bytes", maxMessageBytes)
}
return raw, nil
}
func validateAutoStop(seconds int) error {
if seconds < 0 || seconds > alertstate.MaxAutoStopSeconds {
return fmt.Errorf("autoStopSeconds must be between 0 and %d", alertstate.MaxAutoStopSeconds)
}
return nil
}
func validateDeviceID(raw string) (string, error) {
id := strings.TrimSpace(raw)
if id == "" {
return "", errors.New("deviceId is required")
}
if len(id) > maxDeviceIDLen {
return "", fmt.Errorf("deviceId must be at most %d characters", maxDeviceIDLen)
}
for i := 0; i < len(id); i++ {
if id[i] < 0x20 || id[i] == 0x7f {
return "", errors.New("deviceId must not contain control characters")
}
}
return id, nil
}
func validateOptional(raw string, field string, max int) (*string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, nil
}
if len(s) > max {
return nil, fmt.Errorf("%s must be at most %d characters", field, max)
}
for i := 0; i < len(s); i++ {
if s[i] < 0x20 || s[i] == 0x7f {
return nil, fmt.Errorf("%s must not contain control characters", field)
}
}
return &s, nil
}
// ---- shared views -----------------------------------------------------------
// rfc3339 renders a timestamp for the wire: RFC3339 in UTC.
func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
// rfc3339Ptr renders an optional timestamp, omitting it when unset.
func rfc3339Ptr(t *time.Time) *string {
if t == nil {
return nil
}
s := rfc3339(*t)
return &s
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
)
// actionResponse is the trigger/disarm result.
type actionResponse struct {
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
Delivered int `json:"delivered"`
}
type triggerRequest struct {
AlertID string `json:"alertId"`
AdminToken string `json:"adminToken"`
Message *string `json:"message"`
}
// TriggerAlert implements POST /action/triggerAlert.
//
// The state transition and the event row are written in one transaction, and
// the schema's NOTIFY trigger publishes the event on commit, so every service
// instance fans it out. This instance additionally broadcasts in-process for
// the fast path; the hub de-duplicates the two by event id.
func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "triggerAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
var req triggerRequest
_ = decodePayload(payload, &req)
message := alert.Message
if req.Message != nil {
m, err := validateMessage(*req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
if strings.TrimSpace(m) != "" {
message = &m
}
}
updated, event, err := db.TriggerAlert(r.Context(), alert.ID, p.ID, message)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// DisarmAlert implements POST /action/disarmAlert.
func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "disarmAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
updated, event, err := db.DisarmAlert(r.Context(), alert.ID, p.ID, nil)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// statusView is the alarmStatus polling fallback payload (frozen field set).
type statusView struct {
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
}
// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed
// fallback a client polls when its socket is down or it was killed mid-alarm.
//
// `active` is derived (status + auto-stop window), never stored, so no
// background job is needed to expire an alarm.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
now := time.Now().UTC()
active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now)
if res.Event == nil || res.Event.Kind != alertstate.KindTrigger {
// A never-triggered alert, or one whose newest event is a disarm, is
// not live even if the stored status says "triggered".
active = false
}
view := statusView{
Active: active,
ServerTime: rfc3339(now),
AutoStopSeconds: res.Alert.AutoStopSeconds,
}
if res.Event != nil {
view.EventID = res.Event.ID.String()
view.Action = res.Event.Kind
view.Message = deref(res.Event.Message)
view.ServerTime = rfc3339(res.Event.CreatedAt)
}
writeJSON(w, http.StatusOK, view)
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"fmt"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
)
func init() {
for _, name := range []string{
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Warn",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Error",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.logAction",
} {
logger.RegisterLogHelper(name)
}
}
// Debug logs at debug level. Callers must never pass tokens or credentials.
func Debug(action, verb, format string, v ...any) {
logAction(logger.Debug, action, verb, format, v...)
}
// Warn logs at warn level. Callers must never pass tokens or credentials.
func Warn(action, verb, format string, v ...any) {
logAction(logger.Warn, action, verb, format, v...)
}
// Error logs at error level. Callers must never pass tokens or credentials.
func Error(action, verb, format string, v ...any) {
logAction(logger.Error, action, verb, format, v...)
}
func logAction(logf func(format string, v ...any), action, verb, format string, v ...any) {
logf("[action=%s verb=%s] %s", action, verb, fmt.Sprintf(format, v...))
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"errors"
"net/http"
"strconv"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qr"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
)
// QR rendering defaults: 8 px per module and the 4-module quiet zone the QR
// specification requires for reliable scanning.
const (
defaultQRScale = 8
defaultQRQuietZone = 4
)
// QRImage implements GET/POST /action/qrImage: the PNG of the alert's QR code.
//
// It is a convenience — the Android client renders the same payload locally —
// so it is deliberately simple: admin or subscriber, no caching, no ETag. The
// QR encodes the subscribe token, which both audiences already hold (the admin
// through createAlert/qrPayload, the subscriber through the token they scanned).
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
const action = "qrImage"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
if err != nil {
validationError(w, "%v", err)
return
}
// `margin` is the more common name for the quiet zone; accept either.
quiet := defaultQRQuietZone
quietParam := "quietZone"
if r.URL.Query().Get(quietParam) == "" {
quietParam = "margin"
}
if r.URL.Query().Get(quietParam) != "" {
// The lower bound is the ISO minimum, not 0: a symbol with a smaller
// quiet zone is not reliably scannable, so reject it as a validation
// error here instead of serving an image no scanner can read.
quiet, err = intQuery(r, quietParam, defaultQRQuietZone, qr.MinQuietZone, qr.MaxQuietZone)
if err != nil {
validationError(w, "%v", err)
return
}
}
matrix, err := qr.EncodeString(qrpayload.Build(alert.ID, alert.SubscribeToken))
if err != nil {
Error(action, "encode", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not encode the QR code")
return
}
pngBytes, err := qr.PNG(matrix, scale, quiet)
if err != nil {
if errors.Is(err, qr.ErrBadMatrix) {
Error(action, "render", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not render the QR code")
return
}
validationError(w, "%v", err)
return
}
Debug(action, "GET", "alert=%s modules=%d scale=%d quiet=%d", alert.ID, len(matrix), scale, quiet)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", strconv.Itoa(len(pngBytes)))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pngBytes)
}
// intQuery reads a bounded integer query parameter.
func intQuery(r *http.Request, name string, def, min, max int) (int, error) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, nil
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, errors.New(name + " must be an integer")
}
if v < min || v > max {
return 0, errors.New(name + " is out of range")
}
return v, nil
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"errors"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"github.com/google/uuid"
)
type subscriptionView struct {
ID string `json:"id"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
DeviceID string `json:"deviceId"`
DeviceLabel string `json:"deviceLabel,omitempty"`
Platform string `json:"platform,omitempty"`
Enabled bool `json:"enabled"`
LastSeenAt *string `json:"lastSeenAt,omitempty"`
Created string `json:"created"`
}
// newSubscriptionView maps a row to the wire view.
//
// `username` is deliberately never populated: it lives in the auth service's
// schema, and the Alarm service does not read another service's tables. The
// field is omitted rather than sent empty.
func newSubscriptionView(s *db.Subscription) subscriptionView {
return subscriptionView{
ID: s.ID.String(),
UserID: s.SubscriberUserID.String(),
DeviceID: s.DeviceID,
DeviceLabel: deref(s.DeviceLabel),
Platform: deref(s.Platform),
Enabled: s.Enabled,
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
Created: rfc3339(s.Created),
}
}
// ---- joinAlert --------------------------------------------------------------
type joinRequest struct {
AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"`
DeviceID string `json:"deviceId"`
DeviceLabel string `json:"deviceLabel"`
Platform string `json:"platform"`
}
// JoinAlert implements POST /action/joinAlert.
//
// Idempotency (frozen contract): a repeat for the same
// (alertId, user, deviceId) returns the existing subscription with 200 and
// creates nothing. A different deviceId creates a second subscription for the
// same user. ERR_ALERT_ALREADY_JOINED is reserved for the genuine concurrent
// race, where two inserts for the same device hit the unique index at once.
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "joinAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req joinRequest
if err := decodePayload(payload, &req); err != nil {
validationError(w, "invalid payload: %v", err)
return
}
alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID))
if err != nil {
validationError(w, "alertId is not a valid uuid")
return
}
deviceID, err := validateDeviceID(req.DeviceID)
if err != nil {
validationError(w, "%v", err)
return
}
label, err := validateOptional(req.DeviceLabel, "deviceLabel", maxLabelLen)
if err != nil {
validationError(w, "%v", err)
return
}
platform, err := validateOptional(req.Platform, "platform", maxPlatformLen)
if err != nil {
validationError(w, "%v", err)
return
}
if !alarmtoken.ValidSubscribeTokenShape(strings.TrimSpace(req.SubscribeToken)) {
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) {
Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID)
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return
}
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, deviceID)
switch {
case err == nil:
Debug(action, "POST", "idempotent re-join alert=%s sub=%s device=%s", alertID, existing.ID, deviceID)
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
return
case !errors.Is(err, db.ErrNotFound):
dbError(w, action, err)
return
}
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
AlertID: alertID,
SubscriberUserID: p.ID,
DeviceID: deviceID,
DeviceLabel: label,
Platform: platform,
})
if errors.Is(err, db.ErrConflict) {
// Two concurrent joins for the same device: the sequential repeat path
// above already handled the normal re-scan case.
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s device=%s", alertID, p.ID, deviceID)
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
return
}
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "joined alert=%s user=%s sub=%s device=%s", alertID, p.ID, sub.ID, deviceID)
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
}
// ---- listSubscriptions ------------------------------------------------------
type listSubscriptionsResponse struct {
Items []subscriptionView `json:"items"`
}
// ListSubscriptions implements GET/POST /action/listSubscriptions. Admin only.
func ListSubscriptions(w http.ResponseWriter, r *http.Request, payload any) {
const action = "listSubscriptions"
alert, _, ok := requireOwner(w, r, action, payload)
if !ok {
return
}
subs, err := db.ListSubscriptions(r.Context(), alert.ID)
if err != nil {
dbError(w, action, err)
return
}
items := make([]subscriptionView, 0, len(subs))
for i := range subs {
items = append(items, newSubscriptionView(&subs[i]))
}
writeJSON(w, http.StatusOK, listSubscriptionsResponse{Items: items})
}
// ---- removeSubscription -----------------------------------------------------
type removeSubscriptionRequest struct {
AlertID string `json:"alertId"`
SubscriptionID string `json:"subscriptionId"`
}
// RemoveSubscription implements POST/DELETE /action/removeSubscription. The
// alert's admin may remove any device; a subscriber may remove their own.
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
const action = "removeSubscription"
p, ok := principal(w, r, action)
if !ok {
return
}
var req removeSubscriptionRequest
_ = decodePayload(payload, &req)
if req.SubscriptionID == "" {
req.SubscriptionID = strings.TrimSpace(r.URL.Query().Get("subscriptionId"))
}
if req.AlertID == "" {
req.AlertID = strings.TrimSpace(r.URL.Query().Get("alertId"))
}
alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID))
if err != nil {
validationError(w, "alertId is not a valid uuid")
return
}
subscriptionID, err := uuid.Parse(strings.TrimSpace(req.SubscriptionID))
if err != nil {
validationError(w, "subscriptionId is not a valid uuid")
return
}
sub, err := db.GetSubscription(r.Context(), alertID, subscriptionID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
dbError(w, action, err)
return
}
if sub.SubscriberUserID != p.ID {
// Not the owner: only the alert admin may remove someone else's device.
// A stranger gets the same code as a missing row so the endpoint never
// confirms that the subscription exists.
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if alert.AdminUserID != p.ID {
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID)
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
}
if err := db.DeleteSubscription(r.Context(), alertID, subscriptionID); err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID)
writeJSON(w, http.StatusOK, map[string]any{
"alertId": alertID.String(),
"subscriptionId": subscriptionID.String(),
"removed": true,
})
}