init
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user