diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d87f1c4..867d682e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,7 @@ api/ │ │ ├── lightning-address.ts # GET /lightning-address (public LUD-16 resolve) │ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id; POST /debug/accounts/:id/session (DEBUG_TOKEN) │ │ ├── debug-contacts.ts # GET /debug/contacts (operator DEBUG_TOKEN) +│ │ ├── debug-messages.ts # PUT /debug/messages/:id/video (operator DEBUG_TOKEN) │ │ ├── debug-payments.ts # GET /debug/invoices; GET /debug/zap-ingests (DEBUG_TOKEN) │ │ ├── debug-push.ts # POST /debug/push-ping (operator DEBUG_TOKEN) │ │ ├── push.ts # GET /push/vapid-public; POST/DELETE /me/push-subscriptions @@ -176,6 +177,7 @@ api/ │ ├── contact.test.ts │ ├── conversations.test.ts │ ├── debug-contacts.test.ts +│ ├── debug-messages.test.ts │ ├── debug-payments.test.ts │ ├── push.test.ts │ ├── debug-push.test.ts @@ -195,7 +197,7 @@ api/ ├── scripts/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 │ ├── check-e2e.mjs # CI gate: missing endpoint request or Function: title → exit 1 -│ └── gifts-debug.sh # Operator CLI: list, set role, unlink Lightning Address (DEBUG_TOKEN) +│ └── gifts-debug.sh # Operator CLI: list, set role, unlink Lightning Address, video-put (DEBUG_TOKEN) ├── e2e/ │ ├── http.spec.ts # Playwright endpoint smokes against bun src/index.ts │ ├── forum-replies.spec.ts # Playwright: provision, session, note, public GET, reply, replyCount @@ -391,7 +393,7 @@ Currently: | `BIND_ADDR` | `0.0.0.0:3000` | Listen address | | `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | | `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `message` (plus `message_invoice` and `nostr_zap_ingest`), `contact`, `conversation` / `conversation_message`, `push_subscription`, `push_outbox`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id`, `GET /messages/:id/replies`, `GET /messages/:id/photo`, and `GET /messages/:id/video.*` (MIME in Postgres, bytes under `MEDIA_DIR`) use `PostgresMessageStore`, `POST /contact` / `GET /debug/contacts` use `PostgresContactStore`, `GET/POST /conversations` and `GET/POST /conversations/:id` use `PostgresConversationStore`, `GET /debug/invoices` and `GET /debug/zap-ingests` list invoice attempts and zap ingest rows, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, conversation, and push stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, and `POST /debug/push-ping`. Unset or blank → `503`; the process still boots. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, `PUT /debug/messages/:id/video`, and `POST /debug/push-ping`. Unset or blank → `503`; the process still boots. | | `NIP57_PROBE` | _(unset → probe on)_ | Set to `0` to skip the NIP-57 mint probe on `POST /debug/accounts` new addresses (Playwright e2e only). Unset or any other value probes. Production must not set this. The process still boots. | | `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | | `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | diff --git a/SPEC.md b/SPEC.md index 2b52dcba..e6a4a79e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-31 (`POST /debug/accounts/:id/session`; platform-thread retarget; NIP-17 rumor `created_at`; GET replies `{ messages }`). +**Status**: living document. Last revised 2026-09-01 (`PUT /debug/messages/:id/video`). --- @@ -95,6 +95,7 @@ Public base URLs used in examples: | GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | | GET | `/debug/invoices` | `Authorization: Bearer` | Operator forum invoice attempts (`DEBUG_TOKEN`) | | GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | +| PUT | `/debug/messages/:id/video` | `Authorization: Bearer` | Operator restore of missing forum-video bytes (`DEBUG_TOKEN`) | | GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | | POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | | DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | @@ -958,6 +959,69 @@ Environment: | `DATABASE_URL` | When set, ingest rows are stored in Postgres `nostr_zap_ingest`. | | `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | +### `PUT /debug/messages/:id/video` + +Operator restore of missing forum-video bytes for an **existing** +`hasVideo` row. Authenticated with `Authorization: Bearer` matching +`DEBUG_TOKEN`. This is not an end-user session. The raw body is +`decodeForumVideo`'d and written under `MEDIA_DIR`; the handler does +not insert a message row or change columns. + +`DEBUG_TOKEN` unset or blank → **Response** `503`: + +```json +{ "error": "Debug is not configured" } +``` + +Missing or non-matching bearer → **Response** `401` (checked before the +body is read): + +```json +{ "error": "Unauthorized" } +``` + +Non-UUID or unknown id → **Response** `404`: + +```json +{ "error": "Not found" } +``` + +Row has no video, or stored MIME missing → **Response** `409`: + +```json +{ "error": "Message has no video" } +``` + +Decoded type extension does not match the stored MIME → **Response** +`409`: + +```json +{ "error": "Video type does not match" } +``` + +Empty, oversize, or unrecognized bytes → **Response** `400`: + +```json +{ "error": "Expected a video body" } +``` + +Store or disk write throws → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + +Success → **Response** `204` with an empty body. Public +`GET /messages/:id/video.mp4` (or `.mov` / `.webm` matching the stored +type) can then serve the file. + +Environment: + +| Variable | Meaning | +| ------------- | ----------------------------------------------------------------- | +| `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | +| `MEDIA_DIR` | Directory the bytes are written to. Required at boot. | + ### `GET /push/vapid-public` Bearer session. Returns the VAPID **public** key the browser needs for diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index b8f0e9d2..8e61af72 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -91,6 +91,13 @@ - **Used by:** Operators debugging zap receipt indexing. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. +## Endpoint: PUT /debug/messages/:id/video + +- **Purpose:** Operator restore of missing forum-video bytes for an existing message with `hasVideo`. Raw body is validated (`decodeForumVideo`), must match the stored MIME extension, and is written under `MEDIA_DIR` so public `GET /messages/:id/video.*` can serve it. Does not create a new message id or change the DB row. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match (checked before the body is read); 404 `{ error: 'Not found' }` for a non-UUID or unknown id; 409 `{ error: 'Message has no video' }` when `hasVideo` is not true or `videoContentType` is missing; 409 `{ error: 'Video type does not match' }` when the decoded type's extension differs from the stored MIME; 400 `{ error: 'Expected a video body' }` for empty, oversize, or unrecognized bytes; 503 `{ error: 'Messages are unavailable' }` when the store or disk write throws (`debug.messages.video.put_failed`). +- **Used by:** Operators restoring a missing on-disk forum video without SSH (`gifts-debug video-put`). +- **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. + ## Endpoint: GET /push/vapid-public - **Purpose:** Bearer session. Returns `{ publicKey }` (URL-safe base64 VAPID public) so the app can subscribe. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 3077fa25..54c9bc51 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -229,7 +229,7 @@ - **Purpose:** Constant-time compare of `DEBUG_TOKEN` against `Authorization: Bearer`. - **Inputs:** Configured token (non-empty) and raw header or `undefined`. - **Returns / side effects:** `true` only on an exact Bearer match (trim on the presented token). -- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugPaymentsRoutes`, `debugPushRoutes`. +- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugMessagesRoutes`, `debugPaymentsRoutes`, `debugPushRoutes`. ## Function: compareAccountsForList @@ -252,6 +252,13 @@ - **Returns / side effects:** Hono app. 503 if token unset; 401 if bearer mismatches; 200 `{ contacts }` newest-first (cap 200); 503 on store throw (`contact.list.failed`). Logs `debug.contacts.listed` with count, never the token. - **Used by:** `createApp` at `/debug/contacts`. +## Function: debugMessagesRoutes + +- **Purpose:** Operator restore of a missing forum-video file for an already-existing message with `hasVideo` (raw body under `MEDIA_DIR`; no new message id, no DB create). +- **Inputs:** `DebugMessagesRouteDeps`: message store, optional debugToken. +- **Returns / side effects:** Hono app exposing `PUT /:id/video`. 503 if token unset/blank; 401 if bearer mismatches (before the body is read); 404 for non-UUID or unknown id; 409 when the row has no video or the decoded MIME extension does not match the stored type; 400 for empty/oversize/unrecognized body; 204 after `writeForumVideo`; 503 `{ error: 'Messages are unavailable' }` when `getById` or `writeForumVideo` throws (`debug.messages.video.put_failed`). Logs `debug.messages.video.put` with `messageId` and `bytes`, never the token or raw bytes. +- **Used by:** `createApp` at `/debug/messages`. + ## Function: debugPaymentsRoutes - **Purpose:** Operator listing of forum invoice attempts (`message_invoice`) and kind:9735 ingest decisions (`nostr_zap_ingest`). @@ -541,7 +548,7 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, and invoices. +- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/messages`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `messageStore`, `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), `pushStore`, `vapidPublicKey`, `nostrKek`, spendApiToken, invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresPushStore`, and parsed KEK. Does not take a push sender (worker owns delivery). - **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. - **Used by:** Boot path and every HTTP test. @@ -1357,5 +1364,5 @@ - **Purpose:** Persist video bytes under `MEDIA_DIR` (caller should already faststart MP4/MOV via `decodeForumVideo`). - **Inputs:** message id, video, env. -- **Returns / side effects:** mkdir + writeFile. -- **Used by:** `MessageStore.create`. +- **Returns / side effects:** mkdir, write UUID sibling temp, `rename` onto the public path so readers never see a partial file. +- **Used by:** `MessageStore.create`; `debugMessagesRoutes`. diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 0f40e678..eef858fb 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -414,6 +414,13 @@ test('Function: debugContactsRoutes — GET /debug/contacts without bearer is 40 expect(res.status()).toBe(401); }); +test('Function: debugMessagesRoutes — PUT /debug/messages/:id/video without bearer is 401', async ({ + request, +}) => { + const res = await request.put('/debug/messages/:id/video'); + expect(res.status()).toBe(401); +}); + test('Function: debugPaymentsRoutes — GET /debug/invoices without bearer is 401', async ({ request, }) => { diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index babe4a30..d98d8c60 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -287,6 +287,11 @@ test('GET /debug/contacts without bearer is 401', async ({ request }) => { expect(res.status()).toBe(401); }); +test('PUT /debug/messages/:id/video without bearer is 401', async ({ request }) => { + const res = await request.put('/debug/messages/:id/video'); + expect(res.status()).toBe(401); +}); + test('GET /debug/contacts with the e2e token lists contacts', async ({ request }) => { const res = await request.get('/debug/contacts', { headers: { authorization: 'Bearer e2e-debug-token' }, diff --git a/scripts/check-handbook.mjs b/scripts/check-handbook.mjs index bf507681..f1451a86 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -136,6 +136,7 @@ function extractEndpoints() { 'push.ts': '', 'debug.ts': '/debug/accounts', 'debug-contacts.ts': '/debug/contacts', + 'debug-messages.ts': '/debug/messages', 'debug-payments.ts': '/debug', 'debug-push.ts': '/debug/push-ping', 'well-known.ts': '/.well-known', diff --git a/scripts/gifts-debug.sh b/scripts/gifts-debug.sh index 25cd7b50..c2e8ae61 100755 --- a/scripts/gifts-debug.sh +++ b/scripts/gifts-debug.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # -# gifts-debug — operator listing, role assignment, and Lightning Address unlink -# for 21.gifts accounts (GET /debug/accounts, PATCH /debug/accounts/:id). -# No raw SQL. +# gifts-debug — operator listing, role assignment, Lightning Address unlink, +# and forum-video restore for 21.gifts +# (GET /debug/accounts, PATCH /debug/accounts/:id, +# PUT /debug/messages/:id/video). No raw SQL. # # Credentials (never in this script, never printed): # ~/.config/21gifts/debug.env -> DEBUG_TOKEN, DEBUG_API_URL @@ -13,12 +14,14 @@ # gifts-debug accounts [--raw] # table (default) or JSON # gifts-debug role # set account.role; print updated account JSON # gifts-debug unlink # hard-delete Lightning Address; print updated account JSON +# gifts-debug video-put # PUT video bytes for message id; 204 on success # # Example: # gifts-debug accounts # gifts-debug accounts --raw # gifts-debug role moderator # gifts-debug unlink +# gifts-debug video-put ./clip.mp4 # set -euo pipefail @@ -134,6 +137,28 @@ cmd_unlink() { printf '%s\n' "$body" } +cmd_video_put() { + local id="${1:-}" path="${2:-}" tmp status body + [ -n "$id" ] || die "usage: gifts-debug video-put " + [ -n "$path" ] || die "usage: gifts-debug video-put " + [ -f "$path" ] || die "file not found: ${path}" + tmp=$(mktemp) + status=$(curl -sS -o "$tmp" -w '%{http_code}' \ + -X PUT \ + -H "Authorization: Bearer ${DEBUG_TOKEN}" \ + --data-binary @"${path}" \ + "${DEBUG_API_URL}/debug/messages/${id}/video") || { + rm -f "$tmp" + die "request failed" + } + body=$(cat "$tmp") + rm -f "$tmp" + printf '%s\n' "$status" >&2 + if [ "$status" != "204" ]; then + die "HTTP ${status}: ${body}" + fi +} + ARGS=() while [ $# -gt 0 ]; do case "$1" in @@ -151,6 +176,7 @@ case "${1:-}" in accounts) cmd_accounts ;; role) shift; cmd_role "$@" ;; unlink) shift; cmd_unlink "$@" ;; + video-put) shift; cmd_video_put "$@" ;; ""|-h|--help) usage 0 ;; *) die "unknown command: $1" ;; esac diff --git a/src/__tests__/routes/debug-messages.test.ts b/src/__tests__/routes/debug-messages.test.ts new file mode 100644 index 00000000..56ded008 --- /dev/null +++ b/src/__tests__/routes/debug-messages.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { unsignedNostrDefaults, type MessageRow } from '@/lib/message'; +import { InMemoryMessageStore, type MessageStore } from '@/lib/message-store'; +import { + MESSAGE_VIDEO_MAX_BYTES, + readForumVideoBytes, + resolveMediaDir, + videoFilePath, +} from '@/lib/video'; +import { debugMessagesRoutes } from '@/routes/debug-messages'; + +function parsedEvents(warn: ReturnType): Array> { + return warn.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === 'string' && arg.startsWith('{')) + .map((arg) => JSON.parse(arg) as Record); +} + +function mp4Bytes(): Uint8Array { + const bytes = new Uint8Array(32); + bytes.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + return bytes; +} + +function movBytes(): Uint8Array { + const bytes = mp4Bytes(); + bytes.set([0x71, 0x74, 0x20, 0x20], 8); + return bytes; +} + +const VIDEO_ID = '00000000-0000-4000-8000-000000000001'; + +function videoRow(overrides: Partial = {}): MessageRow { + return { + id: VIDEO_ID, + accountId: '00000000-0000-4000-8000-0000000000aa', + name: 'Ada', + text: 'clip', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + ...overrides, + }; +} + +function mount(store: MessageStore, debugToken: string | undefined): Hono { + return new Hono().route('/debug/messages', debugMessagesRoutes({ store, debugToken })); +} + +describe('debugMessagesRoutes', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 503 when debug is not configured', async () => { + const app = mount(new InMemoryMessageStore(), undefined); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { method: 'PUT' }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Debug is not configured' }); + }); + + it('returns 503 when the token is blank', async () => { + const app = mount(new InMemoryMessageStore(), ' '); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer ' }, + }); + expect(res.status).toBe(503); + }); + + it('returns 401 without a matching bearer', async () => { + const app = mount(new InMemoryMessageStore(), 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { method: 'PUT' }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 401 for a wrong bearer even when a body is present', async () => { + const app = mount(new InMemoryMessageStore([videoRow()]), 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer wrong' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 404 for a non-UUID id with a valid bearer', async () => { + const app = mount(new InMemoryMessageStore(), 'secret'); + const res = await app.request('/debug/messages/not-a-uuid/video', { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns 404 for an unknown id with a valid bearer', async () => { + const app = mount(new InMemoryMessageStore(), 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns 409 when the message has no video', async () => { + const store = new InMemoryMessageStore([videoRow({ hasVideo: false, videoContentType: null })]); + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Message has no video' }); + }); + + it('returns 409 when hasVideo is true but videoContentType is null', async () => { + const store = new InMemoryMessageStore([videoRow({ hasVideo: true, videoContentType: null })]); + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Message has no video' }); + }); + + it('returns 400 for an empty body', async () => { + const store = new InMemoryMessageStore([videoRow()]); + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: new Uint8Array(), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a video body' }); + }); + + it('returns 400 for garbage bytes', async () => { + const store = new InMemoryMessageStore([videoRow()]); + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: new Uint8Array([1, 2, 3, 4]), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a video body' }); + }); + + it('returns 400 for an oversize body', async () => { + const store = new InMemoryMessageStore([videoRow()]); + const app = mount(store, 'secret'); + const huge = new Uint8Array(MESSAGE_VIDEO_MAX_BYTES + 1); + huge.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: huge, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a video body' }); + }); + + it('returns 409 when the decoded type does not match the stored MIME', async () => { + const store = new InMemoryMessageStore([ + videoRow({ hasVideo: true, videoContentType: 'video/mp4' }), + ]); + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: movBytes(), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Video type does not match' }); + }); + + it('writes the video file and returns 204 on the happy path', async () => { + const store = new InMemoryMessageStore([videoRow()]); + const app = mount(store, 'secret'); + const body = mp4Bytes(); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body, + }); + expect(res.status).toBe(204); + expect(await res.text()).toBe(''); + + const path = videoFilePath(resolveMediaDir(), VIDEO_ID, 'video/mp4'); + const onDisk = await readForumVideoBytes(path); + expect(onDisk[4]).toBe(0x66); + expect(onDisk[5]).toBe(0x74); + expect(onDisk[6]).toBe(0x79); + expect(onDisk[7]).toBe(0x70); + + expect( + parsedEvents(warn).some( + (e) => + e['event'] === 'debug.messages.video.put' && + e['messageId'] === VIDEO_ID && + typeof e['bytes'] === 'number' && + (e['bytes'] as number) > 0, + ), + ).toBe(true); + }); + + it('returns 503 and logs when getById throws', async () => { + const store = { + getById: async () => { + throw new Error('boom'); + }, + } as unknown as MessageStore; + const app = mount(store, 'secret'); + const res = await app.request(`/debug/messages/${VIDEO_ID}/video`, { + method: 'PUT', + headers: { authorization: 'Bearer secret' }, + body: mp4Bytes(), + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Messages are unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'debug.messages.video.put_failed')).toBe( + true, + ); + }); +}); diff --git a/src/lib/video.ts b/src/lib/video.ts index 2237f69a..8c62ce54 100644 --- a/src/lib/video.ts +++ b/src/lib/video.ts @@ -475,6 +475,8 @@ export function decodeForumVideo(bytes: Uint8Array): ForumVideo | null { /** * Write video bytes to `MEDIA_DIR`. Creates the directory when missing. + * Writes a UUID sibling temp then `rename`s onto the public path so readers + * never see a partial file. * * @param messageId - Message id (filename stem). * @param video - Validated video. @@ -487,7 +489,10 @@ export async function writeForumVideo( ): Promise { const dir = resolveMediaDir(env); await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(videoFilePath(dir, messageId, video.contentType), video.bytes); + const dest = videoFilePath(dir, messageId, video.contentType); + const tempPath = join(dir, `.${basename(dest)}.${crypto.randomUUID()}.tmp`); + await fs.writeFile(tempPath, video.bytes); + await fs.rename(tempPath, dest); } /** diff --git a/src/routes/debug-messages.ts b/src/routes/debug-messages.ts new file mode 100644 index 00000000..8143061f --- /dev/null +++ b/src/routes/debug-messages.ts @@ -0,0 +1,86 @@ +/** + * Operator debug surface. Restore a missing forum-video file for an + * already-existing message with `hasVideo`. Authenticated by `DEBUG_TOKEN` + * (Bearer), not by an end-user session. Does not create rows or change DB. + */ + +import { Hono } from 'hono'; +import { bearerMatchesDebugToken } from '@/lib/debug-token'; +import { logEvent } from '@/lib/log'; +import type { MessageStore } from '@/lib/message-store'; +import { + MESSAGE_VIDEO_MAX_BYTES, + decodeForumVideo, + forumVideoExt, + writeForumVideo, +} from '@/lib/video'; +import { MESSAGE_ID_RE } from '@/routes/messages'; + +/** Collaborators the debug message routes need. */ +export interface DebugMessagesRouteDeps { + /** Message persistence port. */ + store: MessageStore; + /** Configured operator token, or `undefined` when debug is disabled. */ + debugToken: string | undefined; +} + +/** + * Build the `/debug/messages` route group. + * + * Mounted at `/debug/messages` so the public path is `PUT /debug/messages/:id/video`. + * + * @param deps - Message store and optional debug token. + * @returns A Hono app exposing `PUT /:id/video`. + */ +export function debugMessagesRoutes(deps: DebugMessagesRouteDeps): Hono { + return new Hono().put('/:id/video', async (c) => { + const token = deps.debugToken; + if (token === undefined || token.trim() === '') { + return c.json({ error: 'Debug is not configured' }, 503); + } + if (!bearerMatchesDebugToken(token, c.req.header('authorization'))) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + + try { + const row = await deps.store.getById(id); + if (row === undefined) { + return c.json({ error: 'Not found' }, 404); + } + if ( + row.hasVideo !== true || + row.videoContentType === null || + row.videoContentType === undefined + ) { + return c.json({ error: 'Message has no video' }, 409); + } + + const bytes = new Uint8Array(await c.req.arrayBuffer()); + if (bytes.length === 0 || bytes.length > MESSAGE_VIDEO_MAX_BYTES) { + return c.json({ error: 'Expected a video body' }, 400); + } + const decoded = decodeForumVideo(bytes); + if (decoded === null) { + return c.json({ error: 'Expected a video body' }, 400); + } + if (forumVideoExt(decoded.contentType) !== forumVideoExt(row.videoContentType)) { + return c.json({ error: 'Video type does not match' }, 409); + } + + await writeForumVideo(row.id, decoded); + logEvent('debug.messages.video.put', { + messageId: row.id, + bytes: decoded.bytes.byteLength, + }); + return c.body(null, 204); + } catch { + logEvent('debug.messages.video.put_failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 747222d1..46fe3aab 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -159,7 +159,7 @@ async function authedAccount( } /** Hex UUID as stored on `message.id` (rejects values Postgres would error on). */ -const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Public photo bytes for Nostr clients. Same handler for `/photo` and diff --git a/src/server.ts b/src/server.ts index 53295e4e..f9e92187 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import { wellKnownRoutes } from '@/routes/well-known'; import { contactRoutes } from '@/routes/contact'; import { conversationRoutes } from '@/routes/conversations'; import { debugContactsRoutes } from '@/routes/debug-contacts'; +import { debugMessagesRoutes } from '@/routes/debug-messages'; import { debugPaymentsRoutes } from '@/routes/debug-payments'; import { pushRoutes } from '@/routes/push'; import { debugPushRoutes } from '@/routes/debug-push'; @@ -80,7 +81,8 @@ export interface AppDeps { * blank → `GET /debug/accounts`, `POST /debug/accounts`, * `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, * `GET /debug/contacts`, `GET /debug/invoices`, - * and `GET /debug/zap-ingests` return 503. + * `GET /debug/zap-ingests`, and `PUT /debug/messages/:id/video` + * return 503. */ debugToken?: string; /** @@ -254,6 +256,7 @@ export function createApp(deps: AppDeps = {}): Hono { debugRoutes({ store, debugToken, fetchImpl, conversationStore, now }), ); app.route('/debug/contacts', debugContactsRoutes({ store: contactStore, debugToken })); + app.route('/debug/messages', debugMessagesRoutes({ store: messageStore, debugToken })); app.route('/debug', debugPaymentsRoutes({ store: messageStore, debugToken })); app.route( '/debug/push-ping',