init
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-2-install-and-configure-air
|
||||
|
||||
# .air.toml - Air Configuration for Hot Reloading
|
||||
|
||||
# This file configures how Air watches and rebuilds your Go application
|
||||
|
||||
# Root directory for watching - use "." for current directory
|
||||
root = "."
|
||||
|
||||
# Temporary directory where Air stores build artifacts
|
||||
# This directory is created automatically and should be gitignored
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
# Command to build your application
|
||||
# Add any build flags you need here (like -race for race detection)
|
||||
cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ."
|
||||
|
||||
# Tell Air to launch the compiled binary inside Delve using the legacy API
|
||||
full_bin = "dlv exec --accept-multiclient --headless --continue --listen=:2345 --api-version=2 ./tmp/main"
|
||||
|
||||
# Path to the built binary that Air will execute
|
||||
# bin = "./tmp/main"
|
||||
|
||||
# Entrypoint binary relative to root
|
||||
# Air recommends this over relying on bin alone
|
||||
# entrypoint = ["./tmp/main"]
|
||||
|
||||
# Arguments to pass to your application when running
|
||||
# Useful for passing flags or configuration
|
||||
args_bin = []
|
||||
|
||||
# Pre-build command - runs before each build
|
||||
# Useful for code generation, proto compilation, etc.
|
||||
pre_cmd = []
|
||||
|
||||
# Post-build command - runs after each successful build
|
||||
# Useful for running migrations or other setup tasks
|
||||
post_cmd = []
|
||||
|
||||
# Enable polling mode for file watching (useful for Docker on some OSes)
|
||||
# Set to true if you're on Windows or using certain Docker setups
|
||||
poll = false
|
||||
|
||||
# Polling interval in milliseconds (only used if poll = true)
|
||||
poll_interval = 500
|
||||
|
||||
# Build delay after file change is detected (in milliseconds)
|
||||
# Helps batch multiple rapid changes into one rebuild
|
||||
delay = 1000
|
||||
|
||||
# Stop running the old binary before building the new one
|
||||
# Recommended to avoid port conflicts
|
||||
stop_on_error = true
|
||||
|
||||
# Send interrupt signal before killing process
|
||||
# Allows graceful shutdown of your application
|
||||
send_interrupt = true
|
||||
|
||||
# Delay after interrupt before force killing
|
||||
kill_delay = "500ms"
|
||||
|
||||
# Rerun binary on build error - useful for debugging
|
||||
rerun = false
|
||||
|
||||
# Delay before rerunning (in milliseconds)
|
||||
rerun_delay = 500
|
||||
|
||||
# Build error log file, placed inside tmp_dir
|
||||
log = "build-errors.log"
|
||||
|
||||
# Directories to watch for changes
|
||||
# Air watches the whole root unless you restrict it here
|
||||
include_dir = []
|
||||
|
||||
# Directories to exclude from watching
|
||||
# These directories will be ignored when detecting file changes
|
||||
exclude_dir = [
|
||||
"tmp",
|
||||
"vendor",
|
||||
"node_modules",
|
||||
".git",
|
||||
"testdata",
|
||||
"docs",
|
||||
"scripts",
|
||||
]
|
||||
|
||||
# File patterns to exclude from watching
|
||||
# Useful for ignoring generated files, tests, or documentation
|
||||
exclude_regex = [
|
||||
"_test\\.go$",
|
||||
"_mock\\.go$",
|
||||
"\\.pb\\.go$",
|
||||
"swagger\\.json$",
|
||||
"\\.md$",
|
||||
]
|
||||
|
||||
# File extensions to watch - only these file types trigger rebuilds
|
||||
# Add more extensions if you have other file types that should trigger rebuilds
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "env"]
|
||||
|
||||
# Specific files to exclude from watching
|
||||
exclude_file = [
|
||||
"go.sum",
|
||||
".air.toml",
|
||||
]
|
||||
|
||||
# Specific files to watch (in addition to extension-based matching)
|
||||
include_file = []
|
||||
|
||||
# Exclude unchanged files from triggering rebuilds
|
||||
exclude_unchanged = false
|
||||
|
||||
[color]
|
||||
# Customize output colors for better readability in terminal
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[log]
|
||||
# Log output settings
|
||||
# Time format for logs (Go time format)
|
||||
time = true
|
||||
|
||||
# Only show main binary output (hide Air's internal logs)
|
||||
main_only = false
|
||||
|
||||
[misc]
|
||||
# Clean temporary directory on exit
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
# Clear screen on rebuild for cleaner output
|
||||
clear_on_rebuild = true
|
||||
|
||||
# Keep scroll position (set to false for always scrolling to top)
|
||||
keep_scroll = true
|
||||
|
||||
[proxy]
|
||||
# Proxy configuration (optional, for advanced setups)
|
||||
enabled = false
|
||||
proxy_port = 8090
|
||||
app_port = 8080
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Alert is one alarm.alerts row.
|
||||
type Alert struct {
|
||||
ID uuid.UUID
|
||||
AdminUserID uuid.UUID
|
||||
Name string
|
||||
Message *string
|
||||
Status string
|
||||
AdminTokenHash []byte
|
||||
SubscribeToken string
|
||||
TokenVersion int
|
||||
AutoStopSeconds int
|
||||
Created time.Time
|
||||
Modified time.Time
|
||||
TriggeredAt *time.Time
|
||||
DisarmedAt *time.Time
|
||||
}
|
||||
|
||||
const alertColumns = `id, admin_user_id, name, message, status::text, admin_token_hash,
|
||||
subscribe_token, token_version, auto_stop_seconds, created, modified, triggered_at, disarmed_at`
|
||||
|
||||
// alertColumnsAliased is the same list qualified with the `a` alias used by the
|
||||
// join in GetAlertWithLatestEvent.
|
||||
const alertColumnsAliased = `a.id, a.admin_user_id, a.name, a.message, a.status::text, a.admin_token_hash,
|
||||
a.subscribe_token, a.token_version, a.auto_stop_seconds, a.created, a.modified, a.triggered_at, a.disarmed_at`
|
||||
|
||||
func scanAlert(row pgx.Row) (*Alert, error) {
|
||||
var a Alert
|
||||
err := row.Scan(&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash,
|
||||
&a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified,
|
||||
&a.TriggeredAt, &a.DisarmedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateAlert inserts a new alert and returns the stored row.
|
||||
func CreateAlert(ctx context.Context, a *Alert) (*Alert, error) {
|
||||
const q = `INSERT INTO alarm.alerts
|
||||
(admin_user_id, name, message, admin_token_hash, subscribe_token, auto_stop_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING ` + alertColumns
|
||||
return scanAlert(Pool().QueryRow(ctx, q,
|
||||
a.AdminUserID, a.Name, a.Message, a.AdminTokenHash, a.SubscribeToken, a.AutoStopSeconds))
|
||||
}
|
||||
|
||||
// GetAlert fetches one alert by id. It returns ErrNotFound when the row is gone.
|
||||
func GetAlert(ctx context.Context, alertID uuid.UUID) (*Alert, error) {
|
||||
const q = `SELECT ` + alertColumns + ` FROM alarm.alerts WHERE id = $1`
|
||||
return scanAlert(Pool().QueryRow(ctx, q, alertID))
|
||||
}
|
||||
|
||||
// DeleteAlert removes an alert; subscriptions and events cascade. It reports
|
||||
// ErrNotFound when nothing was deleted, so a repeated delete is visible.
|
||||
func DeleteAlert(ctx context.Context, alertID uuid.UUID) error {
|
||||
tag, err := Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE id = $1`, alertID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateSubscribeToken replaces the subscribe token and bumps token_version.
|
||||
// Existing subscriptions are untouched: rotation invalidates the QR/join path,
|
||||
// not the subscribers who already joined.
|
||||
func RotateSubscribeToken(ctx context.Context, alertID uuid.UUID, newToken string) (int, error) {
|
||||
var version int
|
||||
err := Pool().QueryRow(ctx,
|
||||
`UPDATE alarm.alerts SET subscribe_token = $2, token_version = token_version + 1
|
||||
WHERE id = $1 RETURNING token_version`, alertID, newToken).Scan(&version)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// CountSubscriptions returns how many devices are subscribed to an alert.
|
||||
func CountSubscriptions(ctx context.Context, alertID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := Pool().QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM alarm.subscriptions WHERE alert_id = $1`, alertID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// AlertWithLatestEvent is the cheap single-round-trip read behind alarmStatus
|
||||
// and getAlert: the alert row plus its newest event (nil when it has none).
|
||||
type AlertWithLatestEvent struct {
|
||||
Alert *Alert
|
||||
Event *Event
|
||||
}
|
||||
|
||||
// GetAlertWithLatestEvent runs one indexed query: the alert plus a LATERAL
|
||||
// lookup of its newest event on (alert_id, created_at DESC, id DESC).
|
||||
func GetAlertWithLatestEvent(ctx context.Context, alertID uuid.UUID) (*AlertWithLatestEvent, error) {
|
||||
const q = `
|
||||
SELECT ` + alertColumnsAliased + `,
|
||||
e.id, e.alert_id, e.kind::text, e.actor_user_id, e.message, e.created_at
|
||||
FROM alarm.alerts a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, alert_id, kind, actor_user_id, message, created_at
|
||||
FROM alarm.alarm_events
|
||||
WHERE alert_id = a.id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
) e ON TRUE
|
||||
WHERE a.id = $1`
|
||||
|
||||
var a Alert
|
||||
var (
|
||||
evID *uuid.UUID
|
||||
evAlertID *uuid.UUID
|
||||
evKind *string
|
||||
evActor *uuid.UUID
|
||||
evMessage *string
|
||||
evCreated *time.Time
|
||||
)
|
||||
err := Pool().QueryRow(ctx, q, alertID).Scan(
|
||||
&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash,
|
||||
&a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified,
|
||||
&a.TriggeredAt, &a.DisarmedAt,
|
||||
&evID, &evAlertID, &evKind, &evActor, &evMessage, &evCreated,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &AlertWithLatestEvent{Alert: &a}
|
||||
if evID != nil {
|
||||
out.Event = &Event{
|
||||
ID: *evID,
|
||||
AlertID: *evAlertID,
|
||||
Kind: *evKind,
|
||||
ActorUserID: *evActor,
|
||||
Message: evMessage,
|
||||
CreatedAt: *evCreated,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// 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 db owns the Alarm service's PostgreSQL access: the pool bootstrap and
|
||||
// one file per table family (alerts, subscriptions, events). All state
|
||||
// transitions that must be atomic (trigger/disarm) run inside a transaction
|
||||
// with the alert row locked, so two concurrent admins cannot interleave.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Sentinel errors mapped to the wire error codes by the action layer.
|
||||
var (
|
||||
// ErrNotFound is returned when a row does not exist.
|
||||
ErrNotFound = errors.New("db: not found")
|
||||
// ErrConflict is returned for a unique-constraint violation (23505).
|
||||
ErrConflict = errors.New("db: conflict")
|
||||
// ErrNotAdmin is returned when the caller is not the alert's admin.
|
||||
ErrNotAdmin = errors.New("db: caller is not the alert admin")
|
||||
// ErrState is returned when a transition is illegal for the current status.
|
||||
ErrState = errors.New("db: illegal state transition")
|
||||
)
|
||||
|
||||
// DB is the process-wide connection pool holder.
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
var (
|
||||
instance *DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// DatabaseURL returns DATABASE_URL, defaulting to the shared database as
|
||||
// reachable by DNS on the dcntrlized network. Host-side development overrides
|
||||
// it (e.g. postgres://postgres:dcntrlized@localhost:55432/dcntrlized). The
|
||||
// shared database authenticates with scram-sha-256, hence the shared password
|
||||
// in the default; Maestro injects its own DSN for registry-managed services.
|
||||
func DatabaseURL() string {
|
||||
if url := os.Getenv("DATABASE_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
return "postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable"
|
||||
}
|
||||
|
||||
// Pool returns the process-wide pool, creating it on first use. Creation
|
||||
// tolerates startup ordering: the shared database container (or the migrate
|
||||
// one-shot applying the schema) may not be ready when this service boots, so
|
||||
// the pool pings until it is reachable.
|
||||
func Pool() *pgxpool.Pool {
|
||||
once.Do(func() {
|
||||
instance = newDB()
|
||||
})
|
||||
return instance.Pool
|
||||
}
|
||||
|
||||
// NewDB creates (or returns) the pool and is safe to call more than once.
|
||||
func NewDB() *DB {
|
||||
Pool()
|
||||
return instance
|
||||
}
|
||||
|
||||
func newDB() *DB {
|
||||
url := DatabaseURL()
|
||||
pool, err := pgxpool.New(context.Background(), url)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create database pool: %v", err)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
if err := pool.Ping(pingCtx); err == nil {
|
||||
break
|
||||
} else {
|
||||
select {
|
||||
case <-pingCtx.Done():
|
||||
pool.Close()
|
||||
log.Fatalf("Database %s unreachable within 30s: %v", redact(url), err)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
return &DB{Pool: pool}
|
||||
}
|
||||
|
||||
// Close releases the pool.
|
||||
func Close() {
|
||||
if instance != nil && instance.Pool != nil {
|
||||
instance.Pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// redact strips the password from a DSN before it can reach a log line:
|
||||
// postgres://user:password@host -> postgres://user:***@host.
|
||||
func redact(dsn string) string {
|
||||
scheme := strings.Index(dsn, "://")
|
||||
if scheme < 0 {
|
||||
return dsn
|
||||
}
|
||||
at := strings.LastIndexByte(dsn, '@')
|
||||
if at < 0 {
|
||||
return dsn
|
||||
}
|
||||
colon := strings.IndexByte(dsn[scheme+3:], ':')
|
||||
if colon < 0 {
|
||||
return dsn
|
||||
}
|
||||
colon += scheme + 3
|
||||
if colon >= at {
|
||||
return dsn
|
||||
}
|
||||
return dsn[:colon+1] + "***" + dsn[at:]
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL 23505.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Event is one alarm.alarm_events row (append-only trigger/disarm log).
|
||||
type Event struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
Kind string
|
||||
ActorUserID uuid.UUID
|
||||
Message *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const eventColumns = `id, alert_id, kind::text, actor_user_id, message, created_at`
|
||||
|
||||
func scanEvent(row pgx.Row) (*Event, error) {
|
||||
var e Event
|
||||
err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &e.ActorUserID, &e.Message, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// LatestEvent returns the newest event for an alert (ErrNotFound when the alert
|
||||
// has never fired). It is the indexed read behind alarmStatus.
|
||||
func LatestEvent(ctx context.Context, alertID uuid.UUID) (*Event, error) {
|
||||
const q = `SELECT ` + eventColumns + `
|
||||
FROM alarm.alarm_events WHERE alert_id = $1
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
return scanEvent(Pool().QueryRow(ctx, q, alertID))
|
||||
}
|
||||
|
||||
// TriggerAlert transitions an alert to triggered and appends the event, in one
|
||||
// transaction with the alert row locked. Re-triggering a live alert is allowed
|
||||
// and restarts the auto-stop window; it produces a new event id so every client
|
||||
// re-fires and the hub's event-id de-duplication does not swallow it.
|
||||
//
|
||||
// The caller must already have verified the admin token; this function
|
||||
// re-checks the actor against admin_user_id so the check cannot be skipped.
|
||||
func TriggerAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) {
|
||||
return transition(ctx, alertID, actorUserID, alertstate.KindTrigger, message, nil)
|
||||
}
|
||||
|
||||
// DisarmAlert transitions an alert to disarmed and appends the event. Disarming
|
||||
// an alert that is not triggered is ErrState (the client's view is stale).
|
||||
func DisarmAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) {
|
||||
guard := func(a *Alert) error {
|
||||
if !alertstate.CanDisarm(a.Status) {
|
||||
return ErrState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return transition(ctx, alertID, actorUserID, alertstate.KindDisarm, message, guard)
|
||||
}
|
||||
|
||||
func transition(
|
||||
ctx context.Context,
|
||||
alertID, actorUserID uuid.UUID,
|
||||
kind string,
|
||||
message *string,
|
||||
guard func(*Alert) error,
|
||||
) (*Alert, *Event, error) {
|
||||
tx, err := Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Lock the row for the whole transition so two concurrent commands cannot
|
||||
// interleave their status writes.
|
||||
current, err := scanAlert(tx.QueryRow(ctx,
|
||||
`SELECT `+alertColumns+` FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if current.AdminUserID != actorUserID {
|
||||
return nil, nil, ErrNotAdmin
|
||||
}
|
||||
if guard != nil {
|
||||
if err := guard(current); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var updated *Alert
|
||||
switch kind {
|
||||
case alertstate.KindTrigger:
|
||||
updated, err = scanAlert(tx.QueryRow(ctx,
|
||||
`UPDATE alarm.alerts SET status = 'triggered', triggered_at = NOW()
|
||||
WHERE id = $1 RETURNING `+alertColumns, alertID))
|
||||
case alertstate.KindDisarm:
|
||||
updated, err = scanAlert(tx.QueryRow(ctx,
|
||||
`UPDATE alarm.alerts SET status = 'disarmed', disarmed_at = NOW()
|
||||
WHERE id = $1 RETURNING `+alertColumns, alertID))
|
||||
default:
|
||||
return nil, nil, errors.New("db: unknown event kind")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
event, err := scanEvent(tx.QueryRow(ctx,
|
||||
`INSERT INTO alarm.alarm_events (alert_id, kind, actor_user_id, message)
|
||||
VALUES ($1, $2, $3, $4) RETURNING `+eventColumns,
|
||||
alertID, kind, actorUserID, message))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// The NOTIFY trigger fires on COMMIT, so no frame can be observed before
|
||||
// the new status is visible to every reader.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return updated, event, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
||||
type Subscription struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
SubscriberUserID uuid.UUID
|
||||
DeviceID string
|
||||
DeviceLabel *string
|
||||
Platform *string
|
||||
Enabled bool
|
||||
LastSeenAt *time.Time
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
||||
platform, enabled, last_seen_at, created`
|
||||
|
||||
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSubscription inserts a subscription. A duplicate
|
||||
// (alert_id, subscriber_user_id, device_id) is reported as ErrConflict, which
|
||||
// the join action turns into the ERR_ALERT_ALREADY_JOINED race fallback — the
|
||||
// sequential re-scan path is handled by the caller's lookup and never reaches
|
||||
// this error.
|
||||
func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
|
||||
const q = `INSERT INTO alarm.subscriptions
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING ` + subscriptionColumns
|
||||
row, err := scanSubscription(Pool().QueryRow(ctx, q,
|
||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetSubscriptionByDevice is the idempotency lookup for joinAlert.
|
||||
func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE alert_id = $1 AND subscriber_user_id = $2 AND device_id = $3`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
||||
}
|
||||
|
||||
// GetSubscription fetches one subscription scoped to its alert.
|
||||
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, subscriptionID))
|
||||
}
|
||||
|
||||
// ListSubscriptions returns every device subscribed to an alert, oldest first.
|
||||
func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions WHERE alert_id = $1 ORDER BY created, id`
|
||||
rows, err := Pool().Query(ctx, q, alertID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Subscription, 0, 4)
|
||||
for rows.Next() {
|
||||
var s Subscription
|
||||
if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteSubscription removes one subscription. ErrNotFound when it was already
|
||||
// gone, so a repeated removal is visible to the caller.
|
||||
func DeleteSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) error {
|
||||
tag, err := Pool().Exec(ctx,
|
||||
`DELETE FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`, alertID, subscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSubscriber reports whether the user has at least one subscription row on
|
||||
// the alert. It is the authorisation check behind getAlert/alarmStatus.
|
||||
func IsSubscriber(ctx context.Context, alertID, userID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := Pool().QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2)`,
|
||||
alertID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// UserSubscriptionIDs lists the user's subscription ids on an alert, so a WS
|
||||
// connection can be tied to the right device rows.
|
||||
func UserSubscriptionIDs(ctx context.Context, alertID, userID uuid.UUID) ([]uuid.UUID, error) {
|
||||
rows, err := Pool().Query(ctx,
|
||||
`SELECT id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2 ORDER BY created`,
|
||||
alertID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TouchSubscription stamps last_seen_at. It is called on WS connect and on
|
||||
// every heartbeat; a no-op update (the row vanished mid-connection) is not an
|
||||
// error worth propagating.
|
||||
func TouchSubscription(ctx context.Context, subscriptionID uuid.UUID) error {
|
||||
_, err := Pool().Exec(ctx,
|
||||
`UPDATE alarm.subscriptions SET last_seen_at = NOW() WHERE id = $1`, subscriptionID)
|
||||
return err
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
module git.dcntrlized.org/Dcntrlized/Alarm
|
||||
|
||||
require (
|
||||
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5
|
||||
github.com/boombuler/barcode v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
go 1.26.5
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5 h1:T2To6w6+8J+PlvresQawoqVUiKZWSLG0FtRLuTS/4L4=
|
||||
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5/go.mod h1:BCGcoKSC+yVLRDnYZGUCBcrgu7NaFWdA39PdGG1+oNM=
|
||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,131 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/actions"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/types"
|
||||
)
|
||||
|
||||
// ActionActionNameGet dispatches the GET /action/{actionName} request.
|
||||
//
|
||||
// GET carries no body: the read actions take their arguments from the query
|
||||
// string (e.g. /action/getAlert?alertId=…). The POST switch below accepts the
|
||||
// same actions with a {payload} envelope for clients that prefer one verb.
|
||||
func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
|
||||
switch action {
|
||||
case "getAlert":
|
||||
actions.GetAlert(w, r, nil)
|
||||
case "listSubscriptions":
|
||||
actions.ListSubscriptions(w, r, nil)
|
||||
case "alarmStatus":
|
||||
actions.AlarmStatus(w, r, nil)
|
||||
case "qrImage":
|
||||
actions.QRImage(w, r, nil)
|
||||
case "logout":
|
||||
actions.Logout(w, r, nil)
|
||||
case "me":
|
||||
actions.Me(w, r)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ActionActionNamePost dispatches the POST /action/{actionName} request.
|
||||
func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
// Alarm actions.
|
||||
case "createAlert":
|
||||
actions.CreateAlert(w, r, req.Payload)
|
||||
case "getAlert":
|
||||
actions.GetAlert(w, r, req.Payload)
|
||||
case "joinAlert":
|
||||
actions.JoinAlert(w, r, req.Payload)
|
||||
case "listSubscriptions":
|
||||
actions.ListSubscriptions(w, r, req.Payload)
|
||||
case "removeSubscription":
|
||||
actions.RemoveSubscription(w, r, req.Payload)
|
||||
case "triggerAlert":
|
||||
actions.TriggerAlert(w, r, req.Payload)
|
||||
case "disarmAlert":
|
||||
actions.DisarmAlert(w, r, req.Payload)
|
||||
case "alarmStatus":
|
||||
actions.AlarmStatus(w, r, req.Payload)
|
||||
case "deleteAlert":
|
||||
actions.DeleteAlert(w, r, req.Payload)
|
||||
case "qrImage":
|
||||
actions.QRImage(w, r, req.Payload)
|
||||
case "rotateSubscribeToken":
|
||||
actions.RotateSubscribeToken(w, r, req.Payload)
|
||||
|
||||
// Shared-auth proxies.
|
||||
case "login":
|
||||
actions.Login(w, r, req.Payload)
|
||||
case "register":
|
||||
actions.Register(w, r, req.Payload)
|
||||
case "refresh":
|
||||
actions.Refresh(w, r, req.Payload)
|
||||
case "delete":
|
||||
actions.Delete(w, r, req.Payload)
|
||||
case "logout":
|
||||
actions.Logout(w, r, req.Payload)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ActionActionNamePut dispatches the PUT /action/{actionName} request.
|
||||
func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ActionActionNameDelete dispatches the DELETE /action/{actionName} request.
|
||||
// The two removals are also reachable as POST; DELETE is the REST-idiomatic
|
||||
// verb and carries the same {payload} envelope.
|
||||
func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "deleteAlert":
|
||||
actions.DeleteAlert(w, r, req.Payload)
|
||||
case "removeSubscription":
|
||||
actions.RemoveSubscription(w, r, req.Payload)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthDeletePost proxies POST /auth/delete to the shared dcntrlized-auth service.
|
||||
func AuthDeletePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.Proxy(w, r, "delete")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthLoginPost proxies POST /auth/login to the shared dcntrlized-auth service.
|
||||
func AuthLoginPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.Proxy(w, r, "login")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthLogoutGet proxies GET /auth/logout to the shared dcntrlized-auth service.
|
||||
func AuthLogoutGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.ProxyEmpty(w, r, "logout")
|
||||
}
|
||||
|
||||
// AuthLogoutPost proxies POST /auth/logout to the shared dcntrlized-auth service.
|
||||
func AuthLogoutPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.Proxy(w, r, "logout")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthMeGet proxies GET /auth/me to the shared dcntrlized-auth service.
|
||||
func AuthMeGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.ProxyEmpty(w, r, "me")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthRefreshPost proxies POST /auth/refresh to the shared dcntrlized-auth service.
|
||||
func AuthRefreshPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.Proxy(w, r, "refresh")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthRegisterPost proxies POST /auth/register to the shared dcntrlized-auth service.
|
||||
func AuthRegisterPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
authproxy.Proxy(w, r, "register")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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 auth
|
||||
|
||||
// No inline route-specific types were generated for this tag.
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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 manifest
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed manifest.json
|
||||
var manifest string
|
||||
|
||||
func ManifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(manifest))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Alarm",
|
||||
"author": "Dcntrlized",
|
||||
"capabilities": [
|
||||
"database",
|
||||
"alarm.live"
|
||||
],
|
||||
"has_auth": true,
|
||||
"public_key": "",
|
||||
"icon": ""
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 alarmtoken holds the Alarm service's two bearer secrets and their
|
||||
// comparison rules (frozen contract v1):
|
||||
//
|
||||
// - the ADMIN token is 32 random bytes, base64url (43 chars). It is returned
|
||||
// exactly once at alert creation and stored ONLY as its SHA-256 digest, so
|
||||
// a database leak does not yield a usable admin token. Comparison is
|
||||
// constant-time over the digest.
|
||||
// - the SUBSCRIBE token is 24 random bytes, base64url (32 chars). It is
|
||||
// stored in plaintext on purpose: it is shared through the QR code with
|
||||
// every subscriber, and rotation is a cheap UPDATE (token_version). It is
|
||||
// still compared in constant time so a timing oracle cannot be used to
|
||||
// recover it byte by byte.
|
||||
package alarmtoken
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// AdminTokenBytes is the entropy of an admin token (frozen contract: 32).
|
||||
AdminTokenBytes = 32
|
||||
// SubscribeTokenBytes is the entropy of a subscribe token (frozen contract: 24).
|
||||
SubscribeTokenBytes = 24
|
||||
// HashBytes is the length of the stored SHA-256 digest.
|
||||
HashBytes = sha256.Size
|
||||
)
|
||||
|
||||
// ErrRandomSource reports a failure of crypto/rand. It is returned rather than
|
||||
// panicking so a caller can answer 500 instead of killing the process.
|
||||
var ErrRandomSource = errors.New("alarmtoken: crypto/rand failed")
|
||||
|
||||
// NewAdminToken returns a fresh 32-byte admin token, base64url without padding.
|
||||
func NewAdminToken() (string, error) {
|
||||
return randomToken(AdminTokenBytes)
|
||||
}
|
||||
|
||||
// NewSubscribeToken returns a fresh 24-byte subscribe token, base64url without
|
||||
// padding (32 characters).
|
||||
func NewSubscribeToken() (string, error) {
|
||||
return randomToken(SubscribeTokenBytes)
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrRandomSource, err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Hash returns the SHA-256 digest of a token. That digest is what is persisted
|
||||
// for admin tokens; the plaintext never touches the database.
|
||||
func Hash(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// EqualHash reports whether token hashes to stored. It is constant-time with
|
||||
// respect to the digest contents and rejects a stored digest of the wrong
|
||||
// length up front (a malformed row must never compare equal).
|
||||
func EqualHash(stored []byte, token string) bool {
|
||||
if len(stored) != HashBytes {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(stored, Hash(token)) == 1
|
||||
}
|
||||
|
||||
// EqualToken compares two plaintext tokens in constant time. Both sides are
|
||||
// hashed first, so the comparison time does not depend on either length and a
|
||||
// length mismatch cannot short-circuit.
|
||||
func EqualToken(a, b string) bool {
|
||||
ha := sha256.Sum256([]byte(a))
|
||||
hb := sha256.Sum256([]byte(b))
|
||||
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
|
||||
}
|
||||
|
||||
// ValidSubscribeTokenShape reports whether s looks like a token this service
|
||||
// issues: base64url alphabet, between 16 and 64 characters. It is a cheap
|
||||
// pre-filter for untrusted input (QR payloads, query strings), not a substitute
|
||||
// for EqualToken.
|
||||
func ValidSubscribeTokenShape(s string) bool {
|
||||
if len(s) < 16 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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 alarmtoken
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTokensAreDistinctAndWellShaped(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 64; i++ {
|
||||
admin, err := NewAdminToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewAdminToken: %v", err)
|
||||
}
|
||||
sub, err := NewSubscribeToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSubscribeToken: %v", err)
|
||||
}
|
||||
if seen[admin] || seen[sub] {
|
||||
t.Fatalf("duplicate token generated")
|
||||
}
|
||||
seen[admin], seen[sub] = true, true
|
||||
|
||||
raw, err := base64.RawURLEncoding.DecodeString(admin)
|
||||
if err != nil {
|
||||
t.Fatalf("admin token is not raw-url base64: %v", err)
|
||||
}
|
||||
if len(raw) != AdminTokenBytes {
|
||||
t.Fatalf("admin token entropy = %d bytes, want %d", len(raw), AdminTokenBytes)
|
||||
}
|
||||
raw, err = base64.RawURLEncoding.DecodeString(sub)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe token is not raw-url base64: %v", err)
|
||||
}
|
||||
if len(raw) != SubscribeTokenBytes {
|
||||
t.Fatalf("subscribe token entropy = %d bytes, want %d", len(raw), SubscribeTokenBytes)
|
||||
}
|
||||
if !ValidSubscribeTokenShape(sub) {
|
||||
t.Fatalf("freshly generated subscribe token %q fails its own shape check", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAndEqualHash(t *testing.T) {
|
||||
token, err := NewAdminToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewAdminToken: %v", err)
|
||||
}
|
||||
digest := Hash(token)
|
||||
if len(digest) != HashBytes {
|
||||
t.Fatalf("Hash length = %d, want %d", len(digest), HashBytes)
|
||||
}
|
||||
if !bytes.Equal(digest, Hash(token)) {
|
||||
t.Fatalf("Hash is not deterministic")
|
||||
}
|
||||
if !EqualHash(digest, token) {
|
||||
t.Fatalf("EqualHash rejected the token it was computed from")
|
||||
}
|
||||
other, err := NewAdminToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewAdminToken: %v", err)
|
||||
}
|
||||
if EqualHash(digest, other) {
|
||||
t.Fatalf("EqualHash accepted a different token")
|
||||
}
|
||||
// A malformed (short) stored digest must never compare equal.
|
||||
if EqualHash(digest[:HashBytes-1], token) {
|
||||
t.Fatalf("EqualHash accepted a truncated digest")
|
||||
}
|
||||
if EqualHash(nil, token) {
|
||||
t.Fatalf("EqualHash accepted an empty digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualToken(t *testing.T) {
|
||||
a, _ := NewSubscribeToken()
|
||||
b, _ := NewSubscribeToken()
|
||||
if !EqualToken(a, a) {
|
||||
t.Fatalf("EqualToken rejected identical tokens")
|
||||
}
|
||||
if EqualToken(a, b) {
|
||||
t.Fatalf("EqualToken accepted different tokens")
|
||||
}
|
||||
// Different lengths must compare unequal, not panic.
|
||||
if EqualToken(a, a+"x") {
|
||||
t.Fatalf("EqualToken accepted a longer token")
|
||||
}
|
||||
if !EqualToken("", "") {
|
||||
t.Fatalf("EqualToken rejected two empty strings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSubscribeTokenShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"short", false},
|
||||
{"0123456789abcde", false}, // 15 chars -> too short
|
||||
{"0123456789abcdef", true}, // 16 chars, alphabet ok
|
||||
{strings.Repeat("a", 64), true}, // max length
|
||||
{strings.Repeat("a", 65), false}, // too long
|
||||
{"0123456789abcde!", false}, // illegal character
|
||||
{"0123456789abcde/", false}, // not url-safe
|
||||
{"0123456789abcde ", false}, // whitespace
|
||||
{"0123456789abcdeé", false}, // non-ASCII
|
||||
{"AAAA0123456789-_abcdef", true}, // - and _ are legal
|
||||
{"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJ", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ValidSubscribeTokenShape(tc.in); got != tc.want {
|
||||
t.Errorf("ValidSubscribeTokenShape(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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 alertstate holds the alarm state machine as pure functions, so the
|
||||
// transitions are unit-testable without a database.
|
||||
//
|
||||
// Frozen v1 semantics:
|
||||
//
|
||||
// - `status` is the last COMMANDED state (armed | triggered | disarmed).
|
||||
// - `active` is DERIVED: a triggered alert stops being active once its
|
||||
// auto-stop window elapses, with no background job and no timer. That is
|
||||
// what makes auto-stop correct across several service instances.
|
||||
package alertstate
|
||||
|
||||
import "time"
|
||||
|
||||
// Commanded statuses.
|
||||
const (
|
||||
StatusArmed = "armed"
|
||||
StatusTriggered = "triggered"
|
||||
StatusDisarmed = "disarmed"
|
||||
)
|
||||
|
||||
// Event kinds (also the wire `action` values).
|
||||
const (
|
||||
KindTrigger = "trigger"
|
||||
KindDisarm = "disarm"
|
||||
)
|
||||
|
||||
// MaxAutoStopSeconds bounds auto_stop_seconds (mirrors the SQL CHECK).
|
||||
const MaxAutoStopSeconds = 86400
|
||||
|
||||
// ValidStatus reports whether s is one of the three commanded statuses.
|
||||
func ValidStatus(s string) bool {
|
||||
switch s {
|
||||
case StatusArmed, StatusTriggered, StatusDisarmed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidKind reports whether k is a valid event kind.
|
||||
func ValidKind(k string) bool {
|
||||
return k == KindTrigger || k == KindDisarm
|
||||
}
|
||||
|
||||
// IsActive reports whether the alert is live at now.
|
||||
//
|
||||
// A never-triggered alert (nil triggeredAt) is never active. autoStopSeconds
|
||||
// == 0 means "ring until disarmed"; a positive value bounds the window. The
|
||||
// window is inclusive of its start and exclusive at its end.
|
||||
func IsActive(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) bool {
|
||||
if status != StatusTriggered || triggeredAt == nil {
|
||||
return false
|
||||
}
|
||||
if autoStopSeconds <= 0 {
|
||||
return true
|
||||
}
|
||||
return now.Before(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second))
|
||||
}
|
||||
|
||||
// RemainingAutoStopSeconds returns how much of the auto-stop window is left at
|
||||
// now, or 0 when the alert is not bounded or has already elapsed. It is what
|
||||
// alarmStatus reports so a client can run its own countdown.
|
||||
func RemainingAutoStopSeconds(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) int {
|
||||
if !IsActive(status, triggeredAt, autoStopSeconds, now) || autoStopSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
left := int(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second).Sub(now).Seconds())
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// CanDisarm reports whether a disarm command is legal for the current status.
|
||||
// Disarming an alert that was never triggered (or is already disarmed) is a
|
||||
// state error, not a no-op, so the client learns its view is stale.
|
||||
func CanDisarm(status string) bool {
|
||||
return status == StatusTriggered
|
||||
}
|
||||
|
||||
// CanDelete reports whether the alert may be deleted. A live alarm must be
|
||||
// disarmed first: deleting it would silently orphan every ringing client.
|
||||
func CanDelete(active bool) bool {
|
||||
return !active
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 alertstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidStatusAndKind(t *testing.T) {
|
||||
for _, s := range []string{StatusArmed, StatusTriggered, StatusDisarmed} {
|
||||
if !ValidStatus(s) {
|
||||
t.Errorf("ValidStatus(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "ARMED", "firing", "trigger"} {
|
||||
if ValidStatus(s) {
|
||||
t.Errorf("ValidStatus(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{KindTrigger, KindDisarm} {
|
||||
if !ValidKind(k) {
|
||||
t.Errorf("ValidKind(%q) = false", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"", "TRIGGER", "alarm", "stop"} {
|
||||
if ValidKind(k) {
|
||||
t.Errorf("ValidKind(%q) = true, want false", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsActive(t *testing.T) {
|
||||
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
|
||||
earlier := now.Add(-10 * time.Minute)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
at *time.Time
|
||||
auto int
|
||||
want bool
|
||||
}{
|
||||
{"armed is never active", StatusArmed, &earlier, 0, false},
|
||||
{"disarmed is never active", StatusDisarmed, &earlier, 0, false},
|
||||
{"triggered without auto-stop stays active", StatusTriggered, &earlier, 0, true},
|
||||
{"triggered with auto-stop still inside the window", StatusTriggered, &earlier, 3600, true},
|
||||
{"triggered with auto-stop past the window", StatusTriggered, &earlier, 60, false},
|
||||
{"triggered with no timestamp", StatusTriggered, nil, 0, false},
|
||||
{"unknown status", "firing", &earlier, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsActive(tc.status, tc.at, tc.auto, now); got != tc.want {
|
||||
t.Errorf("%s: IsActive = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemainingAutoStopSeconds(t *testing.T) {
|
||||
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
|
||||
at := now.Add(-10 * time.Minute)
|
||||
|
||||
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 3600, now); got != 3000 {
|
||||
t.Errorf("remaining = %d, want 3000", got)
|
||||
}
|
||||
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 60, now); got != 0 {
|
||||
t.Errorf("expired remaining = %d, want 0", got)
|
||||
}
|
||||
if got := RemainingAutoStopSeconds(StatusArmed, &at, 3600, now); got != 0 {
|
||||
t.Errorf("non-triggered remaining = %d, want 0", got)
|
||||
}
|
||||
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 0, now); got != 0 {
|
||||
t.Errorf("auto-stop 0 remaining = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDisarmAndDelete(t *testing.T) {
|
||||
if !CanDisarm(StatusTriggered) {
|
||||
t.Errorf("CanDisarm(triggered) = false")
|
||||
}
|
||||
for _, s := range []string{StatusArmed, StatusDisarmed, ""} {
|
||||
if CanDisarm(s) {
|
||||
t.Errorf("CanDisarm(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
if CanDelete(true) {
|
||||
t.Errorf("CanDelete(active) = true, want false")
|
||||
}
|
||||
if !CanDelete(false) {
|
||||
t.Errorf("CanDelete(inactive) = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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 apierr holds the frozen v1 wire error codes.
|
||||
//
|
||||
// They live in their own package so the HTTP action layer and the WebSocket
|
||||
// layer (which cannot import each other without a cycle) agree on one source of
|
||||
// truth. These strings are part of the published contract: never rename one
|
||||
// without a contract revision.
|
||||
package apierr
|
||||
|
||||
const (
|
||||
AlertNotFound = "ERR_ALERT_NOT_FOUND"
|
||||
AlertTokenInvalid = "ERR_ALERT_TOKEN_INVALID"
|
||||
AlertNotAdmin = "ERR_ALERT_NOT_ADMIN"
|
||||
AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED"
|
||||
AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED"
|
||||
AlertState = "ERR_ALERT_STATE"
|
||||
Validation = "ERR_VALIDATION"
|
||||
Unauthorized = "ERR_UNAUTHORIZED"
|
||||
Internal = "ERR_INTERNAL"
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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 qr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// Render limits. They bound the memory a single unauthenticated-ish request can
|
||||
// allocate (a 64px module on a 57-module matrix is ~13 MB).
|
||||
const (
|
||||
MaxScale = 64
|
||||
MaxQuietZone = 32
|
||||
|
||||
// MinQuietZone is the quiet zone ISO/IEC 18004 requires (4 modules on each
|
||||
// side). Below it a decoder legitimately fails to locate the symbol —
|
||||
// measured: zbarimg exits 4 with "no symbol" at 0..2 modules — so this
|
||||
// package never renders an unscannable PNG.
|
||||
MinQuietZone = 4
|
||||
)
|
||||
|
||||
// ErrBadMatrix reports a matrix that is not a non-empty square.
|
||||
var ErrBadMatrix = errors.New("qr: matrix must be a non-empty square")
|
||||
|
||||
// PNG renders a module matrix as a PNG image. Each module becomes scale×scale
|
||||
// pixels and the image is surrounded by a quietZone-module light border, which
|
||||
// scanners require.
|
||||
func PNG(matrix [][]bool, scale, quietZone int) ([]byte, error) {
|
||||
size := len(matrix)
|
||||
if size == 0 {
|
||||
return nil, ErrBadMatrix
|
||||
}
|
||||
for _, row := range matrix {
|
||||
if len(row) != size {
|
||||
return nil, ErrBadMatrix
|
||||
}
|
||||
}
|
||||
if scale < 1 || scale > MaxScale {
|
||||
return nil, errors.New("qr: scale out of range")
|
||||
}
|
||||
if quietZone < MinQuietZone || quietZone > MaxQuietZone {
|
||||
return nil, errors.New("qr: quiet zone out of range (minimum 4 modules)")
|
||||
}
|
||||
|
||||
side := (size + 2*quietZone) * scale
|
||||
img := image.NewPaletted(image.Rect(0, 0, side, side),
|
||||
color.Palette{color.White, color.Black})
|
||||
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
if !matrix[y][x] {
|
||||
continue
|
||||
}
|
||||
x0 := (x + quietZone) * scale
|
||||
y0 := (y + quietZone) * scale
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
row := img.PixOffset(x0, y0+dy)
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Pix[row+dx] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 qr encodes a string as a QR symbol and renders it as a PNG.
|
||||
//
|
||||
// The symbol is produced by github.com/boombuler/barcode/qr. This package only
|
||||
// fixes the encoding profile the Alarm service needs — error-correction level
|
||||
// M, automatic mode selection, and a row-major [][]bool module matrix (true =
|
||||
// dark, no quiet zone) — and renders that matrix with the quiet zone scanners
|
||||
// require (png.go).
|
||||
//
|
||||
// Why a dependency instead of the in-repo encoder that lived here: that encoder
|
||||
// stored its Reed-Solomon generator polynomial in the wrong coefficient order,
|
||||
// so every ECC block it computed was wrong and no decoder could read its output.
|
||||
// Verified two ways: extracting the codeword stream from a libqrencode reference
|
||||
// symbol (the data codewords matched, the ECC bytes did not, and a generator
|
||||
// derived from its roots reproduced the reference ECC exactly), and zbarimg
|
||||
// returning "no symbol" for its PNGs at every quiet zone. A QR encoder a real
|
||||
// decoder cannot read is worthless, and re-deriving Reed-Solomon, placement and
|
||||
// mask selection is not worth ~690 lines in a service repo. The library is
|
||||
// pinned in go.mod and its output is verified by a zbarimg round-trip in
|
||||
// qr_test.go.
|
||||
package qr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
barcodeqr "github.com/boombuler/barcode/qr"
|
||||
)
|
||||
|
||||
// Encode encodes data as a QR symbol and returns its module matrix.
|
||||
//
|
||||
// The error-correction level is M (~15%) and the encoding mode is chosen
|
||||
// automatically by the library (a URL with lowercase letters and a '#' lands in
|
||||
// byte mode). The returned matrix has no quiet zone; PNG adds it.
|
||||
func Encode(data []byte) ([][]bool, error) {
|
||||
return EncodeString(string(data))
|
||||
}
|
||||
|
||||
// EncodeString encodes s as a QR symbol and returns its module matrix.
|
||||
func EncodeString(s string) ([][]bool, error) {
|
||||
code, err := barcodeqr.Encode(s, barcodeqr.M, barcodeqr.Auto)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qr: encode: %w", err)
|
||||
}
|
||||
return moduleMatrix(code), nil
|
||||
}
|
||||
|
||||
// moduleMatrix converts a one-pixel-per-module barcode image into a row-major
|
||||
// boolean matrix.
|
||||
func moduleMatrix(code barcode.Barcode) [][]bool {
|
||||
b := code.Bounds()
|
||||
matrix := make([][]bool, b.Dy())
|
||||
for y := 0; y < b.Dy(); y++ {
|
||||
row := make([]bool, b.Dx())
|
||||
for x := 0; x < b.Dx(); x++ {
|
||||
gray := color.GrayModel.Convert(code.At(b.Min.X+x, b.Min.Y+y)).(color.Gray)
|
||||
row[x] = gray.Y < 128
|
||||
}
|
||||
matrix[y] = row
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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 qr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// realPayload is the exact string the service puts in a QR code
|
||||
// (https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}).
|
||||
func realPayload() string {
|
||||
return qrpayload.Build(
|
||||
uuid.MustParse("6f1d0c4e-8a3b-4f2e-9c7d-2b5a1e0f9c33"),
|
||||
"Zm9vYmFyYmF6cXV1eGNvcmdlZ3JhdWx0c3R1Yg",
|
||||
)
|
||||
}
|
||||
|
||||
func testPayloads() map[string]string {
|
||||
return map[string]string{
|
||||
"short": "ALARM",
|
||||
"real": realPayload(),
|
||||
"long": strings.Repeat("a", 200),
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeRoundTrip is the test that matters: a real decoder must read
|
||||
// what this package produces. It renders the PNG exactly as the qrImage action
|
||||
// does (scale 8, quiet zone 4) and shells out to zbarimg.
|
||||
//
|
||||
// This test is skipped when zbarimg is not installed. It must never be replaced
|
||||
// by a golden-matrix comparison: the golden-matrix tests it replaces passed
|
||||
// while the previous in-repo encoder emitted symbols no decoder could read.
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
zbarimg, err := exec.LookPath("zbarimg")
|
||||
if err != nil {
|
||||
t.Skip("zbarimg not installed; skipping QR decode round-trip")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
for name, payload := range testPayloads() {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
matrix, err := EncodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeString: %v", err)
|
||||
}
|
||||
pngBytes, err := PNG(matrix, 8, MinQuietZone)
|
||||
if err != nil {
|
||||
t.Fatalf("PNG: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, name+".png")
|
||||
if err := os.WriteFile(path, pngBytes, 0o600); err != nil {
|
||||
t.Fatalf("write png: %v", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(zbarimg, "--raw", "-q", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("zbarimg could not decode the symbol (%v): %s", err, bytes.TrimSpace(out))
|
||||
}
|
||||
got := strings.TrimRight(string(out), "\r\n")
|
||||
if got != payload {
|
||||
t.Fatalf("decoded payload mismatch:\n got: %q\nwant: %q", got, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeMatchesEncodeString pins that the []byte and string entry points
|
||||
// are the same encoding.
|
||||
func TestEncodeMatchesEncodeString(t *testing.T) {
|
||||
payload := realPayload()
|
||||
fromString, err := EncodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeString: %v", err)
|
||||
}
|
||||
fromBytes, err := Encode([]byte(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
if len(fromString) != len(fromBytes) {
|
||||
t.Fatalf("matrix sizes differ: %d vs %d", len(fromString), len(fromBytes))
|
||||
}
|
||||
for y := range fromString {
|
||||
for x := range fromString[y] {
|
||||
if fromString[y][x] != fromBytes[y][x] {
|
||||
t.Fatalf("module (%d,%d) differs between Encode and EncodeString", y, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatrixShape checks the structural invariants of a QR symbol: square,
|
||||
// odd-sized, and carrying the three finder patterns.
|
||||
func TestMatrixShape(t *testing.T) {
|
||||
matrix, err := EncodeString(realPayload())
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeString: %v", err)
|
||||
}
|
||||
size := len(matrix)
|
||||
if size == 0 || size%2 == 0 || (size-17)%4 != 0 {
|
||||
t.Fatalf("not a valid QR size: %d", size)
|
||||
}
|
||||
for y, row := range matrix {
|
||||
if len(row) != size {
|
||||
t.Fatalf("row %d has %d modules, want %d", y, len(row), size)
|
||||
}
|
||||
}
|
||||
for _, origin := range [][2]int{{0, 0}, {0, size - 7}, {size - 7, 0}} {
|
||||
if !hasFinder(matrix, origin[0], origin[1]) {
|
||||
t.Fatalf("missing finder pattern at (%d,%d)", origin[0], origin[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasFinder(matrix [][]bool, r0, c0 int) bool {
|
||||
want := [7][7]bool{
|
||||
{true, true, true, true, true, true, true},
|
||||
{true, false, false, false, false, false, true},
|
||||
{true, false, true, true, true, false, true},
|
||||
{true, false, true, true, true, false, true},
|
||||
{true, false, true, true, true, false, true},
|
||||
{true, false, false, false, false, false, true},
|
||||
{true, true, true, true, true, true, true},
|
||||
}
|
||||
for dy := 0; dy < 7; dy++ {
|
||||
for dx := 0; dx < 7; dx++ {
|
||||
if matrix[r0+dy][c0+dx] != want[dy][dx] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestPNGGeometry checks the output format and the quiet-zone floor.
|
||||
func TestPNGGeometry(t *testing.T) {
|
||||
matrix, err := EncodeString(realPayload())
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeString: %v", err)
|
||||
}
|
||||
const scale, quiet = 8, 4
|
||||
pngBytes, err := PNG(matrix, scale, quiet)
|
||||
if err != nil {
|
||||
t.Fatalf("PNG: %v", err)
|
||||
}
|
||||
signature := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
if !bytes.HasPrefix(pngBytes, signature) {
|
||||
t.Fatal("output is not a PNG")
|
||||
}
|
||||
|
||||
for _, quietZone := range []int{0, 1, 3} {
|
||||
if _, err := PNG(matrix, scale, quietZone); err == nil {
|
||||
t.Fatalf("PNG accepted quiet zone %d below the 4-module minimum", quietZone)
|
||||
}
|
||||
}
|
||||
if _, err := PNG(matrix, 0, quiet); err == nil {
|
||||
t.Fatal("PNG accepted scale 0")
|
||||
}
|
||||
if _, err := PNG(nil, scale, quiet); err == nil {
|
||||
t.Fatal("PNG accepted an empty matrix")
|
||||
}
|
||||
if _, err := PNG([][]bool{{true, false}}, scale, quiet); err == nil {
|
||||
t.Fatal("PNG accepted a non-square matrix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeRejectsOversized pins that content which cannot fit in any QR
|
||||
// version surfaces as an error instead of a truncated symbol.
|
||||
func TestEncodeRejectsOversized(t *testing.T) {
|
||||
if _, err := EncodeString(strings.Repeat("x", 4000)); err == nil {
|
||||
t.Fatal("expected an error for content beyond QR capacity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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 qrpayload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestBuildParseRoundTrip(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
token := "AbCdEf0123456789_-xyzABC"
|
||||
raw := Build(alertID, token)
|
||||
if !strings.HasPrefix(raw, "https://"+PublicHost+PathPrefix) {
|
||||
t.Fatalf("Build = %q, want the canonical https form", raw)
|
||||
}
|
||||
gotID, gotToken, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(Build(...)): %v", err)
|
||||
}
|
||||
if gotID != alertID {
|
||||
t.Fatalf("alert id = %s, want %s", gotID, alertID)
|
||||
}
|
||||
if gotToken != token {
|
||||
t.Fatalf("token = %q, want %q", gotToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemeParseRoundTrip(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
token := "AbCdEf0123456789_-xyzABC"
|
||||
raw := BuildScheme(alertID, token)
|
||||
if !strings.HasPrefix(raw, Scheme+"://"+SchemeHost+"?") {
|
||||
t.Fatalf("BuildScheme = %q, want the app-scheme form", raw)
|
||||
}
|
||||
gotID, gotToken, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(BuildScheme(...)): %v", err)
|
||||
}
|
||||
if gotID != alertID || gotToken != token {
|
||||
t.Fatalf("round-trip = (%s, %q), want (%s, %q)", gotID, gotToken, alertID, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformed(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
cases := []string{
|
||||
"",
|
||||
" ",
|
||||
"not a url at all",
|
||||
"ftp://" + PublicHost + PathPrefix + alertID.String() + "#token",
|
||||
"https://" + PublicHost + PathPrefix + alertID.String(), // no fragment
|
||||
"https://" + PublicHost + "/b/" + alertID.String() + "#token",
|
||||
"https://" + PublicHost + PathPrefix + "not-a-uuid#token",
|
||||
"https://" + PublicHost + PathPrefix + alertID.String() + "#",
|
||||
"https://" + PublicHost + PathPrefix + alertID.String() + "#short",
|
||||
"dcntrlized-alarm://join?alert=nope&token=AbCdEf0123456789",
|
||||
"dcntrlized-alarm://join?token=AbCdEf0123456789",
|
||||
}
|
||||
for _, in := range cases {
|
||||
if _, _, err := Parse(in); err == nil {
|
||||
t.Errorf("Parse(%q) succeeded, want an error", in)
|
||||
} else if !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Errorf("Parse(%q) error = %v, want it to wrap ErrInvalidPayload", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrimsSurroundingSpace(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
token := "AbCdEf0123456789_-xyzABC"
|
||||
gotID, gotToken, err := Parse(" " + Build(alertID, token) + "\n")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse with surrounding space: %v", err)
|
||||
}
|
||||
if gotID != alertID || gotToken != token {
|
||||
t.Fatalf("round-trip = (%s, %q)", gotID, gotToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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 qrpayload is the single source of truth for the alarm QR payload.
|
||||
//
|
||||
// The frozen v1 wire form is
|
||||
//
|
||||
// https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||
//
|
||||
// The fragment (not the query string) carries the token so the secret is never
|
||||
// sent to the host if a scanner ever resolves the URL in a browser. Build is
|
||||
// used by createAlert/getAlert/rotateSubscribeToken, and Parse is used by any
|
||||
// component that accepts a scanned payload (the Android client's scanner and
|
||||
// the service's own tests).
|
||||
//
|
||||
// Parse additionally accepts the app-scheme form
|
||||
//
|
||||
// dcntrlized-alarm://join?alert={alertId}&token={subscribeToken}
|
||||
//
|
||||
// which the Android client can register as a deep link, and it is deliberately
|
||||
// lenient about the https host: the host grants nothing, the alert id and the
|
||||
// subscribe token are the only things that matter, and a self-hosted instance
|
||||
// may legitimately hand out its own hostname.
|
||||
package qrpayload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// PublicHost is the canonical host in the QR payload.
|
||||
PublicHost = "alarm.dcntrlized.org"
|
||||
// PathPrefix is the canonical path prefix ("/a/" + alert id).
|
||||
PathPrefix = "/a/"
|
||||
// Scheme is the custom deep-link scheme.
|
||||
Scheme = "dcntrlized-alarm"
|
||||
// SchemeHost is the custom deep-link host ("join").
|
||||
SchemeHost = "join"
|
||||
)
|
||||
|
||||
// ErrInvalidPayload is returned by Parse for anything that is not a well-formed
|
||||
// alarm payload. Callers answer ERR_VALIDATION.
|
||||
var ErrInvalidPayload = errors.New("qrpayload: invalid alarm payload")
|
||||
|
||||
// Build renders the canonical QR payload for an alert.
|
||||
func Build(alertID uuid.UUID, subscribeToken string) string {
|
||||
return "https://" + PublicHost + PathPrefix + alertID.String() + "#" + subscribeToken
|
||||
}
|
||||
|
||||
// BuildScheme renders the app-scheme deep link for the same alert.
|
||||
func BuildScheme(alertID uuid.UUID, subscribeToken string) string {
|
||||
return Scheme + "://" + SchemeHost + "?alert=" + alertID.String() + "&token=" + url.QueryEscape(subscribeToken)
|
||||
}
|
||||
|
||||
// Parse extracts the alert id and subscribe token from a scanned payload. It
|
||||
// accepts the canonical https form and the app-scheme form.
|
||||
func Parse(raw string) (uuid.UUID, string, error) {
|
||||
var zero uuid.UUID
|
||||
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return zero, "", fmt.Errorf("%w: empty payload", ErrInvalidPayload)
|
||||
}
|
||||
// A scanner may hand back the payload with the fragment stripped (some
|
||||
// scanners normalise URLs); the canonical form always has a non-empty
|
||||
// fragment, so an empty one is a hard error below.
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return zero, "", fmt.Errorf("%w: unparseable url", ErrInvalidPayload)
|
||||
}
|
||||
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https", "http":
|
||||
return parseWeb(u)
|
||||
case Scheme:
|
||||
return parseScheme(u)
|
||||
default:
|
||||
return zero, "", fmt.Errorf("%w: unsupported scheme %q", ErrInvalidPayload, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func parseWeb(u *url.URL) (uuid.UUID, string, error) {
|
||||
var zero uuid.UUID
|
||||
|
||||
if u.Host == "" {
|
||||
return zero, "", fmt.Errorf("%w: missing host", ErrInvalidPayload)
|
||||
}
|
||||
path := u.EscapedPath()
|
||||
if !strings.HasPrefix(path, PathPrefix) {
|
||||
return zero, "", fmt.Errorf("%w: path must start with %q", ErrInvalidPayload, PathPrefix)
|
||||
}
|
||||
rawID := strings.TrimPrefix(path, PathPrefix)
|
||||
// The token rides the fragment; reject anything with a trailing path
|
||||
// segment or a query string in the id slot.
|
||||
if rawID == "" || strings.ContainsAny(rawID, "/?#") {
|
||||
return zero, "", fmt.Errorf("%w: malformed alert id", ErrInvalidPayload)
|
||||
}
|
||||
alertID, err := uuid.Parse(rawID)
|
||||
if err != nil {
|
||||
return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload)
|
||||
}
|
||||
token := u.Fragment
|
||||
if err := validateToken(token); err != nil {
|
||||
return zero, "", err
|
||||
}
|
||||
return alertID, token, nil
|
||||
}
|
||||
|
||||
func parseScheme(u *url.URL) (uuid.UUID, string, error) {
|
||||
var zero uuid.UUID
|
||||
|
||||
if !strings.EqualFold(u.Host, SchemeHost) {
|
||||
return zero, "", fmt.Errorf("%w: scheme host must be %q", ErrInvalidPayload, SchemeHost)
|
||||
}
|
||||
q := u.Query()
|
||||
alertID, err := uuid.Parse(q.Get("alert"))
|
||||
if err != nil {
|
||||
return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload)
|
||||
}
|
||||
token := q.Get("token")
|
||||
if err := validateToken(token); err != nil {
|
||||
return zero, "", err
|
||||
}
|
||||
return alertID, token, nil
|
||||
}
|
||||
|
||||
func validateToken(token string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("%w: missing subscribe token", ErrInvalidPayload)
|
||||
}
|
||||
if !alarmtoken.ValidSubscribeTokenShape(token) {
|
||||
return fmt.Errorf("%w: malformed subscribe token", ErrInvalidPayload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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 logger is the service-wide leveled logger (house pattern: KeepNote,
|
||||
// Calendar). Info/Warn/Debug go to stdout (plus a file under logs/ when the
|
||||
// working directory is writable); Error goes to stderr and resolves the true
|
||||
// call site even through registered log-helper wrappers.
|
||||
//
|
||||
// It never logs secrets on its own: callers must not pass admin tokens,
|
||||
// subscribe tokens or credentials into a format string.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
infoLogger *log.Logger
|
||||
warnLogger *log.Logger
|
||||
errorLogger *log.Logger
|
||||
debugLogger *log.Logger
|
||||
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// helperFuncs tracks log entry points and any registered wrapper functions
|
||||
// (e.g. git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug) so callerDepth
|
||||
// can skip them and report the true call site.
|
||||
var helperFuncs = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, name := range []string{
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Info",
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Warn",
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Error",
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Debug",
|
||||
} {
|
||||
helperFuncs[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
func initLogger() {
|
||||
if err := os.MkdirAll("logs", 0755); err != nil {
|
||||
log.Printf("Failed to create logs directory: %v. Falling back to standard output.", err)
|
||||
}
|
||||
|
||||
// 1. Setup Info Logger
|
||||
var infoOutput io.Writer = os.Stdout
|
||||
if infoFile, err := os.OpenFile("logs/info.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
|
||||
infoOutput = io.MultiWriter(os.Stdout, infoFile)
|
||||
writeHeader(infoFile)
|
||||
}
|
||||
infoLogger = log.New(infoOutput, "INFO: ", log.Ldate|log.Ltime|log.LUTC)
|
||||
|
||||
// 2. Setup Warn Logger
|
||||
var warnOutput io.Writer = os.Stdout
|
||||
if warnFile, err := os.OpenFile("logs/warn.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
|
||||
warnOutput = io.MultiWriter(os.Stdout, warnFile)
|
||||
writeHeader(warnFile)
|
||||
}
|
||||
warnLogger = log.New(warnOutput, "WARN: ", log.Ldate|log.Ltime|log.LUTC)
|
||||
|
||||
// 3. Setup Error Logger
|
||||
var errorOutput io.Writer = os.Stderr
|
||||
if errorFile, err := os.OpenFile("logs/error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
|
||||
errorOutput = io.MultiWriter(os.Stderr, errorFile)
|
||||
writeHeader(errorFile)
|
||||
}
|
||||
// The error logger does not rely on Lshortfile; Error resolves the true
|
||||
// call site itself via callSite, so line numbers stay correct even through
|
||||
// log helper wrappers.
|
||||
errorLogger = log.New(errorOutput, "ERROR: ", log.Ldate|log.Ltime|log.LUTC)
|
||||
|
||||
// 4. Setup Debug Logger
|
||||
var debugOutput io.Writer = os.Stdout
|
||||
if debugFile, err := os.OpenFile("logs/debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
|
||||
debugOutput = io.MultiWriter(os.Stdout, debugFile)
|
||||
writeHeader(debugFile)
|
||||
}
|
||||
debugLogger = log.New(debugOutput, "DEBUG: ", log.Ldate|log.Ltime|log.LUTC)
|
||||
}
|
||||
|
||||
func writeHeader(file *os.File) {
|
||||
file.WriteString("========== Start Log ==========\n")
|
||||
file.WriteString("[INFO] Logger initialized\n")
|
||||
file.WriteString("[" + time.Now().Format(time.RFC3339) + "]\n")
|
||||
}
|
||||
|
||||
// RegisterLogHelper registers a wrapper function around this package's log
|
||||
// functions so that call sites logged through it point at the true caller
|
||||
// instead of the wrapper. Pass the fully qualified function name, e.g.
|
||||
// "git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug".
|
||||
func RegisterLogHelper(fullFuncName string) {
|
||||
helperFuncs[fullFuncName] = true
|
||||
}
|
||||
|
||||
func isLogHelper(name string) bool {
|
||||
return helperFuncs[name]
|
||||
}
|
||||
|
||||
// callSite walks the call stack, skipping this package's entry points and any
|
||||
// registered log helpers, and returns the file:line of the true call site.
|
||||
//
|
||||
//go:noinline
|
||||
func callSite() (string, int) {
|
||||
pcs := make([]uintptr, 64)
|
||||
n := runtime.Callers(2, pcs)
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !more {
|
||||
return "???", 0
|
||||
}
|
||||
if isLogHelper(frame.Function) {
|
||||
continue
|
||||
}
|
||||
return frame.File, frame.Line
|
||||
}
|
||||
}
|
||||
|
||||
// Info accepts a format string and an optional, arbitrary number of arguments.
|
||||
func Info(format string, v ...any) {
|
||||
once.Do(initLogger)
|
||||
infoLogger.Printf(format, v...)
|
||||
}
|
||||
|
||||
// Warn accepts a format string and an optional, arbitrary number of arguments.
|
||||
func Warn(format string, v ...any) {
|
||||
once.Do(initLogger)
|
||||
warnLogger.Printf(format, v...)
|
||||
}
|
||||
|
||||
// Error accepts a format string and an optional, arbitrary number of arguments.
|
||||
// The reported file:line points at the true call site, even through registered
|
||||
// log helper wrappers.
|
||||
//
|
||||
//go:noinline
|
||||
func Error(format string, v ...any) {
|
||||
once.Do(initLogger)
|
||||
file, line := callSite()
|
||||
errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...)))
|
||||
}
|
||||
|
||||
// Debug accepts a format string and an optional, arbitrary number of arguments.
|
||||
func Debug(format string, v ...any) {
|
||||
once.Do(initLogger)
|
||||
debugLogger.Printf(format, v...)
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
// 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/>.
|
||||
|
||||
// Command alarm is the Dcntrlized alarm/alert service. It owns alerts, their
|
||||
// admin/subscribe tokens and the live trigger fan-out; identity comes from the
|
||||
// shared dcntrlized-auth service and storage from the shared dcntrlized-db.
|
||||
//
|
||||
// API shape (frozen v1 contract): GET /_manifest, the raw /auth/* proxies,
|
||||
// POST/GET /action/<name> ({"payload": …} envelope, X-Access-Token required)
|
||||
// and the GET /ws/alarm WebSocket. Everything rides ONE plaintext :8080
|
||||
// listener (contract rev 9 §B): no second socket, no TLS, no /config edge.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/actions"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/auth"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/manifest"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/middleware"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
|
||||
)
|
||||
|
||||
// servicePort is the service's ONLY listening port. The API, the raw auth
|
||||
// proxies and the /ws/alarm WebSocket all share it.
|
||||
const servicePort = "8080"
|
||||
|
||||
func main() {
|
||||
// Root context cancelled on SIGINT/SIGTERM. The realtime hub (task-4) is
|
||||
// owned by it, so a deploy drains subscribers instead of cutting them off.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Shared-auth consumer: authclient validates RS256 access tokens against
|
||||
// the published key; authproxy.Target() resolves AUTH_SERVICE_URL
|
||||
// (http://dcntrlized-auth:8080 on dcntrlized_net).
|
||||
if err := authclient.Init(context.Background(), authproxy.Target()); err != nil {
|
||||
logger.Error("failed to initialize auth client: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("Alarm service starting")
|
||||
|
||||
// The HTTP surface is built by newAPIMux/newOuterMux so main_test.go can
|
||||
// construct exactly the same wiring and fail the gate on a route conflict:
|
||||
// http.ServeMux panics on a duplicate pattern at registration time, which
|
||||
// build/vet/gofmt cannot see.
|
||||
apiMux := newAPIMux()
|
||||
outer := newOuterMux(apiMux)
|
||||
|
||||
// --- task-2: the shared database pool ---
|
||||
// Created here, not lazily on first request: newDB() pings the shared
|
||||
// database for up to 30s to tolerate container start ordering, and that
|
||||
// wait must not happen inside a request.
|
||||
database := db.NewDB()
|
||||
defer db.Close()
|
||||
|
||||
// --- task-4: the realtime layer ---
|
||||
// Registers GET /ws/alarm on the outer mux (outside the logging/CORS
|
||||
// chain), starts the hub + the DB notify bridge, and returns the drain func
|
||||
// used in the shutdown path below.
|
||||
realtimeShutdown := realtime.Register(outer, database)
|
||||
|
||||
// ONE plaintext listener (contract rev 9 §B). ReadHeaderTimeout/IdleTimeout
|
||||
// cover slow-header and idle-socket abuse. ReadTimeout/WriteTimeout are
|
||||
// deliberately ZERO: this listener also serves the long-lived /ws/alarm
|
||||
// WebSocket, and a server-level read/write deadline is applied to the
|
||||
// hijacked connection, which would tear live alarms down mid-stream. The
|
||||
// WS layer owns its per-message deadlines (ping/pong + SetReadDeadline /
|
||||
// SetWriteDeadline) instead.
|
||||
server := &http.Server{
|
||||
Handler: outer,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", ":"+servicePort)
|
||||
if err != nil {
|
||||
logger.Error("listen failed on :%s: %v", servicePort, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
logger.Info("Alarm listening plaintext on :%s (single port: API + /ws/alarm)", servicePort)
|
||||
go func() { serveErr <- server.Serve(ln) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("signal received, shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
realtimeShutdown(shutdownCtx) // drains live subscribers before the HTTP server closes
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
case err := <-serveErr:
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTP server failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newAPIMux builds the API surface reached through the API chain
|
||||
// (LoggingMiddleware(CorsMiddleware(apiMux))). The logging middleware
|
||||
// deliberately does NOT log request bodies or query strings: every /action
|
||||
// payload carries an adminToken or a subscribeToken (frozen contract) and those
|
||||
// must never land in logs.
|
||||
//
|
||||
// House pattern (see Calendar/src/main.go): exactly ONE registration per path,
|
||||
// with the handler dispatching on method. Registering the same pattern twice
|
||||
// panics at startup, so /auth/logout — which serves GET (bodyless no-op) and
|
||||
// POST (revoke the refresh token) — is a single method-aware registration.
|
||||
func newAPIMux() *http.ServeMux {
|
||||
apiMux := http.NewServeMux()
|
||||
|
||||
apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
manifest.ManifestHandler(w, r)
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth.AuthDeletePost(w, r, map[string]string{})
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth.AuthLoginPost(w, r, map[string]string{})
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
auth.AuthLogoutGet(w, r, map[string]string{})
|
||||
case http.MethodPost:
|
||||
auth.AuthLogoutPost(w, r, map[string]string{})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/me", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth.AuthMeGet(w, r, map[string]string{})
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth.AuthRefreshPost(w, r, map[string]string{})
|
||||
})
|
||||
|
||||
apiMux.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth.AuthRegisterPost(w, r, map[string]string{})
|
||||
})
|
||||
|
||||
// /action/<name> dispatch is by HTTP method (GET/POST/PUT/DELETE); the
|
||||
// switch contents live in handlers/actions (task-2 scope).
|
||||
apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/action/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/action/")
|
||||
params := map[string]string{"actionName": rest}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
actions.ActionActionNameDelete(w, r, params)
|
||||
return
|
||||
case http.MethodGet:
|
||||
actions.ActionActionNameGet(w, r, params)
|
||||
return
|
||||
case http.MethodPost:
|
||||
actions.ActionActionNamePost(w, r, params)
|
||||
return
|
||||
case http.MethodPut:
|
||||
actions.ActionActionNamePut(w, r, params)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return apiMux
|
||||
}
|
||||
|
||||
// newOuterMux builds the single-listener mux. The API prefixes ride the API
|
||||
// chain; /ws/alarm is registered separately by the realtime package (task-4)
|
||||
// because a WebSocket upgrade must not be touched by the logging/CORS chain.
|
||||
func newOuterMux(apiMux *http.ServeMux) *http.ServeMux {
|
||||
outer := http.NewServeMux()
|
||||
|
||||
apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux))
|
||||
outer.Handle("/_manifest", apiChain)
|
||||
outer.Handle("/auth/", apiChain)
|
||||
outer.Handle("/action/", apiChain)
|
||||
|
||||
return outer
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRoutesConstruct builds exactly the wiring main() builds and fails on a
|
||||
// panic. http.ServeMux panics at registration time when the same pattern is
|
||||
// registered twice, which kills the process before it binds :8080 — and
|
||||
// go build/vet/gofmt/go test cannot see it unless a test constructs the mux.
|
||||
// This test exists because a duplicate /auth/logout registration shipped and
|
||||
// only a real launch caught it.
|
||||
func TestRoutesConstruct(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("route registration panicked — the service would not boot: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
apiMux := newAPIMux()
|
||||
if apiMux == nil {
|
||||
t.Fatal("newAPIMux returned nil")
|
||||
}
|
||||
outer := newOuterMux(apiMux)
|
||||
if outer == nil {
|
||||
t.Fatal("newOuterMux returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSocketPatternIsFree proves main.go leaves /ws/alarm to the realtime
|
||||
// layer: registering it on the outer mux must not conflict with anything
|
||||
// main.go registered (realtime.Register does exactly this at boot).
|
||||
func TestWebSocketPatternIsFree(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("/ws/alarm conflicts with a pattern registered by main.go: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
outer := newOuterMux(newAPIMux())
|
||||
outer.Handle("/ws/alarm", http.NotFoundHandler())
|
||||
}
|
||||
|
||||
// TestManifestThroughOuterChain serves GET /_manifest through the real outer
|
||||
// mux (logging + CORS chain included), which is what a caller hits.
|
||||
func TestManifestThroughOuterChain(t *testing.T) {
|
||||
outer := newOuterMux(newAPIMux())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /_manifest = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("GET /_manifest Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, `"name": "Alarm"`) && !strings.Contains(body, `"name":"Alarm"`) {
|
||||
t.Fatalf("GET /_manifest body does not name the service: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutServesBothMethods pins the method-aware registration: GET (bodyless
|
||||
// no-op) and POST (revoke the refresh token) must both be routed, and any other
|
||||
// verb must be rejected with 405. The handler proxies to dcntrlized-auth, so the
|
||||
// requests carry a short deadline: the assertion is about routing, and the test
|
||||
// must not depend on (or wait for) a live auth service.
|
||||
func TestLogoutServesBothMethods(t *testing.T) {
|
||||
outer := newOuterMux(newAPIMux())
|
||||
|
||||
do := func(method string) int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
req := httptest.NewRequest(method, "/auth/logout", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
outer.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||
if code := do(method); code == http.StatusMethodNotAllowed || code == http.StatusNotFound {
|
||||
t.Fatalf("%s /auth/logout = %d, want the method to be routed", method, code)
|
||||
}
|
||||
}
|
||||
|
||||
if code := do(http.MethodPut); code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("PUT /auth/logout = %d, want 405", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CorsMiddleware is the house-pattern CORS shim for the RN clients (no cookie
|
||||
// auth anywhere in the fleet, so echoing the Origin and allowing
|
||||
// X-Access-Token is safe). It answers preflights directly.
|
||||
func CorsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Dynamically echo the origin sent by the browser
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
// Note: Match the exact casing the browser requested, just to be safe
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, X-Access-Token")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
)
|
||||
|
||||
// LoggingMiddleware logs one line per API request: method, path, status,
|
||||
// duration, user agent and client IP.
|
||||
//
|
||||
// It deliberately deviates from the Calendar/KeepNote house shim in ONE way:
|
||||
// it never logs the request body or the query string. In Alarm every
|
||||
// /action payload carries an adminToken or a subscribeToken, and the
|
||||
// /ws/alarm upgrade carries the subscribe token in its query string, so the
|
||||
// frozen contract's "adminToken is stored only as SHA-256" promise would be
|
||||
// broken by writing them to logs/.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
logger.Info("%s request=%s %s path=%s status=%d ua=%q ip=%s duration=%s",
|
||||
time.Now().Format(time.RFC3339),
|
||||
r.Method,
|
||||
r.Proto,
|
||||
r.URL.Path,
|
||||
rec.status,
|
||||
r.UserAgent(),
|
||||
ip,
|
||||
time.Since(start),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the response status without altering the response.
|
||||
// It preserves http.Hijacker so the /ws/alarm upgrade still works when the
|
||||
// route is mounted behind this middleware.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if !s.wroteHeader {
|
||||
s.status = code
|
||||
s.wroteHeader = true
|
||||
}
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !s.wroteHeader {
|
||||
s.wroteHeader = true
|
||||
}
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Hijack forwards to the wrapped ResponseWriter so a WebSocket upgrade can
|
||||
// take over the connection.
|
||||
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hj, ok := s.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
// Flush forwards to the wrapped ResponseWriter when it supports flushing.
|
||||
func (s *statusRecorder) Flush() {
|
||||
if f, ok := s.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// authenticateWS resolves a GET /ws/alarm upgrade request:
|
||||
//
|
||||
// ?alertId=<uuid> required
|
||||
// &token=<secret> required; the alert's admin token OR its subscribe token
|
||||
// &deviceId=<id> optional; picks which device row is kept alive
|
||||
//
|
||||
// The caller's identity always comes from a verified X-Access-Token header. The
|
||||
// access token may also be supplied as ?accessToken= / ?access_token= for
|
||||
// clients (and debugging tools) that cannot set headers on the upgrade; that is
|
||||
// a convenience, not a second trust path — the token is verified identically.
|
||||
//
|
||||
// A subscribe token authorises only a user who actually has a subscription on
|
||||
// the alert, so knowing the shared token is not enough on its own. The admin
|
||||
// token is only honoured for the user the alert was created by.
|
||||
func authenticateWS(r *http.Request) (*Identity, error) {
|
||||
access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
|
||||
if access == "" {
|
||||
access = strings.TrimSpace(r.URL.Query().Get("accessToken"))
|
||||
}
|
||||
if access == "" {
|
||||
access = strings.TrimSpace(r.URL.Query().Get("access_token"))
|
||||
}
|
||||
if access == "" {
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "missing access token",
|
||||
}
|
||||
}
|
||||
|
||||
authReq := r.Clone(r.Context())
|
||||
authReq.Header.Set("X-Access-Token", access)
|
||||
p, err := authclient.Authorize(authReq)
|
||||
if err != nil {
|
||||
logger.Warn("ws: access token rejected: %v", err)
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid access token",
|
||||
}
|
||||
}
|
||||
|
||||
alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId")))
|
||||
if err != nil {
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required",
|
||||
}
|
||||
}
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required",
|
||||
}
|
||||
}
|
||||
|
||||
alert, err := db.GetAlert(r.Context(), alertID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found",
|
||||
}
|
||||
}
|
||||
logger.Error("ws: alert lookup failed: %v", err)
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
|
||||
}
|
||||
}
|
||||
|
||||
// Admin path: the alert's own admin token, presented by the alert's admin.
|
||||
if alarmtoken.EqualHash(alert.AdminTokenHash, token) {
|
||||
if alert.AdminUserID != p.ID {
|
||||
logger.Warn("ws: user=%s presented the admin token of alert=%s without owning it", p.ID, alert.ID)
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusForbidden, Code: apierr.AlertNotAdmin, Message: "caller is not the alert admin",
|
||||
}
|
||||
}
|
||||
return &Identity{AlertID: alert.ID, UserID: p.ID, IsAdmin: true}, nil
|
||||
}
|
||||
|
||||
// Subscriber path: the shared subscribe token plus a real subscription.
|
||||
if !alarmtoken.EqualToken(alert.SubscribeToken, token) {
|
||||
logger.Warn("ws: token mismatch alert=%s user=%s", alert.ID, p.ID)
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token",
|
||||
}
|
||||
}
|
||||
sub, err := resolveSubscription(r, alert.ID, p.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "no subscription for this user on the alert",
|
||||
}
|
||||
}
|
||||
logger.Error("ws: subscription lookup failed: %v", err)
|
||||
return nil, &AuthError{
|
||||
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
|
||||
}
|
||||
}
|
||||
return &Identity{
|
||||
AlertID: alert.ID,
|
||||
UserID: p.ID,
|
||||
SubscriptionID: &sub.ID,
|
||||
DeviceID: sub.DeviceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSubscription picks the device row a connection keeps alive: the one
|
||||
// named by ?deviceId= when given, otherwise the user's oldest subscription on
|
||||
// the alert.
|
||||
func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) {
|
||||
if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" {
|
||||
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceID)
|
||||
}
|
||||
ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
return db.GetSubscription(r.Context(), alertID, ids[0])
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// client is one WebSocket connection. Reads and writes are owned by two
|
||||
// separate goroutines (readPump/writePump); the only shared state is the
|
||||
// buffered send channel and the close-once guard.
|
||||
type client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
identity Identity
|
||||
send chan []byte
|
||||
done chan struct{}
|
||||
|
||||
closeOnce sync.Once
|
||||
|
||||
// ackMu guards acks; the counter is read by tests and by hub.Acks.
|
||||
ackMu sync.Mutex
|
||||
acks int
|
||||
}
|
||||
|
||||
func newClient(h *Hub, conn *websocket.Conn, identity Identity) *client {
|
||||
return &client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
identity: identity,
|
||||
send: make(chan []byte, h.opts.SendBuffer),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// trySend queues a frame without ever blocking. It reports false when the
|
||||
// client's buffer is full (the caller then drops the client).
|
||||
func (c *client) trySend(payload []byte) bool {
|
||||
select {
|
||||
case c.send <- payload:
|
||||
return true
|
||||
case <-c.done:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// close signals both pumps to stop. It is idempotent.
|
||||
func (c *client) close() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) touch() {
|
||||
c.hub.touch(c)
|
||||
}
|
||||
|
||||
// readPump owns all reads. The read deadline is refreshed by pongs, so a dead
|
||||
// peer is detected within PongWait.
|
||||
func (c *client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister(c)
|
||||
c.close()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(c.hub.opts.MaxFrameBytes)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait))
|
||||
c.touch()
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var f ClientFrame
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
// A malformed client frame is ignored, never fatal.
|
||||
continue
|
||||
}
|
||||
switch f.Type {
|
||||
case "ack":
|
||||
c.ackMu.Lock()
|
||||
c.acks++
|
||||
c.ackMu.Unlock()
|
||||
c.hub.recordAck(f.EventID)
|
||||
case "ping":
|
||||
c.touch()
|
||||
// Answer at the protocol level: the frozen contract defines no
|
||||
// JSON pong frame.
|
||||
_ = c.conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(c.hub.opts.WriteWait))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump owns all writes except the WriteControl calls in readPump (which
|
||||
// gorilla explicitly allows concurrently).
|
||||
func (c *client) writePump() {
|
||||
ticker := time.NewTicker(c.hub.opts.PingInterval)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.close()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case payload := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
c.touch()
|
||||
case <-c.done:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ackCount reports how many ack frames this connection sent.
|
||||
func (c *client) ackCount() int {
|
||||
c.ackMu.Lock()
|
||||
defer c.ackMu.Unlock()
|
||||
return c.acks
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// 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 realtime is the Alarm service's live delivery path: a WebSocket hub
|
||||
// with one buffered send channel per client, an in-memory last-event cache for
|
||||
// reconnect catch-up, and a PostgreSQL LISTEN/NOTIFY bridge so a trigger or
|
||||
// disarm started on one service instance reaches subscribers connected to any
|
||||
// other instance.
|
||||
//
|
||||
// Design rules (frozen contract v1):
|
||||
//
|
||||
// - The hub NEVER blocks: Broadcast does a non-blocking channel send and
|
||||
// drops a client whose buffer is full (slow client), rather than stalling
|
||||
// the other subscribers.
|
||||
// - All deadlines live in this layer, not in http.Server: the service sets
|
||||
// Read/WriteTimeout=0 because a non-zero server timeout kills a hijacked
|
||||
// WebSocket connection. Each connection gets its own read deadline
|
||||
// (PongWait), per-message write deadline (WriteWait) and server pings
|
||||
// (PingInterval).
|
||||
// - De-duplication is by event id: the in-process fast path and the NOTIFY
|
||||
// bridge both deliver the same event, and the second one is dropped.
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Defaults for the keepalive envelope.
|
||||
const (
|
||||
DefaultPingInterval = 20 * time.Second
|
||||
DefaultPongWait = 60 * time.Second
|
||||
DefaultWriteWait = 10 * time.Second
|
||||
DefaultSendBuffer = 16
|
||||
DefaultMaxFrameSize = 4096
|
||||
|
||||
// ackCap bounds the per-event ack counters kept for observability.
|
||||
ackCap = 256
|
||||
)
|
||||
|
||||
// AlarmFrame is the server -> client alarm frame.
|
||||
type AlarmFrame struct {
|
||||
Type string `json:"type"` // always "alarm"
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"` // "trigger" | "disarm"
|
||||
Message string `json:"message"`
|
||||
ServerTime string `json:"serverTime"` // RFC3339, UTC
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
}
|
||||
|
||||
// AckFrame is the server -> client acknowledgement of a client ack.
|
||||
type AckFrame struct {
|
||||
Type string `json:"type"` // always "ack"
|
||||
EventID string `json:"eventId"`
|
||||
}
|
||||
|
||||
// ClientFrame is the client -> server frame. `ping` is a keepalive; the server
|
||||
// answers it with a WebSocket pong control frame (the contract defines no JSON
|
||||
// pong) and refreshes subscriptions.last_seen_at.
|
||||
type ClientFrame struct {
|
||||
Type string `json:"type"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
}
|
||||
|
||||
// Identity is the authenticated WebSocket caller.
|
||||
type Identity struct {
|
||||
AlertID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
IsAdmin bool
|
||||
SubscriptionID *uuid.UUID
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
// AuthError lets the authentication callback choose the pre-upgrade HTTP
|
||||
// status and wire error code. Any other error is reported as 401
|
||||
// ERR_UNAUTHORIZED.
|
||||
type AuthError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return fmt.Sprintf("realtime: auth rejected (%d %s): %s", e.Status, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Options configures a Hub. Authenticate is required; the rest default.
|
||||
type Options struct {
|
||||
// Authenticate resolves the caller from the upgrade request (query params
|
||||
// plus headers). Returning an error refuses the upgrade.
|
||||
Authenticate func(r *http.Request) (*Identity, error)
|
||||
// Touch refreshes subscriptions.last_seen_at. Optional.
|
||||
Touch func(ctx context.Context, subscriptionID uuid.UUID) error
|
||||
|
||||
PingInterval time.Duration
|
||||
PongWait time.Duration
|
||||
WriteWait time.Duration
|
||||
SendBuffer int
|
||||
MaxFrameBytes int64
|
||||
Logf func(format string, v ...any)
|
||||
}
|
||||
|
||||
func (o Options) withDefaults() Options {
|
||||
if o.PingInterval <= 0 {
|
||||
o.PingInterval = DefaultPingInterval
|
||||
}
|
||||
if o.PongWait <= 0 {
|
||||
o.PongWait = DefaultPongWait
|
||||
}
|
||||
if o.WriteWait <= 0 {
|
||||
o.WriteWait = DefaultWriteWait
|
||||
}
|
||||
if o.SendBuffer <= 0 {
|
||||
o.SendBuffer = DefaultSendBuffer
|
||||
}
|
||||
if o.MaxFrameBytes <= 0 {
|
||||
o.MaxFrameBytes = DefaultMaxFrameSize
|
||||
}
|
||||
if o.Logf == nil {
|
||||
o.Logf = log.Printf
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
type lastEvent struct {
|
||||
frame AlarmFrame
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (e lastEvent) active(now time.Time) bool {
|
||||
if e.frame.Action != alertstate.KindTrigger {
|
||||
return false
|
||||
}
|
||||
if e.frame.AutoStopSeconds <= 0 {
|
||||
return true
|
||||
}
|
||||
return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second))
|
||||
}
|
||||
|
||||
// Hub fans alarm frames out to every WebSocket client of an alert.
|
||||
type Hub struct {
|
||||
opts Options
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[uuid.UUID]map[*client]struct{}
|
||||
last map[uuid.UUID]lastEvent
|
||||
acks map[string]int
|
||||
ackRing []string
|
||||
closed bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewHub builds a hub. It panics when Authenticate is nil: a hub that cannot
|
||||
// authenticate must never be mounted.
|
||||
func NewHub(opts Options) *Hub {
|
||||
if opts.Authenticate == nil {
|
||||
panic("realtime: Options.Authenticate is required")
|
||||
}
|
||||
opts = opts.withDefaults()
|
||||
return &Hub{
|
||||
opts: opts,
|
||||
upgrader: websocket.Upgrader{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
// Mobile clients send no Origin and the handshake is bearer-token
|
||||
// authenticated (X-Access-Token + alert token), so Origin carries
|
||||
// no authority here. A browser page cannot read the token.
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
clients: make(map[uuid.UUID]map[*client]struct{}),
|
||||
last: make(map[uuid.UUID]lastEvent),
|
||||
acks: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler upgrades GET /ws/alarm?alertId=&token= and serves the connection.
|
||||
func (h *Hub) Handler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := h.opts.Authenticate(r)
|
||||
if err != nil {
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
writeJSONError(w, authErr.Status, authErr.Code, authErr.Message)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
closed := h.closed
|
||||
h.mu.Unlock()
|
||||
if closed {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down")
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
// Upgrade already wrote the HTTP error response.
|
||||
h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err)
|
||||
return
|
||||
}
|
||||
|
||||
c := newClient(h, conn, *identity)
|
||||
h.register(c)
|
||||
|
||||
// Reconnect catch-up: replay the last event only while it is still
|
||||
// live, so a client that reconnects mid-alarm fires immediately and a
|
||||
// client reconnecting after the alarm is never re-alarmed.
|
||||
if f, ok := h.lastActive(identity.AlertID); ok {
|
||||
if b, err := json.Marshal(f); err == nil {
|
||||
c.trySend(b)
|
||||
}
|
||||
}
|
||||
|
||||
c.touch()
|
||||
|
||||
h.wg.Add(2)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
c.writePump()
|
||||
}()
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
c.readPump()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.clients[c.identity.AlertID] == nil {
|
||||
h.clients[c.identity.AlertID] = make(map[*client]struct{})
|
||||
}
|
||||
h.clients[c.identity.AlertID][c] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(c *client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if set, ok := h.clients[c.identity.AlertID]; ok {
|
||||
delete(set, c)
|
||||
if len(set) == 0 {
|
||||
delete(h.clients, c.identity.AlertID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast delivers f to every live client of the alert and returns how many
|
||||
// connections the alert has on this instance. A duplicate event id (the same
|
||||
// event arriving through both the in-process fast path and the NOTIFY bridge)
|
||||
// is delivered once.
|
||||
func (h *Hub) Broadcast(alertID uuid.UUID, f AlarmFrame) int {
|
||||
if f.Type == "" {
|
||||
f.Type = "alarm"
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
if prev, ok := h.last[alertID]; ok && prev.frame.EventID != "" && prev.frame.EventID == f.EventID {
|
||||
n := len(h.clients[alertID])
|
||||
h.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
if ts, err := time.Parse(time.RFC3339Nano, f.ServerTime); err == nil {
|
||||
at = ts
|
||||
}
|
||||
h.last[alertID] = lastEvent{frame: f, at: at}
|
||||
|
||||
targets := make([]*client, 0, len(h.clients[alertID]))
|
||||
for c := range h.clients[alertID] {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
payload, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
h.opts.Logf("ws: marshal frame alert=%s event=%s: %v", alertID, f.EventID, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
delivered := 0
|
||||
for _, c := range targets {
|
||||
if c.trySend(payload) {
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
// Buffer full: the client is not keeping up. Dropping it keeps the
|
||||
// alarm live for everyone else.
|
||||
h.opts.Logf("ws: dropping slow client user=%s alert=%s", c.identity.UserID, alertID)
|
||||
c.close()
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
// LastEvent returns the most recent broadcast frame for an alert, active or
|
||||
// not. Exposed for alarmStatus cross-checks and tests.
|
||||
func (h *Hub) LastEvent(alertID uuid.UUID) (AlarmFrame, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
e, ok := h.last[alertID]
|
||||
return e.frame, ok
|
||||
}
|
||||
|
||||
func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
e, ok := h.last[alertID]
|
||||
if !ok || !e.active(time.Now().UTC()) {
|
||||
return AlarmFrame{}, false
|
||||
}
|
||||
return e.frame, true
|
||||
}
|
||||
|
||||
// ClientCount returns the number of live connections for an alert.
|
||||
func (h *Hub) ClientCount(alertID uuid.UUID) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.clients[alertID])
|
||||
}
|
||||
|
||||
// TotalClients returns the number of live connections across all alerts.
|
||||
func (h *Hub) TotalClients() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
n := 0
|
||||
for _, set := range h.clients {
|
||||
n += len(set)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *Hub) recordAck(eventID string) {
|
||||
if eventID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, seen := h.acks[eventID]; !seen {
|
||||
h.ackRing = append(h.ackRing, eventID)
|
||||
for len(h.ackRing) > ackCap {
|
||||
evict := h.ackRing[0]
|
||||
h.ackRing = h.ackRing[1:]
|
||||
delete(h.acks, evict)
|
||||
}
|
||||
}
|
||||
h.acks[eventID]++
|
||||
}
|
||||
|
||||
// Acks returns how many clients acknowledged an event on this instance.
|
||||
func (h *Hub) Acks(eventID string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.acks[eventID]
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, then drains every connection. It is meant
|
||||
// to be started as `go hub.Run(ctx)`.
|
||||
func (h *Hub) Run(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Shutdown(drainCtx); err != nil {
|
||||
h.opts.Logf("ws: drain incomplete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections, asks every client to close, and waits
|
||||
// for the pumps to finish or ctx to expire. It is idempotent.
|
||||
func (h *Hub) Shutdown(ctx context.Context) error {
|
||||
h.mu.Lock()
|
||||
h.closed = true
|
||||
all := make([]*client, 0, 16)
|
||||
for _, set := range h.clients {
|
||||
for c := range set {
|
||||
all = append(all, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range all {
|
||||
c.close()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) touch(c *client) {
|
||||
if h.opts.Touch == nil || c.identity.SubscriptionID == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.opts.Touch(ctx, *c.identity.SubscriptionID); err != nil {
|
||||
h.opts.Logf("ws: last_seen_at update failed sub=%s: %v", *c.identity.SubscriptionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// testIdentity is what a successful authentication resolves to.
|
||||
func testIdentity(alertID uuid.UUID) Identity {
|
||||
return Identity{AlertID: alertID, UserID: uuid.New()}
|
||||
}
|
||||
|
||||
// startTestServer runs a hub whose authentication always succeeds and returns
|
||||
// the server, its ws:// URL and the hub.
|
||||
func startTestServer(t *testing.T, alertID uuid.UUID) (*httptest.Server, string, *Hub) {
|
||||
t.Helper()
|
||||
hub := NewHub(Options{
|
||||
Authenticate: func(r *http.Request) (*Identity, error) {
|
||||
id := testIdentity(alertID)
|
||||
return &id, nil
|
||||
},
|
||||
Logf: func(format string, v ...any) { t.Logf(format, v...) },
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go hub.Run(ctx)
|
||||
|
||||
srv := httptest.NewServer(hub.Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, "ws" + strings.TrimPrefix(srv.URL, "http"), hub
|
||||
}
|
||||
|
||||
// dial opens one subscriber connection.
|
||||
func dial(t *testing.T, wsURL string) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
t.Fatalf("dial %s: %v (http %d)", wsURL, err, status)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// waitClients blocks until the hub has n clients on the alert.
|
||||
func waitClients(t *testing.T, hub *Hub, alertID uuid.UUID, n int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.ClientCount(alertID) == n {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("hub has %d clients on alert %s, want %d", hub.ClientCount(alertID), alertID, n)
|
||||
}
|
||||
|
||||
// readAlarm reads one server frame and requires it to be an alarm frame.
|
||||
func readAlarm(t *testing.T, conn *websocket.Conn) AlarmFrame {
|
||||
t.Helper()
|
||||
if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
var frame AlarmFrame
|
||||
if err := conn.ReadJSON(&frame); err != nil {
|
||||
t.Fatalf("read alarm frame: %v", err)
|
||||
}
|
||||
if frame.Type != "alarm" {
|
||||
t.Fatalf("frame type = %q, want \"alarm\"", frame.Type)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// TestBroadcastReachesTwoClientsAndAckRoundTrips is the task-4 acceptance test:
|
||||
// ONE Broadcast call must reach TWO live subscribers, each must see the same
|
||||
// event id, and each client ack must be recorded by the hub.
|
||||
func TestBroadcastReachesTwoClientsAndAckRoundTrips(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
_, wsURL, hub := startTestServer(t, alertID)
|
||||
|
||||
first := dial(t, wsURL)
|
||||
second := dial(t, wsURL)
|
||||
waitClients(t, hub, alertID, 2)
|
||||
|
||||
eventID := uuid.NewString()
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: eventID,
|
||||
Action: "trigger",
|
||||
Message: "fire alarm",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
|
||||
t.Fatalf("Broadcast delivered to %d clients, want 2", delivered)
|
||||
}
|
||||
|
||||
for i, conn := range []*websocket.Conn{first, second} {
|
||||
got := readAlarm(t, conn)
|
||||
if got.EventID != eventID {
|
||||
t.Fatalf("client %d event id = %q, want %q", i, got.EventID, eventID)
|
||||
}
|
||||
if got.Action != "trigger" || got.Message != "fire alarm" {
|
||||
t.Fatalf("client %d frame = %+v, want the triggered alarm", i, got)
|
||||
}
|
||||
// Ack round-trip: the client acknowledges, the hub counts it.
|
||||
if err := conn.WriteJSON(ClientFrame{Type: "ack", EventID: eventID}); err != nil {
|
||||
t.Fatalf("client %d ack: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) && hub.Acks(eventID) < 2 {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if got := hub.Acks(eventID); got != 2 {
|
||||
t.Fatalf("hub recorded %d acks for event %s, want 2", got, eventID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastIsDedupedByEventID proves the local broadcast and the database
|
||||
// NOTIFY path cannot deliver the same event twice: a repeat broadcast of an
|
||||
// event the hub already holds delivers nothing, it only reports how many
|
||||
// clients currently hold it.
|
||||
func TestBroadcastIsDedupedByEventID(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
_, wsURL, hub := startTestServer(t, alertID)
|
||||
|
||||
conn := dial(t, wsURL)
|
||||
waitClients(t, hub, alertID, 1)
|
||||
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: uuid.NewString(),
|
||||
Action: "trigger",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 1 {
|
||||
t.Fatalf("first Broadcast delivered to %d clients, want 1", delivered)
|
||||
}
|
||||
// Same event id again: the frame must NOT be written a second time.
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 1 {
|
||||
t.Fatalf("repeat Broadcast reported %d clients, want 1 (already held)", delivered)
|
||||
}
|
||||
if got := readAlarm(t, conn); got.EventID != frame.EventID {
|
||||
t.Fatalf("event id = %q, want %q", got.EventID, frame.EventID)
|
||||
}
|
||||
// Nothing else may arrive: the duplicate was suppressed, not queued.
|
||||
if err := conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
var extra AlarmFrame
|
||||
if err := conn.ReadJSON(&extra); err == nil {
|
||||
t.Fatalf("duplicate event was delivered: %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconnectReplaysLiveTrigger covers the catch-up path: a subscriber that
|
||||
// connects while an alarm is still live is alarmed immediately.
|
||||
func TestReconnectReplaysLiveTrigger(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
_, wsURL, hub := startTestServer(t, alertID)
|
||||
|
||||
// Broadcast with nobody connected: the hub keeps it as the last event.
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: uuid.NewString(),
|
||||
Action: "trigger",
|
||||
Message: "still ringing",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: 300,
|
||||
}
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 0 {
|
||||
t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered)
|
||||
}
|
||||
|
||||
conn := dial(t, wsURL)
|
||||
got := readAlarm(t, conn)
|
||||
if got.EventID != frame.EventID || got.Message != "still ringing" {
|
||||
t.Fatalf("catch-up frame = %+v, want event %s", got, frame.EventID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerRejectsNonGet keeps the WS endpoint's error shape uniform with
|
||||
// /action/*: a plain JSON error, not an upgrade attempt.
|
||||
func TestHandlerRejectsNonGet(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
srv, _, _ := startTestServer(t, alertID)
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ws/alarm", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if body.Error.Code == "" {
|
||||
t.Fatalf("error body has no code: %+v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// TestBroadcastLatency measures the fan-out path on real sockets: from the
|
||||
// instant Broadcast is called to the instant BOTH subscribers have decoded the
|
||||
// frame. It excludes the HTTP action and the database insert that precede a
|
||||
// trigger in production, so it is the floor for the end-to-end number.
|
||||
//
|
||||
// It logs p50/p95/max and only fails on a gross regression (p95 > 500ms) to
|
||||
// stay useful without being flaky on a loaded machine.
|
||||
func TestBroadcastLatency(t *testing.T) {
|
||||
alertID := uuid.New()
|
||||
_, wsURL, hub := startTestServer(t, alertID)
|
||||
|
||||
first := dial(t, wsURL)
|
||||
second := dial(t, wsURL)
|
||||
waitClients(t, hub, alertID, 2)
|
||||
|
||||
const iterations = 100
|
||||
samples := make([]time.Duration, 0, iterations)
|
||||
for i := 0; i < iterations; i++ {
|
||||
eventID := uuid.NewString()
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: eventID,
|
||||
Action: "trigger",
|
||||
Message: "latency probe",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: 300,
|
||||
}
|
||||
start := time.Now()
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
|
||||
t.Fatalf("iteration %d: Broadcast delivered to %d clients, want 2", i, delivered)
|
||||
}
|
||||
for _, conn := range []*websocket.Conn{first, second} {
|
||||
got := readAlarm(t, conn)
|
||||
if got.EventID != eventID {
|
||||
t.Fatalf("iteration %d: event id = %q, want %q", i, got.EventID, eventID)
|
||||
}
|
||||
}
|
||||
samples = append(samples, time.Since(start))
|
||||
}
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
p50 := samples[len(samples)*50/100]
|
||||
p95 := samples[len(samples)*95/100]
|
||||
max := samples[len(samples)-1]
|
||||
t.Logf("broadcast -> 2 clients decoded: n=%d p50=%v p95=%v max=%v",
|
||||
len(samples), p50, p95, max)
|
||||
if p95 > 500*time.Millisecond {
|
||||
t.Fatalf("p95 latency %v exceeds the 500ms budget", p95)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// NotifyChannel is the PostgreSQL NOTIFY channel the alarm schema publishes
|
||||
// trigger/disarm events on (see alarm.notify_alarm_event() in
|
||||
// database/migrations/001_alarm_schema.sql).
|
||||
const NotifyChannel = "alarm_events"
|
||||
|
||||
// NotifyPayload is the transport form of an alarm frame on the NOTIFY channel.
|
||||
// It is emitted by the database trigger, so every service instance — not just
|
||||
// the one that served the trigger request — fans the event out to its own
|
||||
// subscribers.
|
||||
type NotifyPayload struct {
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"`
|
||||
Message string `json:"message"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
// Frame converts the transport payload into the wire frame. ServerTime is the
|
||||
// EVENT time, not the delivery time, so every instance and every client agrees
|
||||
// on when the alarm started (and therefore on the auto-stop countdown).
|
||||
func (p NotifyPayload) Frame() AlarmFrame {
|
||||
return AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: p.AlertID,
|
||||
EventID: p.EventID,
|
||||
Action: p.Action,
|
||||
Message: p.Message,
|
||||
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: p.AutoStopSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrBadNotifyPayload reports a NOTIFY payload that cannot be trusted; the
|
||||
// bridge logs and ignores it rather than delivering a malformed frame.
|
||||
var ErrBadNotifyPayload = errors.New("realtime: malformed notify payload")
|
||||
|
||||
// DeliverNotifyPayload parses one NOTIFY payload and broadcasts the frame. It
|
||||
// is the unit-testable half of the bridge (no PostgreSQL needed).
|
||||
func (h *Hub) DeliverNotifyPayload(payload []byte) (AlarmFrame, bool, error) {
|
||||
var p NotifyPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return AlarmFrame{}, false, ErrBadNotifyPayload
|
||||
}
|
||||
alertID, err := uuid.Parse(p.AlertID)
|
||||
if err != nil {
|
||||
return AlarmFrame{}, false, ErrBadNotifyPayload
|
||||
}
|
||||
if _, err := uuid.Parse(p.EventID); err != nil {
|
||||
return AlarmFrame{}, false, ErrBadNotifyPayload
|
||||
}
|
||||
if !alertstate.ValidKind(p.Action) {
|
||||
return AlarmFrame{}, false, ErrBadNotifyPayload
|
||||
}
|
||||
f := p.Frame()
|
||||
h.Broadcast(alertID, f)
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// RunNotifyBridge listens on the alarm_events channel and fans every payload
|
||||
// out to the hub, reconnecting with exponential backoff. It returns nil when
|
||||
// ctx is cancelled. Run it as `go realtime.RunNotifyBridge(ctx, hub, dsn)`.
|
||||
func RunNotifyBridge(ctx context.Context, hub *Hub, dsn string) error {
|
||||
backoff := 500 * time.Millisecond
|
||||
const maxBackoff = 10 * time.Second
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
err := listenOnce(ctx, hub, dsn)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
hub.opts.Logf("notify: bridge disconnected (%v); reconnecting in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listenOnce(ctx context.Context, hub *Hub, dsn string) error {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close(context.Background()) }()
|
||||
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+NotifyChannel); err != nil {
|
||||
return err
|
||||
}
|
||||
hub.opts.Logf("notify: listening on channel %s", NotifyChannel)
|
||||
|
||||
for {
|
||||
n, err := conn.WaitForNotification(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok, err := hub.DeliverNotifyPayload([]byte(n.Payload)); err != nil {
|
||||
hub.opts.Logf("notify: ignored payload: %v", err)
|
||||
} else if ok {
|
||||
hub.opts.Logf("notify: delivered event from channel %s", NotifyChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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 realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
)
|
||||
|
||||
// current is the hub built by Register, read by the action layer for the
|
||||
// in-process broadcast fast path. It is written once during startup, before the
|
||||
// listener accepts traffic, and never mutated afterwards.
|
||||
var current *Hub
|
||||
|
||||
// Current returns the process-wide hub, or nil when the realtime layer was
|
||||
// never registered (unit tests, or a deliberately API-only process).
|
||||
func Current() *Hub { return current }
|
||||
|
||||
// Register installs GET /ws/alarm on mux, starts the hub and the database
|
||||
// notify bridge, and returns a drain function to be called once during
|
||||
// shutdown. It does not block: the hub goroutine runs until the returned drain
|
||||
// function is called or its context expires.
|
||||
//
|
||||
// The route is registered on the OUTER mux, never behind the API chain: the
|
||||
// upgrade is hijacked, and the subscribe token rides the query string, so the
|
||||
// request must not pass through the logging middleware.
|
||||
//
|
||||
// `database` is the service's single pool handle. The hub only needs the DSN
|
||||
// for the LISTEN bridge, but accepting the handle keeps one pool per process
|
||||
// and makes the dependency explicit.
|
||||
func Register(mux *http.ServeMux, database *db.DB) func(ctx context.Context) {
|
||||
hub := NewHub(Options{
|
||||
Authenticate: authenticateWS,
|
||||
Touch: db.TouchSubscription,
|
||||
Logf: func(format string, v ...any) {
|
||||
logger.Warn(format, v...)
|
||||
},
|
||||
})
|
||||
current = hub
|
||||
|
||||
// The hub's lifetime is owned here, not by the caller: the drain function
|
||||
// cancels it, which makes Run stop accepting and drain its clients.
|
||||
hubCtx, cancelHub := context.WithCancel(context.Background())
|
||||
go hub.Run(hubCtx)
|
||||
|
||||
dsn := db.DatabaseURL()
|
||||
if database != nil && database.Pool != nil {
|
||||
// Prefer the pool's own config so an injected DSN is honoured.
|
||||
dsn = database.Pool.Config().ConnString()
|
||||
}
|
||||
hub.StartNotifyBridge(hubCtx, dsn)
|
||||
|
||||
mux.Handle("/ws/alarm", hub.Handler())
|
||||
|
||||
var once sync.Once
|
||||
return func(ctx context.Context) {
|
||||
once.Do(func() {
|
||||
cancelHub()
|
||||
if err := hub.Shutdown(ctx); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("realtime: hub drain incomplete: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// StartNotifyBridge subscribes this instance to the database's alarm_events
|
||||
// channel so a trigger raised on ANY instance reaches this one's subscribers.
|
||||
// It returns immediately; the listener reconnects with backoff until ctx ends.
|
||||
func (h *Hub) StartNotifyBridge(ctx context.Context, dsn string) {
|
||||
if dsn == "" {
|
||||
logger.Warn("realtime: no database url, cross-instance notify bridge disabled")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := RunNotifyBridge(ctx, h, dsn); err != nil && ctx.Err() == nil {
|
||||
logger.Error("realtime: notify bridge stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 types
|
||||
|
||||
import ()
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Version *string `json:"version,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
HasAuth bool `json:"has_auth"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type UserCredentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UserRegistration struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken *string `json:"accessToken,omitempty"`
|
||||
RefreshToken *string `json:"refreshToken,omitempty"`
|
||||
User interface{} `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type ActionRequest struct {
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type ActionResponse struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user