init
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Keep the build context small and deterministic. Never ship host artifacts.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
src/tmp
|
||||||
|
src/logs
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Template action to generate OCI-compliant containers
|
||||||
|
# Don't forget to change the template variables if any were missed
|
||||||
|
|
||||||
|
name: Build and Publish Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: alpine-latest
|
||||||
|
steps:
|
||||||
|
- name: Install Dependencies
|
||||||
|
shell: sh
|
||||||
|
run: apk add --no-cache bash docker-cli docker-cli-buildx git
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Get full git history
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Normalize release version
|
||||||
|
id: version
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
ORG_REPO="$(printf '%s' "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "image_base=${{ secrets.REGISTRY }}/${ORG_REPO}" >> "${GITHUB_OUTPUT}"
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
PRERELEASE=""
|
||||||
|
case "$TAG" in
|
||||||
|
*-*) PRERELEASE="-${TAG#*-}"; TAG="${TAG%%-*}";;
|
||||||
|
esac
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<EOF
|
||||||
|
$TAG
|
||||||
|
EOF
|
||||||
|
MAJOR="${MAJOR:-0}"
|
||||||
|
MINOR="${MINOR:-0}"
|
||||||
|
PATCH="${PATCH:-0}"
|
||||||
|
VERSION="${MAJOR}.${MINOR}.${PATCH}${PRERELEASE}"
|
||||||
|
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Extract metadata (labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.version.outputs.image_base }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Test Service
|
||||||
|
org.opencontainers.image.vendor=Dcntrlized
|
||||||
|
org.opencontainers.image.description=A test service used to test service creation and deployment
|
||||||
|
org.opencontainers.image.authors=Admin <[email protected]>
|
||||||
|
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.version.outputs.image_base }}:${{ steps.version.outputs.version }}
|
||||||
|
${{ steps.version.outputs.image_base }}:latest
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
IMAGE_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
IMAGE_REVISION=${{ gitea.sha }}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Go build artifacts (Air runs inside the dev container as root)
|
||||||
|
src/tmp/
|
||||||
|
src/logs/
|
||||||
|
|
||||||
|
# Logger writes its logs/ dir relative to the working directory, so package
|
||||||
|
# tests (which run with the package dir as cwd) can drop logs under src/*/.
|
||||||
|
src/handlers/config/logs/
|
||||||
|
src/handlers/manifest/logs/
|
||||||
|
src/actions/logs/
|
||||||
|
src/realtime/logs/
|
||||||
|
src/db/logs/
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Copyright (C) {{.Year}} {{.Holder}}
|
||||||
|
|
||||||
|
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/>.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# addlicense ignore patterns (doublestar globs, relative to the repo root).
|
||||||
|
# Blank lines and lines starting with '#' are skipped. addlicense validates every
|
||||||
|
# pattern and skips file types it does not understand on its own, so this file is
|
||||||
|
# only the escape hatch for files that must not carry an AGPL header.
|
||||||
|
|
||||||
|
# generated / vendored / build output
|
||||||
|
**/node_modules/**
|
||||||
|
**/vendor/**
|
||||||
|
**/tmp/**
|
||||||
|
**/dist/**
|
||||||
|
**/build/**
|
||||||
|
|
||||||
|
# non-code and config files
|
||||||
|
**/*.md
|
||||||
|
**/*.json
|
||||||
|
**/*.yaml
|
||||||
|
**/*.yml
|
||||||
|
**/*.toml
|
||||||
|
**/*.lock
|
||||||
|
**/*.sum
|
||||||
|
**/*.mod
|
||||||
|
**/*.log
|
||||||
|
**/*.txt
|
||||||
|
**/*.debug
|
||||||
|
|
||||||
|
# repo plumbing
|
||||||
|
.licenseignore
|
||||||
|
.gitignore
|
||||||
|
.gitmodules
|
||||||
|
.vscode/**
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Backend",
|
||||||
|
"type": "go",
|
||||||
|
"request": "attach",
|
||||||
|
"mode": "remote",
|
||||||
|
"port": 2349,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"debugAdapter": "legacy",
|
||||||
|
"substitutePath": [
|
||||||
|
{
|
||||||
|
"from": "${workspaceFolder}/src",
|
||||||
|
"to": "/app"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"showLog": true,
|
||||||
|
"preLaunchTask": "Launch Backend",
|
||||||
|
"presentation": {
|
||||||
|
"hidden": false,
|
||||||
|
"group": "Alarm",
|
||||||
|
"order": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach to Alarm (Air)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "attach",
|
||||||
|
"mode": "remote",
|
||||||
|
"port": 2349,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"debugAdapter": "legacy",
|
||||||
|
"substitutePath": [
|
||||||
|
{
|
||||||
|
"from": "${workspaceFolder}/src",
|
||||||
|
"to": "/app"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"showLog": true,
|
||||||
|
"presentation": {
|
||||||
|
"hidden": false,
|
||||||
|
"group": "Alarm",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+54
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Launch Backend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yaml up -d && until nc -z 127.0.0.1 2349 >/dev/null 2>&1; do sleep 1; done && echo DAE_BACKEND_READY",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": [
|
||||||
|
{
|
||||||
|
"pattern": {
|
||||||
|
"regexp": "^.*$"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": ".*",
|
||||||
|
"endsPattern": "DAE_BACKEND_READY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Follow Backend Logs",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yaml logs -f",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Stop Backend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yaml down",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Rebuild Backend Image",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yaml up -d --build",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
# Alarm — service API (v1, frozen)
|
||||||
|
|
||||||
|
The Alarm service is the server half of the Dcntrlized alert feature: one user
|
||||||
|
(the **admin**) creates an alert and holds an admin token; everybody else joins
|
||||||
|
by scanning the alert's **subscribe token** as a QR code; the admin can trigger
|
||||||
|
and disarm the alert at will, and a trigger must fire **live** on every
|
||||||
|
subscribed client.
|
||||||
|
|
||||||
|
- Container / DNS name: `alarm`
|
||||||
|
- One plaintext listener inside the container: `:8080` (dev host port `8088`)
|
||||||
|
- Module: `git.dcntrlized.org/Dcntrlized/Alarm`
|
||||||
|
- Image: `192.168.122.183:3000/dcntrlized/alarm:dev`
|
||||||
|
|
||||||
|
Everything — the API, the auth proxy and the WebSocket — rides that **single
|
||||||
|
port**. There is no second listener and no TLS on the service itself (an edge
|
||||||
|
proxy terminates TLS).
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /_manifest` | service manifest consumed by Maestro |
|
||||||
|
| `/auth/*` | shared-auth proxy (`dcntrlized-auth`), unchanged from every other service |
|
||||||
|
| `/action/{actionName}` | the action API documented below |
|
||||||
|
| `GET /ws/alarm` | the live alarm socket |
|
||||||
|
|
||||||
|
`/ws/alarm` is deliberately registered on the **outer** mux, outside the
|
||||||
|
logging/CORS chain: the connection is hijacked and the subscribe token travels
|
||||||
|
in the query string, so it must never reach a request logger.
|
||||||
|
|
||||||
|
## Envelope and authentication
|
||||||
|
|
||||||
|
`/action/*` uses the platform envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": { "...": "action specific" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `POST`, `PUT` and `DELETE` read the envelope from the body. Unknown fields
|
||||||
|
**inside `payload` are ignored** (forward compatible); an unknown top-level
|
||||||
|
envelope field is rejected by the dispatcher with a plain-text `400` (see
|
||||||
|
*Errors* below).
|
||||||
|
- `GET` carries no body: the same actions take their arguments from the query
|
||||||
|
string, e.g. `GET /action/getAlert?alertId=…`.
|
||||||
|
- Every action that addresses an alert accepts **`alertId` under the alias
|
||||||
|
`id`**, both as a query parameter and as a payload key:
|
||||||
|
`?id=<uuid>` and `{ "payload": { "id": "<uuid>" } }` work exactly like
|
||||||
|
`alertId`. Precedence is query `alertId` → query `id` → payload `alertId` →
|
||||||
|
payload `id`; the first non-empty one wins.
|
||||||
|
|
||||||
|
**Every alert action requires a verified access token** in the `X-Access-Token`
|
||||||
|
header (minted by `dcntrlized-auth`). A missing, malformed or expired token is
|
||||||
|
always:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "ERR_UNAUTHORIZED", "message": "…" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
with HTTP `401`. The service never trusts a user id, name or email from the
|
||||||
|
payload — identity comes from the token only. The shared-auth proxies
|
||||||
|
(`login`, `register`, `refresh`, `delete`, `logout`, `me`) are the exception:
|
||||||
|
`login`/`register` mint the token, `me`/`logout`/`refresh`/`delete` forward
|
||||||
|
whatever header the caller sent and let `dcntrlized-auth` decide.
|
||||||
|
|
||||||
|
Two further credentials exist, both **in the payload** (never in a header):
|
||||||
|
|
||||||
|
- `adminToken` — the alert's admin secret. Minted once by `createAlert`,
|
||||||
|
stored only as a SHA-256 hash server-side, and required by the destructive
|
||||||
|
actions.
|
||||||
|
- `subscribeToken` — the shareable secret rendered as the QR code. It is
|
||||||
|
*rotatable* and is the only thing a joining client needs.
|
||||||
|
|
||||||
|
Errors are **not uniform across the request pipeline**. Clients must know
|
||||||
|
where the boundary is.
|
||||||
|
|
||||||
|
**Action-level errors** — everything the action handlers themselves return —
|
||||||
|
use the platform envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "ERR_…", "message": "human readable" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
That covers every code in the table below (401/403/404/409/500) as well as the
|
||||||
|
WebSocket rejections.
|
||||||
|
|
||||||
|
**Dispatch-level errors** are the exception. They are produced by the layer
|
||||||
|
that routes `/action/{actionName}` by HTTP method, *before* any handler or
|
||||||
|
error helper runs, and they are **plain text** (`text/plain; charset=utf-8`),
|
||||||
|
never JSON. A client that calls `JSON.parse` on every error body throws on
|
||||||
|
exactly these — check `Content-Type` first, and treat a non-JSON body as a
|
||||||
|
generic failure:
|
||||||
|
|
||||||
|
| Situation | HTTP | Body | Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| unknown action name for this verb | `501` | `Action not implemented` | `POST /action/getAlerts` (typo), `GET /action/triggerAlert`, **any** `PUT /action/<name>` |
|
||||||
|
| malformed request envelope | `400` | the JSON decoder's message | body is not valid JSON, has an unknown **top-level** field (`DisallowUnknownFields`), or cannot be decoded into the envelope — e.g. `json: unknown field "payloads"` |
|
||||||
|
| unsupported HTTP verb | `405` | `method not allowed` | `PATCH /action/createAlert` |
|
||||||
|
|
||||||
|
Consequences worth internalising:
|
||||||
|
|
||||||
|
- `PUT` is routed but implements nothing, so **every** `PUT /action/<name>` is
|
||||||
|
`501` — including for actions that exist under another verb.
|
||||||
|
- A known action sent with the *wrong* verb is also `501`, not `405`. `405` is
|
||||||
|
reserved for a verb outside `GET`/`POST`/`PUT`/`DELETE`.
|
||||||
|
- `GET` never decodes a body, so it can only produce the `501` row.
|
||||||
|
- Only `POST`, `PUT` and `DELETE` can produce the plain-text `400`.
|
||||||
|
|
||||||
|
## Error codes
|
||||||
|
|
||||||
|
| Code | HTTP | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ERR_VALIDATION` | 400 | malformed payload, bad uuid, field too long, bad query parameter |
|
||||||
|
| `ERR_UNAUTHORIZED` | 401 | missing/invalid access token |
|
||||||
|
| `ERR_ALERT_TOKEN_INVALID` | 403 | the `adminToken` or `subscribeToken` does not match this alert |
|
||||||
|
| `ERR_ALERT_NOT_ADMIN` | 403 | a valid access token, but not the alert's admin, for an admin-only action |
|
||||||
|
| `ERR_ALERT_NOT_FOUND` | 404 | no such alert (also returned instead of 403 where hiding existence matters) |
|
||||||
|
| `ERR_ALERT_NOT_SUBSCRIBED` | 404 | caller is neither the admin nor a subscriber of this alert |
|
||||||
|
| `ERR_ALERT_ALREADY_JOINED` | 409 | unique-constraint race on `(alertId, user, deviceId)`; the normal re-scan path is idempotent and returns `200` |
|
||||||
|
| `ERR_ALERT_STATE` | 409 | the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one) |
|
||||||
|
| `ERR_INTERNAL` | 500 | unexpected server/database failure |
|
||||||
|
|
||||||
|
`ERR_METHOD_NOT_ALLOWED` (405) and `ERR_UNAVAILABLE` (503) also exist on the
|
||||||
|
WebSocket endpoint — see below. These codes only ever appear in a JSON
|
||||||
|
envelope; the dispatch-level `405` described above is plain text and carries no
|
||||||
|
code.
|
||||||
|
|
||||||
|
## Action summary
|
||||||
|
|
||||||
|
The dispatcher routes by HTTP method (`GET`, `POST`, `PUT`, `DELETE`); in every
|
||||||
|
case the action name is the path segment after `/action/`.
|
||||||
|
|
||||||
|
`POST` accepts every **alert** action plus the shared-auth proxies. `GET`
|
||||||
|
accepts the read-only alert subset plus `me` and `logout` — **`me` is
|
||||||
|
GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals.
|
||||||
|
`PUT` implements nothing at all.
|
||||||
|
|
||||||
|
### Alert actions
|
||||||
|
|
||||||
|
| Action | Verbs | Admin token | Access |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `createAlert` | POST | — | any authenticated user becomes the admin |
|
||||||
|
| `getAlert` | GET, POST | — | admin **or** subscriber |
|
||||||
|
| `joinAlert` | POST | — | any authenticated user holding the subscribe token |
|
||||||
|
| `listSubscriptions` | GET, POST | — | alert admin only: the access-token user must be the alert's `adminUserId`; **no** admin token is used |
|
||||||
|
| `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) |
|
||||||
|
| `triggerAlert` | POST | required | admin |
|
||||||
|
| `disarmAlert` | POST | required | admin |
|
||||||
|
| `alarmStatus` | GET, POST | — | admin **or** subscriber |
|
||||||
|
| `deleteAlert` | DELETE, POST | required | admin |
|
||||||
|
| `qrImage` | GET, POST | — | admin **or** subscriber |
|
||||||
|
| `rotateSubscribeToken` | POST | required | admin |
|
||||||
|
|
||||||
|
### Shared-auth actions (`/auth/*` proxies)
|
||||||
|
|
||||||
|
Thin proxies to `dcntrlized-auth`, kept on `/action/` for the RN clients that
|
||||||
|
send everything through one path. They take the same `{payload}` envelope; the
|
||||||
|
payload is forwarded as the raw auth body and the auth response is relayed
|
||||||
|
as-is, unwrapped (so these return the auth service's own body shape, not the
|
||||||
|
alert shapes documented here).
|
||||||
|
|
||||||
|
| Action | Verbs | Proxies | Payload |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `me` | GET | `GET /auth/me` | — (access token header only) |
|
||||||
|
| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op |
|
||||||
|
| `login` | POST | `POST /auth/login` | `{ "username": "…", "password": "…" }` |
|
||||||
|
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
|
||||||
|
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||||||
|
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
|
||||||
|
|
||||||
|
Direct `/auth/*` routes exist for the same calls; the RN clients call those for
|
||||||
|
login/register (raw body, no envelope). These `/action/` variants are the
|
||||||
|
envelope-wrapped form of the same operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `createAlert`
|
||||||
|
|
||||||
|
Creates an alert and makes the caller its admin.
|
||||||
|
|
||||||
|
**POST** `/action/createAlert`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": {
|
||||||
|
"name": "Fire drill",
|
||||||
|
"message": "Leave the building now",
|
||||||
|
"autoStopSeconds": 300
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Rules |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required, 1–255 characters after trimming |
|
||||||
|
| `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger |
|
||||||
|
| `autoStopSeconds` | int | optional, `0`–`86400`; `0` (default) means "ring until disarmed" |
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alertId": "0f8fad5b-d9cb-469f-a165-70867728950e",
|
||||||
|
"name": "Fire drill",
|
||||||
|
"message": "Leave the building now",
|
||||||
|
"status": "armed",
|
||||||
|
"active": false,
|
||||||
|
"adminToken": "…", // shown ONCE, store it
|
||||||
|
"subscribeToken": "…",
|
||||||
|
"qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
|
||||||
|
"autoStopSeconds": 300,
|
||||||
|
"tokenVersion": 1,
|
||||||
|
"created": "2026-10-18T17:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`adminToken` is returned **only here** (and nowhere else, ever). The server
|
||||||
|
keeps a hash. Losing it means losing admin rights over that alert.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `getAlert`
|
||||||
|
|
||||||
|
Returns the alert, the caller's relationship to it, and the newest event.
|
||||||
|
|
||||||
|
**GET** `/action/getAlert?alertId=<uuid>` · **POST** `{ "payload": { "alertId": "…" } }`
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alertId": "…",
|
||||||
|
"name": "Fire drill",
|
||||||
|
"message": "Leave the building now",
|
||||||
|
"status": "triggered", // "armed" | "triggered" | "disarmed"
|
||||||
|
"active": true, // derived, see alarmStatus
|
||||||
|
"autoStopSeconds": 300,
|
||||||
|
"tokenVersion": 1,
|
||||||
|
"created": "2026-10-18T17:00:00Z",
|
||||||
|
"modified": "2026-10-18T17:05:00Z",
|
||||||
|
"triggeredAt": "2026-10-18T17:05:00Z",
|
||||||
|
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
|
||||||
|
"isAdmin": true,
|
||||||
|
"subscriptionCount": 2,
|
||||||
|
"lastEvent": {
|
||||||
|
"eventId": "…", "action": "trigger", "message": "Leave the building now",
|
||||||
|
"serverTime": "2026-10-18T17:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`qrPayload` is present for the admin and for subscribers (the subscriber already
|
||||||
|
holds that token). `isAdmin` tells the client which controls to render.
|
||||||
|
|
||||||
|
Optional fields are **omitted, never sent as `null`** (`omitempty` on the
|
||||||
|
wire). That covers `triggeredAt` (absent until the first trigger — and *kept*
|
||||||
|
after a disarm, so it always means "when it last rang"), `disarmedAt` (absent
|
||||||
|
until a disarm) and `lastEvent` (absent while the alert has no events). Test for
|
||||||
|
presence, not for `null`.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||||
|
`ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `joinAlert`
|
||||||
|
|
||||||
|
Subscribes the caller's device. This is what the QR scan calls.
|
||||||
|
|
||||||
|
**POST** `/action/joinAlert`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": {
|
||||||
|
"alertId": "0f8fad5b-…",
|
||||||
|
"subscribeToken": "…",
|
||||||
|
"deviceId": "android-8f2c…",
|
||||||
|
"deviceLabel": "Pixel 7",
|
||||||
|
"platform": "android"
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Rules |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `alertId` | uuid | required (the scanner takes it from the QR payload) |
|
||||||
|
| `subscribeToken` | string | required, 16–64 url-safe characters, must match the alert |
|
||||||
|
| `deviceId` | string | **required**, 1–128 characters after trimming, no control characters — the client generates and persists a stable per-install id and sends it on every join |
|
||||||
|
| `deviceLabel` | string | optional, ≤ 128 chars |
|
||||||
|
| `platform` | string | optional, ≤ 32 chars (`android`, `ios`, …) |
|
||||||
|
|
||||||
|
`deviceId` is part of the subscription's identity, so it is **not optional**:
|
||||||
|
missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it
|
||||||
|
once per install (a random UUID persisted in app storage is enough) and never
|
||||||
|
rotate it — a new value makes the same user a separate subscription, which is
|
||||||
|
exactly the "second device" behaviour. Reuse the same value as `?deviceId=` on
|
||||||
|
`/ws/alarm` so the right subscription's `last_seen_at` is refreshed.
|
||||||
|
|
||||||
|
Response `200` — the subscription view (see `listSubscriptions`).
|
||||||
|
|
||||||
|
**Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the
|
||||||
|
same device returns `200` with the same subscription id and creates no new row.
|
||||||
|
The same user scanning on a *second* device creates a second subscription.
|
||||||
|
`ERR_ALERT_ALREADY_JOINED` is only the 409 fallback for a concurrent duplicate
|
||||||
|
insert race.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||||||
|
`ERR_ALERT_ALREADY_JOINED`, `ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `listSubscriptions`
|
||||||
|
|
||||||
|
**GET** `/action/listSubscriptions?alertId=<uuid>` ·
|
||||||
|
**POST** `{ "payload": { "alertId": "…" } }`
|
||||||
|
|
||||||
|
Admin only (the access token must belong to the alert's admin — the admin token
|
||||||
|
is *not* required for this read).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [
|
||||||
|
{ "id": "…", "userId": "…", "deviceId": "android-8f2c…",
|
||||||
|
"deviceLabel": "Pixel 7", "platform": "android",
|
||||||
|
"enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z",
|
||||||
|
"created": "2026-10-18T16:00:00Z" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`username` is intentionally **never** populated: it lives in the auth service's
|
||||||
|
schema and Alarm does not read another service's tables. The field is omitted
|
||||||
|
rather than sent empty. `lastSeenAt` is refreshed whenever that subscription's
|
||||||
|
socket connects or heartbeats.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_ADMIN`,
|
||||||
|
`ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `removeSubscription`
|
||||||
|
|
||||||
|
**DELETE** `/action/removeSubscription` · **POST** (same envelope)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": { "alertId": "…", "subscriptionId": "…" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin may remove any subscription of the alert; a subscriber may remove its
|
||||||
|
own. Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "alertId": "…", "subscriptionId": "…", "removed": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||||
|
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `triggerAlert`
|
||||||
|
|
||||||
|
Fires the alarm **live** on every subscriber of every instance.
|
||||||
|
|
||||||
|
**POST** `/action/triggerAlert`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": {
|
||||||
|
"alertId": "0f8fad5b-…",
|
||||||
|
"adminToken": "…",
|
||||||
|
"message": "Optional per-trigger override"
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
|
||||||
|
`message` is optional; when omitted the alert's stored message is used. The
|
||||||
|
override is **not** persisted — it applies to this event only.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "…",
|
||||||
|
"action": "trigger",
|
||||||
|
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||||
|
"autoStopSeconds": 300,
|
||||||
|
"delivered": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `delivered` is the number of **live WebSocket clients on this instance** that
|
||||||
|
the frame was written to. It is not a delivery guarantee: clients that were
|
||||||
|
not connected (or are on another instance) are reached by the reconnect
|
||||||
|
catch-up and by the `alarmStatus` fallback instead.
|
||||||
|
- Re-triggering an already-triggered alert is allowed and always produces a
|
||||||
|
**new `eventId`**, restarting the auto-stop window. Clients must treat each
|
||||||
|
`eventId` as a new alarm.
|
||||||
|
- The alert state is stored **before** the fan-out, so a client that reconnects
|
||||||
|
immediately after the call still sees the live event.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||||||
|
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `disarmAlert`
|
||||||
|
|
||||||
|
**POST** `/action/disarmAlert` — same payload as `triggerAlert` (the `message`
|
||||||
|
override applies to the disarm event the same way).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200` — same shape as `triggerAlert` with `"action": "disarm"`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "…",
|
||||||
|
"action": "disarm",
|
||||||
|
"serverTime": "2026-10-18T17:09:00.456Z",
|
||||||
|
"autoStopSeconds": 300,
|
||||||
|
"delivered": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not
|
||||||
|
`0`: disarming does not rewrite the stored column, and the same value rides the
|
||||||
|
broadcast `disarm` frame. Treat it as informational — "that is how long the
|
||||||
|
trigger would have rung" — never as a stop signal. **Cancel the ring on
|
||||||
|
`action: "disarm"`** (or on `active: false` from `alarmStatus`). A client that
|
||||||
|
waits for `autoStopSeconds === 0` before stopping will keep ringing.
|
||||||
|
|
||||||
|
Disarming an alert that is **not** currently active is `409 ERR_ALERT_STATE`.
|
||||||
|
|
||||||
|
Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`.
|
||||||
|
|
||||||
|
## `alarmStatus`
|
||||||
|
|
||||||
|
The cheap, indexed fallback a client polls when its socket is down, or on cold
|
||||||
|
start when it may have been killed mid-alarm.
|
||||||
|
|
||||||
|
**GET** `/action/alarmStatus?alertId=<uuid>` ·
|
||||||
|
**POST** `{ "payload": { "alertId": "…" } }`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"eventId": "…",
|
||||||
|
"action": "trigger",
|
||||||
|
"message": "Leave the building now",
|
||||||
|
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||||
|
"autoStopSeconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`active` is **derived**, never a timer: it is true only when the newest event is
|
||||||
|
a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`)
|
||||||
|
`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the
|
||||||
|
answer stays correct across restarts and across instances. When there is no
|
||||||
|
event yet the fields are empty and `active` is `false`.
|
||||||
|
|
||||||
|
`serverTime` is the event's timestamp (or the server's clock when there is no
|
||||||
|
event) — use it to correct client clock skew rather than comparing with the
|
||||||
|
device clock.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||||
|
`ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `deleteAlert`
|
||||||
|
|
||||||
|
**DELETE** `/action/deleteAlert` · **POST** (same envelope)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": { "alertId": "…", "adminToken": "…" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes the alert, its subscriptions and its event history (cascade). Response
|
||||||
|
`200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "alertId": "…", "deleted": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Refused with `409 ERR_ALERT_STATE` while the alert is active — disarm first, so
|
||||||
|
a ringing alarm cannot be deleted out from under its subscribers.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||||||
|
`ERR_ALERT_NOT_ADMIN`, `ERR_ALERT_STATE`, `ERR_UNAUTHORIZED`.
|
||||||
|
|
||||||
|
## `qrImage`
|
||||||
|
|
||||||
|
Renders the alert's subscribe QR as a PNG.
|
||||||
|
|
||||||
|
**GET** `/action/qrImage?alertId=<uuid>&scale=8&quietZone=4` ·
|
||||||
|
**POST** `{ "payload": { "alertId": "…" } }`
|
||||||
|
|
||||||
|
| Query | Default | Range |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `scale` | `8` | 1–64 pixels per module |
|
||||||
|
| `quietZone` (alias `margin`) | `4` | 4–32 modules |
|
||||||
|
|
||||||
|
A quiet zone below **4 modules** is rejected with `400 ERR_VALIDATION`: the
|
||||||
|
ISO/IEC 18004 minimum is 4 and smaller margins produce symbols real scanners
|
||||||
|
refuse (measured: `zbarimg` returns "no symbol" at 0–3).
|
||||||
|
|
||||||
|
Response `200` is **binary**, not JSON: `Content-Type: image/png`,
|
||||||
|
`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. The image encodes
|
||||||
|
exactly `qrPayload` (below). The QR is byte-mode, EC level **M**, version
|
||||||
|
chosen automatically from the payload length.
|
||||||
|
|
||||||
|
Keep the quiet zone at ≥ 4 modules; scanners reject codes without it.
|
||||||
|
|
||||||
|
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||||
|
`ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected).
|
||||||
|
|
||||||
|
## `rotateSubscribeToken`
|
||||||
|
|
||||||
|
Invalidates the current QR and mints a new subscribe token. Existing
|
||||||
|
subscriptions keep working; only *joining* with the old token stops working.
|
||||||
|
Use it when the QR leaked.
|
||||||
|
|
||||||
|
**POST** `/action/rotateSubscribeToken`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "payload": { "alertId": "…", "adminToken": "…" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "alertId": "…", "subscribeToken": "…",
|
||||||
|
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
|
||||||
|
"tokenVersion": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors: as `triggerAlert`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QR payload format
|
||||||
|
|
||||||
|
The QR encodes exactly the `qrPayload` field:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://alarm.dcntrlized.org/a/<alertId>#<subscribeToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
An app-scheme deep link carries the same pair for in-app scanners:
|
||||||
|
|
||||||
|
```
|
||||||
|
dcntrlized-alarm://join?alert=<alertId>&token=<subscribeToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
`alertId` is a UUID and `subscribeToken` is 16–64 url-safe characters
|
||||||
|
(`A–Z a–z 0–9 - _`). The fragment is used because a URL fragment is never sent
|
||||||
|
to a server by a browser — the token stays client-side until the app posts it to
|
||||||
|
`joinAlert`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live protocol — `GET /ws/alarm`
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://<host>:8088/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The socket is **authenticated, not anonymous**: the access token identifies the
|
||||||
|
user and the second token proves the relationship to the alert.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Credential | Where | Required |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| access token (who you are) | `X-Access-Token` header **or** `?accessToken=` / `?access_token=` | yes |
|
||||||
|
| `alertId` | query | yes, valid uuid |
|
||||||
|
| `token` | query | yes — the admin token **or** the subscribe token |
|
||||||
|
| `deviceId` | query | optional; the `deviceId` used at `joinAlert` — selects which subscription's `last_seen_at` is refreshed (without it the oldest subscription is used) |
|
||||||
|
|
||||||
|
- admin token match **and** the caller is the alert's admin → accepted as the
|
||||||
|
admin connection (no subscription required);
|
||||||
|
- subscribe token match **and** the caller has a subscription on that alert →
|
||||||
|
accepted as a subscriber connection (`?deviceId=` selects the subscription,
|
||||||
|
otherwise the oldest one is used);
|
||||||
|
- anything else is rejected **before the upgrade** with the usual JSON error
|
||||||
|
(`401 ERR_UNAUTHORIZED`, `403 ERR_ALERT_TOKEN_INVALID`,
|
||||||
|
`403 ERR_ALERT_NOT_ADMIN`, `404 ERR_ALERT_NOT_SUBSCRIBED`,
|
||||||
|
`404 ERR_ALERT_NOT_FOUND`, or `503 ERR_UNAVAILABLE` during shutdown).
|
||||||
|
A non-`GET` request gets `405 ERR_METHOD_NOT_ALLOWED`.
|
||||||
|
|
||||||
|
A header is preferred for the access token (headers do not appear in access
|
||||||
|
logs); the query form exists because browsers cannot set headers on a WebSocket
|
||||||
|
handshake.
|
||||||
|
|
||||||
|
### Server → client frames
|
||||||
|
|
||||||
|
`alarm` — the alarm event. Every subscribed client receives the **same frame**
|
||||||
|
from one fan-out:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "alarm",
|
||||||
|
"alertId": "0f8fad5b-…",
|
||||||
|
"eventId": "…",
|
||||||
|
"action": "trigger", // "trigger" | "disarm"
|
||||||
|
"message": "Leave the building now",
|
||||||
|
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||||
|
"autoStopSeconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ack` — the server's acknowledgement of a client ack:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ack", "eventId": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client → server frames
|
||||||
|
|
||||||
|
`ack` — "I have this event and I am ringing":
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ack", "eventId": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ping` — application-level keepalive. The server answers with a WebSocket
|
||||||
|
**pong control frame** (there is no JSON pong) and refreshes the subscription's
|
||||||
|
`last_seen_at`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ping" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown or malformed client frames are ignored, never fatal.
|
||||||
|
|
||||||
|
**The one fatal exception is frame size.** A client frame larger than **4096
|
||||||
|
bytes** is a protocol violation: the server refuses it and closes the
|
||||||
|
connection with the WebSocket close code `1009` (message too big). `ack` and
|
||||||
|
`ping` are tiny, so this only bites a client that tries to send something else
|
||||||
|
through the socket — don't. If you see an unexplained disconnect, this is a
|
||||||
|
candidate; the same applies to sending non-JSON frames.
|
||||||
|
|
||||||
|
### Keepalive and timeouts
|
||||||
|
|
||||||
|
- The server sends a WebSocket ping every **20s**; a client that sends nothing
|
||||||
|
(no pong, no `ping`) for **60s** is dropped.
|
||||||
|
- Every write has a **10s** deadline, so a dead peer can never block the hub.
|
||||||
|
- Each connection has a buffered send queue. A client that cannot keep up is
|
||||||
|
**dropped** (its socket closed) rather than allowed to slow the fan-out down
|
||||||
|
for everyone else. Reconnect and the catch-up frame restores the state.
|
||||||
|
|
||||||
|
### Reconnect catch-up
|
||||||
|
|
||||||
|
The service keeps the **last event per alert** in memory. On connect, if that
|
||||||
|
event is still active (a `trigger` whose auto-stop window has not expired), the
|
||||||
|
server sends it immediately — so a client that reconnects mid-alarm fires
|
||||||
|
without polling, and a client that reconnects *after* the alarm is over is never
|
||||||
|
re-alarmed.
|
||||||
|
|
||||||
|
### Multiple instances
|
||||||
|
|
||||||
|
Fan-out is local: `Broadcast` writes to this instance's connections. Every
|
||||||
|
trigger/disarm is also an `INSERT` into `alarm.alarm_events`, whose trigger
|
||||||
|
issues `pg_notify('alarm_events', …)`; each instance holds a `LISTEN` and
|
||||||
|
re-broadcasts to its own clients. So a trigger raised on instance A fires on
|
||||||
|
subscribers of instance B as well. Duplicate deliveries are suppressed by
|
||||||
|
`eventId`, so the local fast path and the NOTIFY path cannot double-fire.
|
||||||
|
|
||||||
|
### Minimal client
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` +
|
||||||
|
`&accessToken=${accessToken}&deviceId=${deviceId}`);
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const f = JSON.parse(ev.data);
|
||||||
|
if (f.type === "alarm" && f.action === "trigger") {
|
||||||
|
// autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N
|
||||||
|
ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen
|
||||||
|
ws.send(JSON.stringify({ type: "ack", eventId: f.eventId }));
|
||||||
|
}
|
||||||
|
if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId);
|
||||||
|
};
|
||||||
|
setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 25000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polling fallback
|
||||||
|
|
||||||
|
When the socket cannot be established, poll `alarmStatus` (every 5–15s while
|
||||||
|
the app is foregrounded) and treat `active: true` with a new `eventId` exactly
|
||||||
|
like a live `trigger` frame, and `active: false` (or `action: "disarm"`) as the
|
||||||
|
stop signal. Always prefer the socket: it is the < 1s path, and polling only
|
||||||
|
bounds the worst case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /_manifest`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Alarm",
|
||||||
|
"author": "Dcntrlized",
|
||||||
|
"capabilities": ["database", "alarm.live"],
|
||||||
|
"has_auth": true,
|
||||||
|
"public_key": "",
|
||||||
|
"icon": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Served verbatim, `Content-Type: application/json`. `database` declares the
|
||||||
|
schema/migration capability Maestro provisions; `has_auth` tells Maestro to
|
||||||
|
mount the shared-auth proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://localhost:8088
|
||||||
|
|
||||||
|
# 1. access token (shared auth)
|
||||||
|
TOKEN=$(curl -s -X POST "$BASE/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin_password_123"}' | jq -r .accessToken)
|
||||||
|
|
||||||
|
# 2. create the alert (keep adminToken!)
|
||||||
|
curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq
|
||||||
|
|
||||||
|
# 3. a second user scans the QR payload and joins
|
||||||
|
curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"payload":{"alertId":"'$ALERT'","subscribeToken":"'$SUB'","deviceId":"pixel-7"}}' | jq
|
||||||
|
|
||||||
|
# 4. bob opens the socket (leave it running in another terminal)
|
||||||
|
websocat "ws://localhost:8088/ws/alarm?alertId=$ALERT&token=$SUB&accessToken=$BOB&deviceId=pixel-7"
|
||||||
|
|
||||||
|
# 5. trigger — bob's socket prints the alarm frame
|
||||||
|
curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
|
||||||
|
|
||||||
|
# 6. disarm
|
||||||
|
curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
All state lives in the shared PostgreSQL database, schema `alarm`:
|
||||||
|
|
||||||
|
| Table | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, `auto_stop_seconds` |
|
||||||
|
| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` |
|
||||||
|
| `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances |
|
||||||
|
|
||||||
|
Migrations live in `database/migrations/` and are applied by the compose
|
||||||
|
`migrate` one-shot; they are idempotent and safe to re-apply.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# --- Build Stage ---
|
||||||
|
FROM golang:1.26 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by
|
||||||
|
# the dev Gitea Go module proxy. Override via --build-arg in production.
|
||||||
|
ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go
|
||||||
|
ARG GONOSUMDB_VALUE=git.dcntrlized.org
|
||||||
|
ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct
|
||||||
|
ENV GONOSUMDB=${GONOSUMDB_VALUE}
|
||||||
|
|
||||||
|
COPY src/go.mod src/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY src/ ./
|
||||||
|
|
||||||
|
# Build the whole main package (not just main.go): task-2/task-4 add files to
|
||||||
|
# package main (and any embed directive would need it), so a single-file build
|
||||||
|
# is not safe.
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
|
||||||
|
|
||||||
|
# --- Run Stage ---
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
ARG IMAGE_VERSION=dev
|
||||||
|
ARG IMAGE_REVISION=
|
||||||
|
|
||||||
|
LABEL org.dcntrlized.service=alarm
|
||||||
|
LABEL org.dcntrlized.port=8080
|
||||||
|
LABEL org.opencontainers.image.version=$IMAGE_VERSION
|
||||||
|
LABEL org.opencontainers.image.revision=$IMAGE_REVISION
|
||||||
|
|
||||||
|
COPY --from=builder /app/server .
|
||||||
|
|
||||||
|
# 8080 is the service's ONLY port: the API (/_manifest, /auth/*, /action/*) and
|
||||||
|
# the /ws/alarm WebSocket share it, plaintext in-container (the operator's TLS
|
||||||
|
# proxy fronts alarm.dcntrlized.org in prod).
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["./server"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-3-create-the-development-dockerfile
|
||||||
|
|
||||||
|
# Dockerfile (Development)
|
||||||
|
# This Dockerfile is optimized for development with hot reloading
|
||||||
|
# It includes Air and mounts your source code as a volume
|
||||||
|
|
||||||
|
# Use the official Go image as our base
|
||||||
|
# Alpine variant is smaller but we use the full image for better compatibility
|
||||||
|
FROM golang:1.26-bookworm
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
# All subsequent commands will run from this directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by
|
||||||
|
# the dev Gitea Go module proxy. Override via --build-arg in production.
|
||||||
|
ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go
|
||||||
|
ARG GONOSUMDB_VALUE=git.dcntrlized.org
|
||||||
|
ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct
|
||||||
|
ENV GONOSUMDB=${GONOSUMDB_VALUE}
|
||||||
|
|
||||||
|
# Install Air for hot reloading
|
||||||
|
# We install it globally so it's available in the PATH
|
||||||
|
RUN go install github.com/air-verse/air@latest
|
||||||
|
|
||||||
|
# Install additional development tools (optional but useful)
|
||||||
|
# - delve: Go debugger for debugging inside containers
|
||||||
|
# - golangci-lint: Linter for code quality checks
|
||||||
|
RUN go install github.com/go-delve/delve/cmd/dlv@latest && \
|
||||||
|
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.2
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum first for better layer caching
|
||||||
|
# This layer only rebuilds when dependencies change
|
||||||
|
COPY src/go.mod src/go.sum* ./
|
||||||
|
|
||||||
|
# Download all dependencies
|
||||||
|
# This is cached separately from source code changes
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
# Copy the Air configuration file
|
||||||
|
# This tells Air how to watch and rebuild our application
|
||||||
|
COPY ./src/.air.toml ./
|
||||||
|
|
||||||
|
# Note: We do NOT copy source code here!
|
||||||
|
# Source code is mounted as a volume in Docker Compose
|
||||||
|
# This enables hot reloading without rebuilding the image
|
||||||
|
|
||||||
|
# Expose the application port
|
||||||
|
# Make sure this matches your application's listening port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Expose delve debugger port (optional, for debugging)
|
||||||
|
EXPOSE 2345
|
||||||
|
|
||||||
|
# Set environment variables for development
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
|
||||||
|
# Default command: run Air for hot reloading
|
||||||
|
# Air will watch for changes and rebuild automatically
|
||||||
|
CMD ["air", "-c", ".air.toml"]
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
ARG IMAGE_VERSION=dev
|
||||||
|
ARG IMAGE_REVISION=
|
||||||
|
|
||||||
|
LABEL org.dcntrlized.service=Alarm
|
||||||
|
LABEL org.dcntrlized.port=8080
|
||||||
|
LABEL org.opencontainers.image.version=$IMAGE_VERSION
|
||||||
|
LABEL org.opencontainers.image.revision=$IMAGE_REVISION
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/env bash
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE_NAME="alarm"
|
||||||
|
DOCKERFILE="Dockerfile"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--debug" ]]; then
|
||||||
|
DOCKERFILE="Dockerfile.debug"
|
||||||
|
IMAGE_NAME="alarm:debug"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo dev)"
|
||||||
|
REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
docker build --network=host -t "$IMAGE_NAME" -f "$DOCKERFILE" --build-arg IMAGE_VERSION="$VERSION" --build-arg IMAGE_REVISION="$REVISION" .
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
\c dcntrlized;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Alarm service v1 schema (contract frozen 2026-10-18).
|
||||||
|
--
|
||||||
|
-- Identity comes from the shared dcntrlized-auth service: every *_user_id
|
||||||
|
-- column is a plain UUID pointing at auth.users(id) -- NO foreign key, so the
|
||||||
|
-- alarm schema never depends on the auth service's internals (platform
|
||||||
|
-- convention, see Calendar/KeepNote).
|
||||||
|
--
|
||||||
|
-- The whole file is idempotent: every statement is guarded, so the compose
|
||||||
|
-- `migrate` one-shot can re-apply it against an already-migrated database.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS alarm;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Guarded enum types. CREATE TYPE has no IF NOT EXISTS, so each one is wrapped
|
||||||
|
-- in a DO block that checks pg_type/pg_namespace first.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_type t
|
||||||
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||||
|
WHERE t.typname = 'alert_status' AND n.nspname = 'alarm'
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE alarm.alert_status AS ENUM ('armed', 'triggered', 'disarmed');
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_type t
|
||||||
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||||
|
WHERE t.typname = 'event_kind' AND n.nspname = 'alarm'
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE alarm.event_kind AS ENUM ('trigger', 'disarm');
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- alerts: one row per alert created by an admin (user A).
|
||||||
|
--
|
||||||
|
-- admin_token_hash sha256(adminToken); the plaintext admin token is returned
|
||||||
|
-- exactly once at creation and never stored.
|
||||||
|
-- subscribe_token stored PLAINTEXT on purpose: it is shared through the QR
|
||||||
|
-- code, and rotating it is a cheap UPDATE (token_version).
|
||||||
|
-- status last COMMANDED state. `active` is derived, never stored:
|
||||||
|
-- active = status = 'triggered' AND (auto_stop_seconds = 0
|
||||||
|
-- OR now() < triggered_at + auto_stop_seconds).
|
||||||
|
-- Deriving it keeps auto-stop stateless and multi-instance
|
||||||
|
-- safe (no timers, no background sweep).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS alarm.alerts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
admin_user_id UUID NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
status alarm.alert_status NOT NULL DEFAULT 'armed',
|
||||||
|
admin_token_hash BYTEA NOT NULL,
|
||||||
|
subscribe_token VARCHAR(128) NOT NULL,
|
||||||
|
token_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
auto_stop_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
triggered_at TIMESTAMPTZ,
|
||||||
|
disarmed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT alerts_auto_stop_seconds_range CHECK (auto_stop_seconds >= 0 AND auto_stop_seconds <= 86400),
|
||||||
|
CONSTRAINT alerts_token_version_positive CHECK (token_version >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- subscriptions: one row per (alert, subscriber user, device). A user may
|
||||||
|
-- subscribe several devices; the same device is idempotent.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS alarm.subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
|
||||||
|
subscriber_user_id UUID NOT NULL,
|
||||||
|
device_id VARCHAR(128) NOT NULL,
|
||||||
|
device_label VARCHAR(128),
|
||||||
|
platform VARCHAR(32),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_seen_at TIMESTAMPTZ,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id, device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- alarm_events: append-only trigger/disarm log. It is the source of truth for
|
||||||
|
-- alarmStatus (the polling fallback) and for the WS catch-up frame.
|
||||||
|
--
|
||||||
|
-- The AFTER INSERT trigger publishes the event on the `alarm_events` NOTIFY
|
||||||
|
-- channel, so the fan-out reaches EVERY service instance. NOTIFY is delivered
|
||||||
|
-- on COMMIT, so a frame can never be observed before the alert state change is
|
||||||
|
-- visible.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS alarm.alarm_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
|
||||||
|
kind alarm.event_kind NOT NULL,
|
||||||
|
actor_user_id UUID NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Indexes. idx_alarm_events_latest backs the alarmStatus single-row lookup
|
||||||
|
-- (alert_id, newest first); the others back the ownership/subscriber checks.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_admin_user_id ON alarm.alerts(admin_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_alert_id ON alarm.subscriptions(alert_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON alarm.subscriptions(subscriber_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alarm_events_alert_id ON alarm.alarm_events(alert_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alarm_events_latest ON alarm.alarm_events(alert_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- modified maintenance for alerts.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION alarm.update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.modified = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_alerts_modtime ON alarm.alerts;
|
||||||
|
CREATE TRIGGER update_alerts_modtime
|
||||||
|
BEFORE UPDATE ON alarm.alerts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION alarm.update_updated_at_column();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Cross-instance fan-out. The payload is the transport form of the WS frame:
|
||||||
|
-- the bridge formats serverTime from createdAtMs, so the in-process fast path
|
||||||
|
-- and the NOTIFY path agree on the wire shape.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION alarm.notify_alarm_event()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
auto_stop INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT a.auto_stop_seconds INTO auto_stop FROM alarm.alerts a WHERE a.id = NEW.alert_id;
|
||||||
|
|
||||||
|
PERFORM pg_notify('alarm_events', json_build_object(
|
||||||
|
'alertId', NEW.alert_id::text,
|
||||||
|
'eventId', NEW.id::text,
|
||||||
|
'action', NEW.kind::text,
|
||||||
|
'message', COALESCE(NEW.message, ''),
|
||||||
|
'autoStopSeconds', COALESCE(auto_stop, 0),
|
||||||
|
'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint
|
||||||
|
)::text);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notify_alarm_event ON alarm.alarm_events;
|
||||||
|
CREATE TRIGGER notify_alarm_event
|
||||||
|
AFTER INSERT ON alarm.alarm_events
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION alarm.notify_alarm_event();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Least-privilege role, mirroring the Calendar/KeepNote house pattern. The
|
||||||
|
-- shared database currently authenticates as the shared `postgres` role
|
||||||
|
-- (contract rev 9), so these grants are inert-but-ready; they are idempotent
|
||||||
|
-- and safe to re-run.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'alarm_user') THEN
|
||||||
|
CREATE USER alarm_user WITH PASSWORD 'alarm';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE dcntrlized TO alarm_user;
|
||||||
|
GRANT USAGE ON SCHEMA alarm TO alarm_user;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA alarm TO alarm_user;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA alarm TO alarm_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA alarm GRANT ALL ON TABLES TO alarm_user;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Dependencies for the database/seeds/*.py scripts. Installed by the
|
||||||
|
# compose 'seed' service (and used for host-side seeding via setup.py).
|
||||||
|
psycopg2-binary
|
||||||
|
bcrypt
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Example seed for the shared dcntrlized database.
|
||||||
|
|
||||||
|
Seeds run automatically after migrations complete, via the compose 'seed'
|
||||||
|
service: database/seeds is mounted read-only into a python container and every
|
||||||
|
*.py file here is executed in sorted order. Connection settings come from the
|
||||||
|
PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE environment variables set by that
|
||||||
|
service (psycopg2.connect() reads them implicitly).
|
||||||
|
|
||||||
|
Keep every seed idempotent: it re-runs on every `docker compose up` (use
|
||||||
|
ON CONFLICT DO NOTHING / IF NOT EXISTS style upserts, stable uuids, ...).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = psycopg2.connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT 1")
|
||||||
|
cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print("001_seed_example.py: no-op seed. Replace with real seeds.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Dev</title>
|
||||||
|
<link rel="stylesheet" href="main.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
function handleFormSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
fetch(form.action, {
|
||||||
|
method: form.method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
console.log("Success: " + JSON.stringify(data));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<form
|
||||||
|
id="login"
|
||||||
|
action="http://127.0.0.1:8080/auth/login"
|
||||||
|
method="post"
|
||||||
|
onsubmit="handleFormSubmit(event)"
|
||||||
|
>
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input type="text" id="username" name="username" required />
|
||||||
|
<br />
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required />
|
||||||
|
<br />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
id="register"
|
||||||
|
action="http://127.0.0.1:8080/auth/register"
|
||||||
|
method="post"
|
||||||
|
onsubmit="handleFormSubmit(event)"
|
||||||
|
>
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input type="text" id="username" name="username" required />
|
||||||
|
<br />
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required />
|
||||||
|
<br />
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-4-set-up-docker-compose
|
||||||
|
|
||||||
|
# docker-compose.yml
|
||||||
|
# Development environment with hot reloading
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Main Go application with Air hot reloading
|
||||||
|
alarm-debug:
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
image: alarm:debug
|
||||||
|
container_name: alarm-debug
|
||||||
|
|
||||||
|
# Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image
|
||||||
|
# directly — no pre-build step needed. Do NOT add `network: host` here:
|
||||||
|
# `docker compose up`/`build` cannot grant the `network.host` build
|
||||||
|
# entitlement (that needs `--allow=network.host`, which only `docker build`
|
||||||
|
# accepts), so a cold up would fail with "additional privileges requested".
|
||||||
|
# The default bridge reaches the dev Gitea (192.168.122.183), so
|
||||||
|
# Dockerfile.debug's GOPROXY resolves shared modules without host networking.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.debug
|
||||||
|
|
||||||
|
# `alarm:debug` is a local-only tag: build it, never look it up in a
|
||||||
|
# registry (otherwise a cold `up` warns "pull access denied").
|
||||||
|
pull_policy: build
|
||||||
|
|
||||||
|
|
||||||
|
# Expose ports: host:container
|
||||||
|
ports:
|
||||||
|
# Frozen v1 contract: host 8088 -> container 8080 (single port: API +
|
||||||
|
# /ws/alarm).
|
||||||
|
- "8088:8080"
|
||||||
|
# Delve: unique host port per service (auth-debug 2345, keepnote-debug
|
||||||
|
# 2346, calendar-debug 2347, store 2348), so the Go attach launch config
|
||||||
|
# reaches Air's Delve listener (in-container 2345) from the host.
|
||||||
|
- "2349:2345" # Delve debugger port
|
||||||
|
|
||||||
|
|
||||||
|
# Volume mounts - THE KEY TO HOT RELOADING
|
||||||
|
# Mount source code as a volume so changes are reflected immediately
|
||||||
|
volumes:
|
||||||
|
- ./src:/app
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- /app/src/tmp # Exclude Air's tmp dir from syncing
|
||||||
|
|
||||||
|
working_dir: /app/
|
||||||
|
|
||||||
|
# Environment variables for the application
|
||||||
|
environment:
|
||||||
|
# DCNTRLIZED_DEV is the single dev selector (registry/auth URLs).
|
||||||
|
- DCNTRLIZED_DEV=true
|
||||||
|
# Contract rev 9: internal traffic is plaintext, so the debug selector no
|
||||||
|
# longer gates any listener — this listener is ALWAYS plaintext now.
|
||||||
|
- DCNTRLIZED_DEBUG=true
|
||||||
|
|
||||||
|
# Shared auth service target (dcntrlized-auth on the dcntrlized network)
|
||||||
|
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
|
||||||
|
|
||||||
|
# Service identity (inert in v1 — no /config edge — kept for fleet parity)
|
||||||
|
- DCNTRLIZED_SERVICE_NAME=alarm
|
||||||
|
|
||||||
|
# Database connection (shared dcntrlized-db, scram-sha-256, sslmode=disable)
|
||||||
|
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
|
||||||
|
|
||||||
|
# Go specific settings
|
||||||
|
- GO111MODULE=on
|
||||||
|
- GOPROXY=http://192.168.122.183:3000/api/packages/Dcntrlized/go,https://proxy.golang.org,direct
|
||||||
|
- GONOSUMDB=git.dcntrlized.org
|
||||||
|
depends_on:
|
||||||
|
- migrate
|
||||||
|
|
||||||
|
# Restart policy for development
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Network for service communication
|
||||||
|
networks:
|
||||||
|
- dcntrlized_net
|
||||||
|
|
||||||
|
cap_add:
|
||||||
|
- SYS_PTRACE
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
|
migrate:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: alarm-migrate
|
||||||
|
labels:
|
||||||
|
org.dcntrlized.oneoff: "true"
|
||||||
|
networks:
|
||||||
|
- dcntrlized_net
|
||||||
|
volumes:
|
||||||
|
- ./database/migrations:/migrations:ro
|
||||||
|
environment:
|
||||||
|
- PGHOST=dcntrlized-db
|
||||||
|
- PGPORT=5432
|
||||||
|
- PGUSER=postgres
|
||||||
|
- PGPASSWORD=
|
||||||
|
- PGDATABASE=dcntrlized
|
||||||
|
# Role-agnostic (no -U/-d): Maestro injects the shared credentials/env.
|
||||||
|
command: >
|
||||||
|
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
|
||||||
|
for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done'
|
||||||
|
|
||||||
|
# Named volumes for data persistence and caching
|
||||||
|
volumes:
|
||||||
|
# Go module cache - shared across rebuilds
|
||||||
|
go-mod-cache:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# Go build cache - speeds up compilation
|
||||||
|
go-build-cache:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dcntrlized_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
alarm-service:
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
# Deliberately no build: block — compose-from-git pulls this image
|
||||||
|
# (Calendar still carries one; do not copy it here). A prod compose that
|
||||||
|
# can build invites a silent local build when the registry pull fails.
|
||||||
|
image: 192.168.122.183:3000/dcntrlized/alarm:dev
|
||||||
|
container_name: alarm
|
||||||
|
networks:
|
||||||
|
- dcntrlized_net
|
||||||
|
expose:
|
||||||
|
# Single port: the API (/_manifest, /auth/*, /action/*) and the
|
||||||
|
# /ws/alarm WebSocket all share :8080. Expose-only so the operator's TLS
|
||||||
|
# proxy (NPM-private) can front it on alarm.dcntrlized.org without a
|
||||||
|
# host port.
|
||||||
|
- "8080"
|
||||||
|
ports:
|
||||||
|
# Frozen v1 contract: host 8088 -> container 8080.
|
||||||
|
- "8088:8080"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
|
||||||
|
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
|
||||||
|
# Service identity (same value Maestro injects for the /config ES256
|
||||||
|
# token audience in Calendar/KeepNote). Alarm mounts no /config edge in
|
||||||
|
# v1, so this is inert today and correct if one is added later.
|
||||||
|
- DCNTRLIZED_SERVICE_NAME=alarm
|
||||||
|
depends_on:
|
||||||
|
- migrate
|
||||||
|
migrate:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: alarm-migrate
|
||||||
|
labels:
|
||||||
|
org.dcntrlized.oneoff: "true"
|
||||||
|
networks:
|
||||||
|
- dcntrlized_net
|
||||||
|
volumes:
|
||||||
|
- ./database/migrations:/migrations:ro
|
||||||
|
environment:
|
||||||
|
- PGHOST=dcntrlized-db
|
||||||
|
- PGPORT=5432
|
||||||
|
- PGUSER=postgres
|
||||||
|
- PGPASSWORD=
|
||||||
|
- PGDATABASE=dcntrlized
|
||||||
|
command: >
|
||||||
|
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
|
||||||
|
for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dcntrlized_net:
|
||||||
|
external: true
|
||||||
+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