openvibers/openvibe.media Commits
Initial OpenVibe release
Changes
nnew file mode 100644@@@ -0,0 +1,59 @@++# ── OpenVibe.Media — environment ─────────────────────────────++# Copy to .env (or /etc/openvibe/media.env in production) and adjust.++++# HTTP++PORT=4100++HOST=0.0.0.0++NODE_ENV=development++# Public base URL used when building absolute playback/asset URLs.++MEDIA_PUBLIC_URL=https://openvibe.media++++# Database (better-sqlite3, WAL)++DB_PATH=./data/media.db++++# Storage paths (all created on boot)++VOD_PATH=./data/vods++CLIPS_PATH=./data/clips++PASTES_PATH=./data/pastes++THUMBNAILS_PATH=./data/thumbnails++FILES_PATH=./data/files++++# RTP ingest port pool (UDP, bound on 127.0.0.1 by ffmpeg)++RTP_PORT_MIN=12000++RTP_PORT_MAX=12199++++# Identity provider (OpenVibe.Network) — JWKS fetched at boot for offline++# RS256 verification of user JWTs on browser endpoints.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# Per-service key for Network's /internal/* endpoints (header X-Internal-Key).++INTERNAL_API_KEY=++++# ── Tenant (app) seeding ─────────────────────────────────────++# JSON array, upserted into the `apps` table on every boot (api_key is hashed,++# never stored in plaintext). allowed_origins guards browser (user-JWT) calls.++# MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"change-me","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"change-me-too","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'++MEDIA_APPS_SEED=++# Fallback short form (no webhooks/origins): "live:key1,games:key2"++MEDIA_APP_KEYS=++++# ── Object storage tiering (optional; local-only when unset) ─++# Backblaze B2 (canonical cold tier)++MEDIA_B2_ENDPOINT=++MEDIA_B2_REGION=us-west-004++MEDIA_B2_BUCKET=++MEDIA_B2_KEY_ID=++MEDIA_B2_APP_KEY=++# Cloudflare R2 (popularity cache, free egress)++MEDIA_R2_ENDPOINT=++MEDIA_R2_REGION=auto++MEDIA_R2_BUCKET=++MEDIA_R2_ACCESS_KEY_ID=++MEDIA_R2_SECRET_ACCESS_KEY=++++# ── Recording knobs (defaults match the inherited recorder) ──++# VOD_STOP_GRACE_MS=60000++# VOD_DISK_WARN_GB=15++# VOD_DISK_CRIT_GB=5++# MAX_VOD_SIZE_MB=2048++# VOD_NO_NORMALIZE=0nnew file mode 100644@@@ -0,0 +1,5 @@++node_modules/++data/++.env++*.log++.smoke/nnew file mode 100644@@@ -0,0 +1,223 @@++# OpenVibe.Media++++Standalone multi-tenant media service (port **4100**) extracted from the++OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering++(local → Backblaze B2 → Cloudflare R2), clips, pastes, generic files, and++thumbnails, for all OpenVibe apps (`live`, `games`, `tools`, `network`).++++Implements **Media API v1** from `../CONTRACTS.md`.++++```++npm install++cp .env.example .env # set MEDIA_APPS_SEED at minimum++npm start # node server/index.js++```++++Requires Node ≥ 20, `ffmpeg`/`ffprobe` on PATH. SQLite (better-sqlite3, WAL).++++## Layout++++```++server/++ index.js boot, route mounts, background jobs, graceful shutdown++ config.js env config (PORT, DB_PATH, *_PATH, MEDIA_B2_*/R2_*, RTP pool, OV_NETWORK_URL)++ auth.js tenancy + auth middleware, JWKS fetch, app seeding++ webhooks.js HMAC-signed outbound webhooks++ db/schema.sql apps, vods, clips, pastes(+likes/comments), files, content_views, media_settings++ db/database.js better-sqlite3 helpers, all app_id-scoped; legacy row importer++ vod/recorder.js ffmpeg recording: RTMP pull + RTP (SDP) ingest, codec passthrough++ vod/media-tools.js probes, seekable remux, DVR sidecar, chunk-segment concat++ vod/finalize.js finalize pipeline (remux → probe → master recovery → thumbnail → webhook)++ vod/routes.js /api/v1/:app/vods (+ ingest + chunks)++ vod/clip-cutter.js re-encoding clip cutter (local file or presigned URL source)++ vod/clips-routes.js /api/v1/:app/clips++ vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI++ vod/health-scanner.js probe/decode/master-recovery primitives++ vod/health-job.js background health scan + quarantine cleanup + master sweep++ pastes/routes.js /api/v1/:app/pastes++ files/routes.js /api/v1/:app/files++ thumbnails/ thumbnail service + /api/v1/:app/thumbnails++ public/routes.js public /v /c /p /t /f++vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)++scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)++```++++## Tenancy & auth++++Tenant = the `:app` path segment. Rows in the `apps` table define tenants:++++| column | meaning |++|---|---|++| `app_id` | tenant id (`live`, `games`, …) |++| `api_key_hash` | sha256 of the app API key (never stored in plaintext) |++| `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) |++| `allowed_origins` | JSON array; guards browser (user-JWT) calls |++| `quota_bytes` | files-subsystem quota (0 = unlimited) |++++Two credential types on `/api/v1/:app/...`:++++1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared in++ constant time against the app's stored hash. A key is only valid for its++ own `:app` segment; presenting another app's key returns **403**.++2. **Network user JWT** (browser endpoints only — chunks/complete, clips,++ pastes, files, thumbnails) — RS256, verified **offline** against the JWKS++ public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot++ (cached, refreshed every 6 h). `aud` must include `openvibe.media` when++ present. If the request carries an `Origin` header it must be in the app's++ `allowed_origins` (CORS is reflected for allow-listed origins only).++++### App seeding++++On every boot the service upserts apps from env (keys are hashed):++++```bash++MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'++# fallback short form (no webhooks/origins):++MEDIA_APP_KEYS="live:key1,games:key2"++```++++## API (all under `/api/v1/:app`)++++### VODs++++| method | path | notes |++|---|---|---|++| POST | `/vods` | `{ title?, stream_id?, stream_key?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |++| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4` |++| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |++| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |++| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |++| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |++| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |++| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, … }` |++| GET | `/vods?limit&offset&user_id&stream_id` | list |++| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |++| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |++++`status`: `pending → recording → ready | failed` (derived; failures come from++health quarantine: corrupt / zero-byte / missing file).++++**Recording formats** (inherited codec-passthrough behavior — VODs are *not*++always `.webm`): RTMP and RTP-H.264 record by lossless stream copy into a++fragmented **`.mp4`** (audio → AAC on the RTP path); RTP-VP8/VP9 copies into++**`.webm`** (Opus copied); exotic codecs fall back to a libvpx re-encode with a++lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every++60 s during recording so `/v/:id` is DVR-seekable while live.++++### Clips++++| method | path | notes |++|---|---|---|++| POST | `/clips` | `{ vod_id, start_s, end_s, title?, user_id? }` → **202** `{ id, status: 'processing' }`; cut runs in background (from the local file or a presigned B2/R2 URL); duplicate windows are deduplicated; live recordings are clamped to flushed footage |++| GET | `/clips/:id` | status: `processing | ready | failed` |++| GET | `/clips?limit&offset&vod_id&stream_id&user_id` | list |++| PUT | `/clips/:id` | `{ title?, visibility? }` |++| DELETE | `/clips/:id` | local + offloaded objects + row |++++### Pastes++++| method | path | notes |++|---|---|---|++| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` |++| GET | `/pastes?limit&offset&type&search&user_id` | public list |++| GET | `/pastes/:slug` | full paste (private: owner/app only) |++| PUT | `/pastes/:slug` | update (owner/app) |++| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |++| POST | `/pastes/:slug/fork` | fork a text paste |++| POST | `/pastes/:slug/like` | toggle like (needs a user identity) |++| POST | `/pastes/:slug/copy` | track a copy event |++| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |++| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |++++Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,++`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers++are trusted server-to-server. AI summary/tags generation was **dropped** (Live++owns AI) but the columns remain for imported rows.++++### Files++++| method | path | notes |++|---|---|---|++| POST | `/files` | multipart `file` → `{ key, url, size, mime }`; key = `<sha256[0:12]>-<name>`; per-app `quota_bytes` enforced (413 on exceed) |++| GET | `/files` | list + `used_bytes`/`quota_bytes` |++| GET | `/files/:key` | meta |++| DELETE | `/files/:key` | delete |++++### Thumbnails++++| method | path | notes |++|---|---|---|++| POST | `/thumbnails/:kind/:id` | kind `vod`/`clip`: multipart `thumbnail` (or `{image: base64}`) uploads a custom image, or with no body (re)generates from the media (VOD @10%, clip near first frame); kind `live`: upload a broadcaster frame, stored under the stable name `stream-<app>-<id>.jpg` → `{ url }` |++++### Webhooks (outbound)++++`POST` to the app's `webhook_url` with body++`{ "event": "vod.ready"|"vod.failed"|"clip.ready"|"clip.failed", "app_id", "data" }`++and header `X-OVMedia-Signature: sha256=<hex hmac-sha256 of the raw body with++the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.++++## Public serving (no auth unless the item is private)++++| path | behavior |++|---|---|++| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex` |++| `GET /c/:id` | clip playback, same logic, `noindex` |++| `GET /p/:slug` | server-rendered paste HTML page (**indexable** — Media is the canonical home for pastes) |++| `GET /p/:slug/raw` | `text/plain` |++| `GET /p/:slug/screenshot` | paste screenshot image |++| `GET /t/:id` | thumbnail (id = filename), `noindex` |++| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |++++Private items respond 403/404 unless the request bears the owning app's API++key or the owning user's JWT.++++## Storage tiering++++`vod/vod-storage.js` (ported nearly as-is): local → B2 canonical cold tier →++R2 popularity cache; verified uploads before any deletion; periodic sweep++(offload by age/views/last-access, aggressive drain under disk pressure, R2++promote/demote); presigned-302 playback. Knobs live in `media_settings` under++`storage_tier.*` (JSON values). CLI:++++```++node server/vod/vod-storage.js check|migrate-legacy|drain [targetPct]++```++++Background jobs wired in `index.js`: tiering sweep, VOD health job (probe scan,++master-recovery repair, quarantine + cleanup, orphaned-master sweep), disk++guardian (refuses new recordings when free space is critical), stale live-thumb++cleanup, and an on-boot sweep that finalizes recordings orphaned by an unclean++shutdown.++++## Migration from the predecessor DB++++Every domain table carries `app_id TEXT NOT NULL DEFAULT 'live'`, and the new++schema keeps all predecessor columns (including legacy `storage_tier`), so the++cutover script can bulk-copy rows unchanged:++++```js++const media = require('./server/db/database');++const old = require('better-sqlite3')('/path/to/old-live.db', { readonly: true });++for (const table of ['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views']) {++ const rows = old.prepare(`SELECT * FROM ${table}`).all();++ console.log(table, media.importLegacyRows(table, rows, 'live'));++}++```++++`importLegacyRows` inserts by column-name intersection (unknown legacy columns++ignored, new columns take defaults), preserves ids, backfills `app_id`, and is++idempotent (`INSERT OR IGNORE`). After the copy, run++`node server/vod/vod-storage.js migrate-legacy` to map legacy `storage_tier =++'cold'` rows to provider `b2`.++++## Not ported++++- **Song-request media queue** (`media/` yt-dlp downloader/queue): deeply++ coupled to Live's channels/chat/coins subsystems; Live keeps it local.++- Server-side live thumbnail grabbers (RTMP-FLV / JSMPEG-WS / SFU PlainRTP)++ and JSMPEG stdin recording: sources live inside the apps now; apps POST++ frames / use RTMP-RTP-chunk ingest instead.++- VOD part rotation + live-recording reconciler: tied to the predecessor's++ stream registry; apps re-trigger ingest on their side.++- Comments on VODs/clips, notifications, permissions/moderation ranks: owned++ by the apps.nnew file mode 100644@@@ -0,0 +1,29 @@++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.media www.openvibe.media;++ return 301 https://openvibe.media$request_uri;++}++++server {++ listen 443 ssl http2;++ listen [::]:443 ssl http2;++ server_name openvibe.media www.openvibe.media;++++ ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;++++ client_max_body_size 4096m;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++++ location / {++ proxy_pass http://127.0.0.1:4100;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_request_buffering off;++ }++}nnew file mode 100644@@@ -0,0 +1,25 @@++# OpenVibe.Media — multi-tenant media service (VOD recording/serving, clips, pastes, files).++# Secrets live in /etc/openvibe/media.env (0600).++[Unit]++Description=OpenVibe.Media service++After=network.target++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.media++EnvironmentFile=/etc/openvibe/media.env++ExecStart=/usr/bin/env node /opt/openvibe.media/server/index.js++Restart=always++RestartSec=3++TimeoutStopSec=30++KillSignal=SIGTERM++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++ReadWritePaths=/opt/openvibe.media/data++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,2508 @@++{++ "name": "openvibe-media",++ "version": "1.0.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-media",++ "version": "1.0.0",++ "license": "MIT",++ "dependencies": {++ "@aws-sdk/client-s3": "^3.600.0",++ "@aws-sdk/lib-storage": "^3.600.0",++ "@aws-sdk/s3-request-presigner": "^3.600.0",++ "better-sqlite3": "^11.0.0",++ "dotenv": "^16.4.5",++ "express": "^4.19.2",++ "multer": "^1.4.5-lts.1",++ "openvibe-shared": "file:./vendor/openvibe-shared",++ "sharp": "^0.33.4"++ }++ },++ "node_modules/@aws-sdk/checksums": {++ "version": "3.1000.28",++ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",++ "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/client-s3": {++ "version": "3.1111.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz",++ "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/checksums": "^3.1000.28",++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/credential-provider-node": "^3.972.80",++ "@aws-sdk/middleware-sdk-s3": "^3.972.74",++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/fetch-http-handler": "^5.6.13",++ "@smithy/node-http-handler": "^4.9.13",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/core": {++ "version": "3.977.8",++ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz",++ "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/types": "^3.974.4",++ "@aws-sdk/xml-builder": "^3.972.39",++ "@aws/lambda-invoke-store": "^0.3.0",++ "@smithy/core": "^3.31.1",++ "@smithy/signature-v4": "^5.6.12",++ "@smithy/types": "^4.16.1",++ "bowser": "^2.11.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-env": {++ "version": "3.972.69",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz",++ "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-http": {++ "version": "3.972.71",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz",++ "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/fetch-http-handler": "^5.6.13",++ "@smithy/node-http-handler": "^4.9.13",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-ini": {++ "version": "3.973.14",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz",++ "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/credential-provider-env": "^3.972.69",++ "@aws-sdk/credential-provider-http": "^3.972.71",++ "@aws-sdk/credential-provider-login": "^3.972.76",++ "@aws-sdk/credential-provider-process": "^3.972.69",++ "@aws-sdk/credential-provider-sso": "^3.973.13",++ "@aws-sdk/credential-provider-web-identity": "^3.972.75",++ "@aws-sdk/nested-clients": "^3.997.43",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/credential-provider-imds": "^4.4.16",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-login": {++ "version": "3.972.76",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz",++ "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/nested-clients": "^3.997.43",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-node": {++ "version": "3.972.80",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz",++ "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/credential-provider-env": "^3.972.69",++ "@aws-sdk/credential-provider-http": "^3.972.71",++ "@aws-sdk/credential-provider-ini": "^3.973.14",++ "@aws-sdk/credential-provider-process": "^3.972.69",++ "@aws-sdk/credential-provider-sso": "^3.973.13",++ "@aws-sdk/credential-provider-web-identity": "^3.972.75",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/credential-provider-imds": "^4.4.16",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-process": {++ "version": "3.972.69",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz",++ "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-sso": {++ "version": "3.973.13",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz",++ "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/nested-clients": "^3.997.43",++ "@aws-sdk/token-providers": "3.1111.0",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-web-identity": {++ "version": "3.972.75",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz",++ "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/nested-clients": "^3.997.43",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/lib-storage": {++ "version": "3.1111.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1111.0.tgz",++ "integrity": "sha512-UjLJE0Zxv/m8Xgd3DLBXssZYISgoEPZpJiPWY+MadEdP36oOcjHVsR8ToUq6MMmKdzuBFYB6qGHWS/yKAG4fow==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "buffer": "5.6.0",++ "events": "3.3.0",++ "stream-browserify": "3.0.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ },++ "peerDependencies": {++ "@aws-sdk/client-s3": "^3.1111.0"++ }++ },++ "node_modules/@aws-sdk/middleware-sdk-s3": {++ "version": "3.972.74",++ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz",++ "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/nested-clients": {++ "version": "3.997.43",++ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz",++ "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/fetch-http-handler": "^5.6.13",++ "@smithy/node-http-handler": "^4.9.13",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/s3-request-presigner": {++ "version": "3.1111.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz",++ "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/signature-v4-multi-region": {++ "version": "3.996.45",++ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz",++ "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/types": "^3.974.4",++ "@smithy/signature-v4": "^5.6.12",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/token-providers": {++ "version": "3.1111.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz",++ "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.977.8",++ "@aws-sdk/nested-clients": "^3.997.43",++ "@aws-sdk/types": "^3.974.4",++ "@smithy/core": "^3.31.1",++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/types": {++ "version": "3.974.4",++ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz",++ "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/xml-builder": {++ "version": "3.972.39",++ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz",++ "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.16.1",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws/lambda-invoke-store": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",++ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@emnapi/runtime": {++ "version": "1.11.3",++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",++ "license": "MIT",++ "optional": true,++ "dependencies": {++ "tslib": "^2.4.0"++ }++ },++ "node_modules/@img/sharp-darwin-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",++ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",++ "cpu": [++ "arm64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-darwin-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",++ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",++ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",++ "cpu": [++ "arm64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",++ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",++ "cpu": [++ "x64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",++ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",++ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-s390x": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",++ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",++ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",++ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",++ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-linux-arm": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",++ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm": "1.0.5"++ }++ },++ "node_modules/@img/sharp-linux-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",++ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-s390x": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",++ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-s390x": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",++ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",++ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",++ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-wasm32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",++ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",++ "cpu": [++ "wasm32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",++ "optional": true,++ "dependencies": {++ "@emnapi/runtime": "^1.2.0"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-ia32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",++ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",++ "cpu": [++ "ia32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",++ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@smithy/core": {++ "version": "3.33.2",++ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz",++ "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/credential-provider-imds": {++ "version": "4.5.2",++ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",++ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.2",++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/fetch-http-handler": {++ "version": "5.7.2",++ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",++ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.2",++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/node-http-handler": {++ "version": "4.11.2",++ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz",++ "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.2",++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/signature-v4": {++ "version": "5.7.2",++ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz",++ "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.2",++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/types": {++ "version": "4.17.2",++ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",++ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",++ "license": "Apache-2.0",++ "dependencies": {++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/append-field": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",++ "license": "MIT"++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/bl/node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/body-parser": {++ "version": "1.20.6",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",++ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.15.1",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/bowser": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",++ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",++ "license": "MIT"++ },++ "node_modules/buffer": {++ "version": "5.6.0",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",++ "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.0.2",++ "ieee754": "^1.1.4"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/buffer-from": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",++ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",++ "license": "MIT"++ },++ "node_modules/busboy": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",++ "dependencies": {++ "streamsearch": "^1.1.0"++ },++ "engines": {++ "node": ">=10.16.0"++ }++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/color": {++ "version": "4.2.3",++ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",++ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",++ "license": "MIT",++ "dependencies": {++ "color-convert": "^2.0.1",++ "color-string": "^1.9.0"++ },++ "engines": {++ "node": ">=12.5.0"++ }++ },++ "node_modules/color-convert": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",++ "license": "MIT",++ "dependencies": {++ "color-name": "~1.1.4"++ },++ "engines": {++ "node": ">=7.0.0"++ }++ },++ "node_modules/color-name": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",++ "license": "MIT"++ },++ "node_modules/color-string": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",++ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",++ "license": "MIT",++ "dependencies": {++ "color-name": "^1.0.0",++ "simple-swizzle": "^0.2.2"++ }++ },++ "node_modules/concat-stream": {++ "version": "1.6.2",++ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",++ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",++ "engines": [++ "node >= 0.8"++ ],++ "license": "MIT",++ "dependencies": {++ "buffer-from": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^2.2.2",++ "typedarray": "^0.0.6"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",++ "license": "MIT"++ },++ "node_modules/core-util-is": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",++ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/events": {++ "version": "3.3.0",++ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",++ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.8.x"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/express": {++ "version": "4.22.2",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",++ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.12",++ "proxy-addr": "~2.0.7",++ "qs": "~6.15.1",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/is-arrayish": {++ "version": "0.3.4",++ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",++ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",++ "license": "MIT"++ },++ "node_modules/isarray": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",++ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp": {++ "version": "0.5.6",++ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",++ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",++ "license": "MIT",++ "dependencies": {++ "minimist": "^1.2.6"++ },++ "bin": {++ "mkdirp": "bin/cmd.js"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/multer": {++ "version": "1.4.5-lts.2",++ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",++ "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",++ "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",++ "license": "MIT",++ "dependencies": {++ "append-field": "^1.0.0",++ "busboy": "^1.0.0",++ "concat-stream": "^1.5.2",++ "mkdirp": "^0.5.4",++ "object-assign": "^4.1.1",++ "type-is": "^1.6.4",++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">= 6.0.0"++ }++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/node-abi": {++ "version": "3.94.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",++ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/object-assign": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",++ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-shared": {++ "resolved": "vendor/openvibe-shared",++ "link": true++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/process-nextick-args": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",++ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",++ "license": "MIT"++ },++ "node_modules/proxy-addr": {++ "version": "2.0.7",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",++ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/qs": {++ "version": "6.15.3",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",++ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "2.3.8",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",++ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",++ "license": "MIT",++ "dependencies": {++ "core-util-is": "~1.0.0",++ "inherits": "~2.0.3",++ "isarray": "~1.0.0",++ "process-nextick-args": "~2.0.0",++ "safe-buffer": "~5.1.1",++ "string_decoder": "~1.1.1",++ "util-deprecate": "~1.0.1"++ }++ },++ "node_modules/readable-stream/node_modules/safe-buffer": {++ "version": "5.1.2",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",++ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",++ "license": "MIT"++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/sharp": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",++ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",++ "hasInstallScript": true,++ "license": "Apache-2.0",++ "dependencies": {++ "color": "^4.2.3",++ "detect-libc": "^2.0.3",++ "semver": "^7.6.3"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-darwin-arm64": "0.33.5",++ "@img/sharp-darwin-x64": "0.33.5",++ "@img/sharp-libvips-darwin-arm64": "1.0.4",++ "@img/sharp-libvips-darwin-x64": "1.0.4",++ "@img/sharp-libvips-linux-arm": "1.0.5",++ "@img/sharp-libvips-linux-arm64": "1.0.4",++ "@img/sharp-libvips-linux-s390x": "1.0.4",++ "@img/sharp-libvips-linux-x64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",++ "@img/sharp-linux-arm": "0.33.5",++ "@img/sharp-linux-arm64": "0.33.5",++ "@img/sharp-linux-s390x": "0.33.5",++ "@img/sharp-linux-x64": "0.33.5",++ "@img/sharp-linuxmusl-arm64": "0.33.5",++ "@img/sharp-linuxmusl-x64": "0.33.5",++ "@img/sharp-wasm32": "0.33.5",++ "@img/sharp-win32-ia32": "0.33.5",++ "@img/sharp-win32-x64": "0.33.5"++ }++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/simple-swizzle": {++ "version": "0.2.4",++ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",++ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",++ "license": "MIT",++ "dependencies": {++ "is-arrayish": "^0.3.1"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/stream-browserify": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",++ "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "~2.0.4",++ "readable-stream": "^3.5.0"++ }++ },++ "node_modules/stream-browserify/node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/streamsearch": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",++ "engines": {++ "node": ">=10.0.0"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",++ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.1.0"++ }++ },++ "node_modules/string_decoder/node_modules/safe-buffer": {++ "version": "5.1.2",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",++ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",++ "license": "MIT"++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/tar-stream/node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/tslib": {++ "version": "2.8.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",++ "license": "0BSD"++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/typedarray": {++ "version": "0.0.6",++ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",++ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",++ "license": "MIT"++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ },++ "vendor/openvibe-shared": {++ "version": "1.0.0",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "name": "openvibe-media",++ "version": "1.0.0",++ "description": "OpenVibe.Media — multi-tenant media service (VOD ingest/recording, storage tiering, clips, pastes, files, thumbnails)",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "init-db": "node server/db/init.js",++ "smoke": "bash scripts/smoke-test.sh"++ },++ "license": "MIT",++ "dependencies": {++ "@aws-sdk/client-s3": "^3.600.0",++ "@aws-sdk/lib-storage": "^3.600.0",++ "@aws-sdk/s3-request-presigner": "^3.600.0",++ "better-sqlite3": "^11.0.0",++ "dotenv": "^16.4.5",++ "express": "^4.19.2",++ "multer": "^1.4.5-lts.1",++ "openvibe-shared": "file:./vendor/openvibe-shared",++ "sharp": "^0.33.4"++ }++}nnew file mode 100755@@@ -0,0 +1,150 @@++#!/usr/bin/env bash++# OpenVibe.Media — end-to-end smoke test.++# Boots the service against a throwaway data dir, then exercises:++# vod create → chunk upload → complete → meta (duration+thumbnail) →++# clip cut → /v range playback → paste (+ /p page + /raw) → file upload →++# /f fetch → bad API key rejected → cross-tenant key rejected.++set -u++ROOT="$(cd "$(dirname "$0")/.." && pwd)"++WORK="$ROOT/.smoke"++PORT="${SMOKE_PORT:-41300}"++BASE="http://127.0.0.1:$PORT"++LIVE_KEY="smoke-live-key-1234567890"++GAMES_KEY="smoke-games-key-0987654321"++++PASS=0; FAIL=0++ok() { PASS=$((PASS+1)); echo " PASS: $1"; }++bad() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }++check() { # check <description> <expected> <actual>++ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi++}++jsonget() { # jsonget <json> <dotted.path>++ node -e 'const o=JSON.parse(process.argv[1]);const v=process.argv[2].split(".").reduce((a,k)=>a?.[k],o);console.log(v===undefined?"":v)' "$1" "$2"++}++++echo "== OpenVibe.Media smoke test =="++rm -rf "$WORK"; mkdir -p "$WORK/data"++++echo "-- generating 5s test clip (ffmpeg testsrc)"++ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \++ -f lavfi -i "sine=frequency=440:duration=5" \++ -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest \++ "$WORK/test.mp4" >/dev/null 2>&1++[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }++++echo "-- booting server on :$PORT (temp data dir)"++SEED="[{\"app_id\":\"live\",\"name\":\"Live\",\"api_key\":\"$LIVE_KEY\",\"allowed_origins\":[\"http://localhost:3000\"]},{\"app_id\":\"games\",\"name\":\"Games\",\"api_key\":\"$GAMES_KEY\",\"quota_bytes\":10485760}]"++env PORT="$PORT" HOST=127.0.0.1 \++ DB_PATH="$WORK/data/media.db" \++ VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \++ PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \++ FILES_PATH="$WORK/data/files" \++ MEDIA_APPS_SEED="$SEED" \++ OV_NETWORK_URL="http://127.0.0.1:1" \++ node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &++SRV=$!++trap 'kill "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null' EXIT++++for i in $(seq 1 50); do++ sleep 0.2++ if curl -sf "$BASE/healthz" >/dev/null 2>&1; then break; fi++ if [ "$i" = 50 ]; then bad "server boot"; cat "$WORK/server.log"; exit 1; fi++done++ok "server is up ($(curl -s "$BASE/healthz"))"++++AUTH="Authorization: Bearer $LIVE_KEY"++++echo "-- vod: create"++R=$(curl -s -X POST "$BASE/api/v1/live/vods" -H "$AUTH" -H 'Content-Type: application/json' \++ -d '{"title":"Smoke VOD","stream_id":42,"user_id":7}')++VOD_ID=$(jsonget "$R" id)++[ -n "$VOD_ID" ] && ok "created vod id=$VOD_ID" || { bad "vod create ($R)"; exit 1; }++++echo "-- vod: upload media via chunks endpoint + complete"++R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/chunks" -H "$AUTH" \++ -F "chunk=@$WORK/test.mp4;type=video/mp4")++check "chunk accepted" "created" "$(jsonget "$R" status)"++R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/complete" -H "$AUTH")++ST=$(jsonget "$R" vod.status)++check "complete → vod ready" "ready" "$ST"++++echo "-- vod: meta shows duration + thumbnail"++R=$(curl -s "$BASE/api/v1/live/vods/$VOD_ID" -H "$AUTH")++DUR=$(jsonget "$R" vod.duration)++THUMB=$(jsonget "$R" vod.thumbnail_url)++[ "$DUR" = "5" ] && ok "duration probed = 5s" || bad "duration (got '$DUR')"++case "$THUMB" in /t/*) ok "thumbnail_url = $THUMB";; *) bad "thumbnail_url (got '$THUMB')";; esac++TCODE=$(curl -s -o "$WORK/thumb.jpg" -w '%{http_code}' "$BASE$THUMB")++check "GET $THUMB serves image" "200" "$TCODE"++++echo "-- clips: cut a 2s clip"++R=$(curl -s -X POST "$BASE/api/v1/live/clips" -H "$AUTH" -H 'Content-Type: application/json' \++ -d "{\"vod_id\":$VOD_ID,\"start_s\":1,\"end_s\":3,\"title\":\"Smoke clip\"}")++CLIP_ID=$(jsonget "$R" id)++[ -n "$CLIP_ID" ] && ok "clip queued id=$CLIP_ID (status=$(jsonget "$R" status))" || bad "clip create ($R)"++CLIP_ST=""++for i in $(seq 1 60); do++ sleep 0.5++ CLIP_ST=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.status)++ [ "$CLIP_ST" = "ready" ] && break++ [ "$CLIP_ST" = "failed" ] && break++done++check "clip processed" "ready" "$CLIP_ST"++CDUR=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.duration)++echo " (clip duration: ${CDUR}s)"++CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")++check "GET /c/$CLIP_ID plays" "200" "$CCODE"++++echo "-- playback: /v/:id with Range"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v/$VOD_ID")++check "GET /v/$VOD_ID" "200" "$CODE"++HDRS=$(curl -s -D - -o /dev/null -H 'Range: bytes=0-99' "$BASE/v/$VOD_ID")++echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Content" || bad "Range request ($(echo "$HDRS" | head -1))"++echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"++echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"++++echo "-- pastes: create + public page + raw"++R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \++ -d '{"title":"Smoke paste","content":"hello from the smoke test\nline two","user_id":7}')++SLUG=$(jsonget "$R" slug)++[ -n "$SLUG" ] && ok "paste created slug=$SLUG" || bad "paste create ($R)"++PAGE=$(curl -s "$BASE/p/$SLUG")++echo "$PAGE" | grep -q 'Smoke paste' && ok "/p/$SLUG HTML page renders title" || bad "/p page content"++echo "$PAGE" | grep -q 'OpenVibe' && ok "/p page carries OpenVibe branding" || bad "/p page branding"++RAW=$(curl -s "$BASE/p/$SLUG/raw")++check "/p/$SLUG/raw returns content" "hello from the smoke test++line two" "$RAW"++++echo "-- files: upload + public fetch"++echo "smoke-file-payload-$(date +%s)" > "$WORK/upload.txt"++R=$(curl -s -X POST "$BASE/api/v1/live/files" -H "$AUTH" -F "file=@$WORK/upload.txt;type=text/plain")++FKEY=$(jsonget "$R" key)++[ -n "$FKEY" ] && ok "file stored key=$FKEY" || bad "file upload ($R)"++FBODY=$(curl -s "$BASE/f/$FKEY")++check "GET /f/$FKEY returns payload" "$(cat "$WORK/upload.txt")" "$FBODY"++FCT=$(curl -s -o /dev/null -w '%{content_type}' "$BASE/f/$FKEY")++check "file served with stored MIME" "text/plain" "$FCT"++++echo "-- auth: bad API key rejected"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods" -H 'Authorization: Bearer wrong-key')++check "bad key → 401" "401" "$CODE"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods")++check "no key → 401" "401" "$CODE"++++echo "-- tenancy: live key cannot touch /api/v1/games/*"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "$AUTH")++check "live key on games → 403" "403" "$CODE"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "Authorization: Bearer $GAMES_KEY")++check "games key on games → 200" "200" "$CODE"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/nosuchapp/vods" -H "$AUTH")++check "unknown app → 404" "404" "$CODE"++++echo "-- shutting down server"++kill "$SRV" 2>/dev/null++wait "$SRV" 2>/dev/null++trap - EXIT++ok "server stopped"++++echo++echo "== RESULT: $PASS passed, $FAIL failed =="++[ "$FAIL" = 0 ]nnew file mode 100644@@@ -0,0 +1,247 @@++/**++ * OpenVibe.Media — Tenancy & Auth middleware++ *++ * Two credential types (per CONTRACTS.md, Media API v1):++ * 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).++ * Constant-time hash compare against the `apps` row; a key is ONLY valid++ * for its own `:app` path segment.++ * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key++ * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).++ * Browser endpoints additionally check the Origin header against the++ * app's allowed_origins list.++ *++ * App seeding: MEDIA_APPS_SEED (JSON array) and/or MEDIA_APP_KEYS ("id:key,…")++ * are upserted on boot; keys are stored as sha256 hashes.++ */++'use strict';++++const crypto = require('crypto');++const db = require('./db/database');++const config = require('./config');++++// ── JWKS → PEM (offline user-JWT verification) ───────────────++++let _networkPublicKeyPem = null;++let _jwksTimer = null;++++async function fetchNetworkPublicKey() {++ const url = `${config.network.url}/api/.well-known/jwks`;++ try {++ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];++ if (!jwk) throw new Error('JWKS contained no keys');++ const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });++ _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });++ console.log('[Auth] Network JWKS public key loaded');++ return _networkPublicKeyPem;++ } catch (err) {++ console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);++ return null;++ }++}++++function startJwksRefresh() {++ fetchNetworkPublicKey().catch(() => {});++ _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);++ if (_jwksTimer.unref) _jwksTimer.unref();++}++++function stopJwksRefresh() {++ if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }++}++++/** Verify an RS256 user JWT offline. Returns the payload or null. */++function verifyUserJwt(token) {++ if (!_networkPublicKeyPem || !token) return null;++ const parts = String(token).split('.');++ if (parts.length !== 3) return null;++ try {++ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));++ if (header.alg !== 'RS256') return null;++ const ok = crypto.verify(++ 'RSA-SHA256',++ Buffer.from(`${parts[0]}.${parts[1]}`),++ _networkPublicKeyPem,++ Buffer.from(parts[2], 'base64url')++ );++ if (!ok) return null;++ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ if (payload.exp && Date.now() / 1000 > payload.exp) return null;++ if (payload.iss && payload.iss !== config.network.url) return null;++ // aud must include this service when present.++ if (payload.aud) {++ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];++ if (!aud.includes('openvibe.media')) return null;++ }++ return payload;++ } catch {++ return null;++ }++}++++// ── App key check (constant-time) ────────────────────────────++++function checkAppKey(app, presentedKey) {++ if (!app || !presentedKey) return false;++ const presentedHash = Buffer.from(db.hashApiKey(presentedKey), 'hex');++ const storedHash = Buffer.from(String(app.api_key_hash || ''), 'hex');++ if (presentedHash.length !== storedHash.length || !storedHash.length) return false;++ return crypto.timingSafeEqual(presentedHash, storedHash);++}++++function bearerToken(req) {++ const h = req.headers.authorization;++ if (h && h.startsWith('Bearer ')) return h.slice(7).trim();++ return null;++}++++// ── Middleware ───────────────────────────────────────────────++++/**++ * Resolve the `:app` path segment to req.appRow / req.appId, then authenticate.++ *++ * @param {object} opts++ * @param {boolean} [opts.allowUser=false] also accept a Network user JWT++ * (browser endpoints); Origin, when present, must be allow-listed.++ */++function tenantAuth({ allowUser = false } = {}) {++ return (req, res, next) => {++ const appId = String(req.params.app || '').trim();++ const app = appId ? db.getApp(appId) : null;++ if (!app) return res.status(404).json({ error: 'Unknown app' });++ req.appId = appId;++ req.appRow = app;++++ const token = bearerToken(req);++++ // 1) App API key — only valid for its own :app segment. A valid key of a++ // DIFFERENT app must NOT fall through to anything else.++ if (token && checkAppKey(app, token)) {++ req.authType = 'app';++ return next();++ }++ if (token && isKeyOfOtherApp(token, appId)) {++ return res.status(403).json({ error: 'API key not valid for this app' });++ }++++ // 2) Network user JWT (browser endpoints only).++ if (allowUser && token) {++ const payload = verifyUserJwt(token);++ if (payload) {++ const origin = req.headers.origin;++ if (origin && !db.appAllowedOrigins(app).includes(origin)) {++ return res.status(403).json({ error: 'Origin not allowed for this app' });++ }++ req.authType = 'user';++ req.user = payload;++ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);++ return next();++ }++ }++++ return res.status(401).json({ error: 'Authentication required' });++ };++}++++/** Does the presented key belong to some OTHER app? (for a precise 403) */++function isKeyOfOtherApp(presentedKey, exceptAppId) {++ const hash = db.hashApiKey(presentedKey);++ const row = db.get('SELECT app_id FROM apps WHERE api_key_hash = ?', [hash]);++ return !!(row && row.app_id !== exceptAppId);++}++++/**++ * Optional identity on PUBLIC serving routes (/v /c /p /t /f): attaches the++ * owning app (via any valid app key) or user payload if a credential is sent.++ * Never rejects.++ */++function optionalIdentity(req, _res, next) {++ const token = bearerToken(req);++ if (token) {++ const hash = db.hashApiKey(token);++ const appRow = db.get('SELECT * FROM apps WHERE api_key_hash = ?', [hash]);++ if (appRow) {++ req.authType = 'app';++ req.appRow = appRow;++ req.appId = appRow.app_id;++ } else {++ const payload = verifyUserJwt(token);++ if (payload) {++ req.authType = 'user';++ req.user = payload;++ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);++ }++ }++ }++ next();++}++++/** CORS for tenant API routes — reflects allow-listed origins per app. */++function tenantCors(req, res, next) {++ const origin = req.headers.origin;++ if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Vary', 'Origin');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');++ }++ next();++}++++// ── App seeding ──────────────────────────────────────────────++++function seedApps() {++ let seeded = 0;++++ if (config.apps.seedJson) {++ try {++ const list = JSON.parse(config.apps.seedJson);++ for (const entry of Array.isArray(list) ? list : []) {++ if (!entry || !entry.app_id || !entry.api_key) continue;++ db.upsertApp(entry);++ seeded++;++ }++ } catch (err) {++ console.error('[Auth] MEDIA_APPS_SEED is not valid JSON:', err.message);++ }++ }++++ if (config.apps.seedKeys) {++ for (const pair of config.apps.seedKeys.split(',')) {++ const idx = pair.indexOf(':');++ if (idx < 1) continue;++ const app_id = pair.slice(0, idx).trim();++ const api_key = pair.slice(idx + 1).trim();++ if (!app_id || !api_key) continue;++ // Don't clobber a richer MEDIA_APPS_SEED entry for the same app.++ const existing = db.getApp(app_id);++ if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;++ db.upsertApp({++ app_id,++ name: existing?.name || app_id,++ api_key,++ webhook_url: existing?.webhook_url || null,++ webhook_secret: existing?.webhook_secret || null,++ allowed_origins: existing ? db.appAllowedOrigins(existing) : [],++ quota_bytes: existing?.quota_bytes || 0,++ });++ seeded++;++ }++ }++++ if (seeded) console.log(`[Auth] Seeded/updated ${seeded} app(s): ${db.listApps().map(a => a.app_id).join(', ')}`);++ else if (!db.listApps().length) console.warn('[Auth] No apps configured — set MEDIA_APPS_SEED (all API calls will 404)');++}++++module.exports = {++ tenantAuth,++ tenantCors,++ optionalIdentity,++ verifyUserJwt,++ seedApps,++ startJwksRefresh,++ stopJwksRefresh,++ fetchNetworkPublicKey,++};nnew file mode 100644@@@ -0,0 +1,72 @@++/**++ * OpenVibe.Media — Config++ *++ * Lean env-driven configuration per CONTRACTS.md (Media API v1).++ * Shapes (config.vod.path, config.vod.clipsPath, config.thumbnails.path)++ * are kept compatible with the inherited vod/thumbnail modules.++ */++require('dotenv').config();++++const path = require('path');++++function intEnv(name, fallback) {++ const v = parseInt(process.env[name] || '', 10);++ return Number.isFinite(v) ? v : fallback;++}++++const config = {++ port: intEnv('PORT', 4100),++ host: process.env.HOST || '0.0.0.0',++ nodeEnv: process.env.NODE_ENV || 'development',++ publicUrl: (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, ''),++++ db: {++ path: process.env.DB_PATH || './data/media.db',++ },++++ vod: {++ path: process.env.VOD_PATH || './data/vods',++ clipsPath: process.env.CLIPS_PATH || './data/clips',++ maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),++ },++ pastes: {++ path: process.env.PASTES_PATH || './data/pastes',++ },++ thumbnails: {++ path: process.env.THUMBNAILS_PATH || './data/thumbnails',++ },++ files: {++ path: process.env.FILES_PATH || './data/files',++ maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),++ },++++ rtp: {++ portMin: intEnv('RTP_PORT_MIN', 12000),++ portMax: intEnv('RTP_PORT_MAX', 12199),++ },++++ network: {++ url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),++ internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),++ internalApiKey: process.env.INTERNAL_API_KEY || '',++ },++++ apps: {++ // JSON array: [{app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes}]++ seedJson: process.env.MEDIA_APPS_SEED || '',++ // Fallback short form: "live:key1,games:key2"++ seedKeys: process.env.MEDIA_APP_KEYS || '',++ },++};++++// Resolve all storage paths to absolute++for (const p of ['path']) {++ config.vod[p] = path.resolve(config.vod[p]);++}++config.vod.clipsPath = path.resolve(config.vod.clipsPath);++config.pastes.path = path.resolve(config.pastes.path);++config.thumbnails.path = path.resolve(config.thumbnails.path);++config.files.path = path.resolve(config.files.path);++config.db.path = path.resolve(config.db.path);++++module.exports = config;nnew file mode 100644@@@ -0,0 +1,456 @@++/**++ * OpenVibe.Media — Database++ *++ * Lean port of the predecessor's vod/clip/paste helpers with app_id (tenant)++ * scoping on every query. better-sqlite3 in WAL mode.++ *++ * Migration path: `importLegacyRows(table, rows, appId)` bulk-inserts rows from++ * the old streamer DB unchanged — only columns that exist in the new table are++ * used (extras ignored), ids preserved, app_id backfilled. The cutover script++ * can do: ATTACH old db, SELECT * per table, importLegacyRows('vods', rows, 'live').++ */++'use strict';++++const path = require('path');++const fs = require('fs');++const crypto = require('crypto');++const Database = require('better-sqlite3');++const config = require('../config');++++let database = null;++++function getDb() {++ if (database) return database;++ const dataDir = path.dirname(config.db.path);++ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });++ database = new Database(config.db.path);++ database.pragma('journal_mode = WAL');++ const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');++ database.exec(schema);++ seedSettings();++ return database;++}++++function seedSettings() {++ const defaults = [++ // Clip system++ ['max_clip_duration', '60', 'Maximum clip length in seconds', 'number'],++ // Paste system (same keys the inherited routes read)++ ['paste_max_size_kb', '512', 'Maximum paste content size in KB', 'number'],++ ['paste_screenshot_max_size_mb', '8', 'Maximum screenshot upload size in MB', 'number'],++ ['paste_cooldown_seconds', '30', 'Cooldown between paste submissions in seconds (user-JWT callers)', 'number'],++ ['paste_max_per_user_per_day', '200', 'Maximum pastes per user per day (0 = unlimited)', 'number'],++ ['paste_comment_cooldown_seconds', '10', 'Cooldown between paste comments in seconds', 'number'],++ ['paste_comment_max_length', '2000', 'Maximum paste comment length in characters', 'number'],++ ['paste_comment_anon_allowed', 'true', 'Allow anonymous comments on pastes', 'boolean'],++ ];++ const stmt = database.prepare('INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)');++ for (const [k, v, d, t] of defaults) stmt.run(k, v, d, t);++}++++// ── Generic helpers ──────────────────────────────────────────++++function run(sql, params = []) {++ return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));++}++++function get(sql, params = []) {++ return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));++}++++function all(sql, params = []) {++ return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));++}++++function close() {++ if (database) { try { database.close(); } catch { /* ignore */ } database = null; }++}++++// ── Settings ─────────────────────────────────────────────────++++function getSetting(key) {++ const row = get('SELECT value, type FROM media_settings WHERE key = ?', [key]);++ if (!row) return null;++ if (row.type === 'number') return Number(row.value);++ if (row.type === 'boolean') return row.value === 'true' || row.value === '1';++ return row.value;++}++++function setSetting(key, value, type = 'string') {++ run(`INSERT INTO media_settings (key, value, type) VALUES (?, ?, ?)++ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,++ [key, String(value), type]);++}++++// ── Apps (tenants) ───────────────────────────────────────────++++function hashApiKey(key) {++ return crypto.createHash('sha256').update(String(key)).digest('hex');++}++++function getApp(appId) {++ return get('SELECT * FROM apps WHERE app_id = ?', [appId]);++}++++function listApps() {++ return all('SELECT app_id, name, webhook_url, allowed_origins, quota_bytes, created_at FROM apps ORDER BY app_id');++}++++function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {++ if (!app_id || !api_key) throw new Error('app_id and api_key required');++ const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);++ run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)++ VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT(app_id) DO UPDATE SET++ name = excluded.name,++ api_key_hash = excluded.api_key_hash,++ webhook_url = excluded.webhook_url,++ webhook_secret = excluded.webhook_secret,++ allowed_origins = excluded.allowed_origins,++ quota_bytes = excluded.quota_bytes`,++ [app_id, name || app_id, hashApiKey(api_key), webhook_url || null, webhook_secret || null, origins, quota_bytes || 0]);++ return getApp(app_id);++}++++function appAllowedOrigins(app) {++ try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }++}++++// ── VOD helpers ──────────────────────────────────────────────++++function createVod({ app_id, stream_id, stream_key, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta }) {++ return run(++ `INSERT INTO vods (app_id, stream_id, stream_key, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,++ [app_id, stream_id || null, stream_key || null, user_id || null, title || 'Recording', description || '',++ file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,++ JSON.stringify(meta || {})]++ );++}++++function getVodById(id, appId = null) {++ const clause = appId ? ' AND app_id = ?' : '';++ const params = appId ? [id, appId] : [id];++ return get(`SELECT *, COALESCE(duration_seconds, probe_duration_seconds, 0) AS duration_seconds++ FROM vods WHERE id = ?${clause}`, params);++}++++function listVods(appId, { limit = 50, offset = 0, user_id = null, stream_id = null, includeRecording = true } = {}) {++ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];++ const params = [appId];++ if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }++ params.push(limit, offset);++ return all(`SELECT * FROM vods WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);++}++++function countVods(appId, { user_id = null, stream_id = null } = {}) {++ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];++ const params = [appId];++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }++ return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;++}++++const VALID_VISIBILITY = new Set(['public', 'unlisted', 'private']);++function _normVisibility(v) { return VALID_VISIBILITY.has(v) ? v : 'public'; }++++// Set VOD/clip visibility; is_public mirrors (1 iff public) so listing filters hold.++function setVodVisibility(vodId, visibility) {++ const vis = _normVisibility(visibility);++ return run('UPDATE vods SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, vodId]);++}++++function updateVodHealth(vodId, { status, score, issues = [], probeDuration, probeFormat, quarantine = false, keepPublic = false }) {++ const updates = [];++ const params = [];++ if (status) { updates.push('health_status = ?'); params.push(status); }++ if (typeof score === 'number') { updates.push('health_score = ?'); params.push(score); }++ if (issues) { updates.push('health_issues_json = ?'); params.push(JSON.stringify(issues)); }++ if (typeof probeDuration === 'number') { updates.push('probe_duration_seconds = ?'); params.push(probeDuration); }++ if (probeFormat !== undefined) { updates.push('probe_format_json = ?'); params.push(JSON.stringify(probeFormat || {})); }++ if (quarantine) {++ updates.push("quarantined_at = datetime('now')");++ if (!keepPublic) updates.push('is_public = 0');++ }++ updates.push("last_health_scan_at = datetime('now')");++ params.push(vodId);++ if (!updates.length) return null;++ return run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);++}++++function repairVodDuration(vodId, duration, fileSize) {++ return run(++ `UPDATE vods SET duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, last_health_scan_at = datetime('now') WHERE id = ?`,++ [duration, fileSize, duration, vodId]++ );++}++++// VODs the periodic health job should scan: finished (not recording), and either never++// scanned or last scanned longer ago than `staleDays`. Never-scanned + oldest-scanned first.++function getVodsNeedingHealthScan({ staleDays = 30, limit = 3 } = {}) {++ return all(`SELECT * FROM vods++ WHERE COALESCE(is_recording, 0) = 0++ AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file'))++ AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?))++ ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC++ LIMIT ?`, [`-${Math.max(1, staleDays)} days`, limit]);++}++++// Genuinely-broken VODs quarantined long enough that they should be cleaned up.++function getQuarantinedVodsForCleanup({ graceDays = 14, limit = 5 } = {}) {++ return all(`SELECT * FROM vods++ WHERE quarantined_at IS NOT NULL++ AND quarantined_at <= datetime('now', ?)++ AND health_status IN ('corrupt','zero_byte','missing_file')++ AND COALESCE(is_recording, 0) = 0++ ORDER BY quarantined_at ASC++ LIMIT ?`, [`-${Math.max(1, graceDays)} days`, limit]);++}++++// Derived VOD status for the API: pending → recording → ready | failed.++function vodStatus(vod) {++ if (!vod) return 'unknown';++ if (vod.is_recording) return 'recording';++ if (['corrupt', 'zero_byte', 'missing_file'].includes(vod.health_status)) return 'failed';++ if (vod.file_path && (vod.duration_seconds > 0 || vod.file_size > 0)) return 'ready';++ return 'pending';++}++++// ── Clip helpers ─────────────────────────────────────────────++++function createClip({ app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, auto_generated, status }) {++ const pub = (is_public === 0 || is_public === false) ? 0 : 1;++ return run(++ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [app_id, vod_id || null, stream_id || null, user_id || null, title || 'Untitled Clip', description || '',++ file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,++ pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']++ );++}++++function getClipById(id, appId = null) {++ const clause = appId ? ' AND app_id = ?' : '';++ const params = appId ? [id, appId] : [id];++ return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);++}++++function listClips(appId, { limit = 50, offset = 0, vod_id = null, stream_id = null, user_id = null } = {}) {++ const conds = ['app_id = ?'];++ const params = [appId];++ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }++ params.push(limit, offset);++ return all(`SELECT * FROM clips WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);++}++++function countClips(appId, { vod_id = null, stream_id = null, user_id = null } = {}) {++ const conds = ['app_id = ?'];++ const params = [appId];++ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }++ return get(`SELECT COUNT(*) AS count FROM clips WHERE ${conds.join(' AND ')}`, params)?.count || 0;++}++++function setClipVisibility(clipId, visibility) {++ const vis = _normVisibility(visibility);++ return run('UPDATE clips SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, clipId]);++}++++function findDuplicateClip({ appId, streamId = null, vodId = null, startTime = 0, endTime = 0, startWindow = 8, endWindow = 10, createdSinceMinutes = 10 }) {++ const filters = [];++ const params = [appId];++ if (streamId) { filters.push('stream_id = ?'); params.push(streamId); }++ if (vodId) { filters.push('vod_id = ?'); params.push(vodId); }++ if (!filters.length) return null;++ return get(`++ SELECT * FROM clips++ WHERE app_id = ? AND (${filters.join(' OR ')})++ AND ABS(COALESCE(start_time, 0) - ?) <= ?++ AND ABS(COALESCE(end_time, 0) - ?) <= ?++ AND created_at >= datetime('now', ?)++ ORDER BY created_at DESC++ LIMIT 1++ `, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);++}++++// ── Paste helpers ────────────────────────────────────────────++++function getPasteBySlug(slug, appId = null) {++ const clause = appId ? ' AND app_id = ?' : '';++ const params = appId ? [slug, appId] : [slug];++ return get(`SELECT * FROM pastes WHERE slug = ?${clause}`, params);++}++++function likePaste(pasteId, userId) {++ run('INSERT OR IGNORE INTO paste_likes (paste_id, user_id) VALUES (?, ?)', [pasteId, userId]);++ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);++ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);++}++++function unlikePaste(pasteId, userId) {++ run('DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);++ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);++ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);++}++++function hasUserLikedPaste(pasteId, userId) {++ return !!get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);++}++++function incrementPasteCopies(slug) {++ return run('UPDATE pastes SET copies = copies + 1 WHERE slug = ?', [slug]);++}++++function countUserPastesToday(appId, userId, ip) {++ if (userId) {++ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')", [appId, userId])?.c || 0;++ }++ if (ip) {++ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')", [appId, ip])?.c || 0;++ }++ return 0;++}++++function getLastPasteTime(appId, userId, ip) {++ let row;++ if (userId) {++ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1', [appId, userId]);++ } else if (ip) {++ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1', [appId, ip]);++ }++ return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;++}++++// ── Paste comment helpers ────────────────────────────────────++++function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {++ return run(++ `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)++ VALUES (?, ?, ?, ?, ?, ?)`,++ [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]++ );++}++++function getPasteComments(pasteId, limit = 50, offset = 0) {++ return all(`++ SELECT * FROM paste_comments++ WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL++ ORDER BY created_at DESC++ LIMIT ? OFFSET ?++ `, [pasteId, limit, offset]);++}++++function getPasteCommentReplies(parentId) {++ return all(`++ SELECT * FROM paste_comments++ WHERE parent_id = ? AND is_deleted = 0++ ORDER BY created_at ASC++ `, [parentId]);++}++++function getPasteCommentById(commentId) {++ return get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);++}++++function getPasteCommentCount(pasteId) {++ return get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId])?.count || 0;++}++++function deletePasteComment(commentId) {++ return run('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [commentId]);++}++++function getRecentPasteCommentsByIp(ip, seconds = 10) {++ return all(`++ SELECT * FROM paste_comments++ WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')++ ORDER BY created_at DESC++ `, [ip, seconds]);++}++++// ── File helpers ─────────────────────────────────────────────++++function createFile({ key, app_id, user_id, original_name, size, mime, sha256 }) {++ return run(++ `INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256)++ VALUES (?, ?, ?, ?, ?, ?, ?)`,++ [key, app_id, user_id || null, original_name || null, size || 0, mime || 'application/octet-stream', sha256 || null]++ );++}++++function getFileByKey(key, appId = null) {++ const clause = appId ? ' AND app_id = ?' : '';++ const params = appId ? [key, appId] : [key];++ return get(`SELECT * FROM files WHERE key = ?${clause}`, params);++}++++function listFiles(appId, { limit = 100, offset = 0 } = {}) {++ return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);++}++++function deleteFileRow(key) {++ return run('DELETE FROM files WHERE key = ?', [key]);++}++++function appFilesBytes(appId) {++ return get('SELECT COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?', [appId])?.bytes || 0;++}++++// ── Legacy import (cutover from the old streamer DB) ─────────++++/**++ * Bulk-insert rows exported from the predecessor DB. Only columns present in++ * the destination table are used; missing ones take schema defaults; app_id is++ * backfilled. Row ids are preserved (INSERT OR IGNORE keeps re-runs idempotent).++ * Returns { inserted, skipped }.++ */++function importLegacyRows(table, rows, appId = 'live') {++ const allowed = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views', 'files']);++ if (!allowed.has(table)) throw new Error(`importLegacyRows: table ${table} not importable`);++ if (!Array.isArray(rows) || !rows.length) return { inserted: 0, skipped: 0 };++++ const cols = getDb().prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);++ const colSet = new Set(cols);++ const hasAppId = colSet.has('app_id');++++ let inserted = 0, skipped = 0;++ const tx = getDb().transaction((batch) => {++ for (const raw of batch) {++ const row = { ...raw };++ if (hasAppId && row.app_id == null) row.app_id = appId;++ const keys = Object.keys(row).filter(k => colSet.has(k));++ if (!keys.length) { skipped++; continue; }++ const sql = `INSERT OR IGNORE INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`;++ const res = getDb().prepare(sql).run(...keys.map(k => row[k]));++ if (res.changes > 0) inserted++; else skipped++;++ }++ });++ tx(rows);++ return { inserted, skipped };++}++++module.exports = {++ getDb, run, get, all, close,++ getSetting, setSetting,++ // apps++ hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,++ // vods++ createVod, getVodById, listVods, countVods, setVodVisibility, vodStatus,++ updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,++ // clips++ createClip, getClipById, listClips, countClips, setClipVisibility, findDuplicateClip,++ // pastes++ getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,++ countUserPastesToday, getLastPasteTime,++ createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,++ getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,++ // files++ createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,++ // migration++ importLegacyRows,++};nnew file mode 100644@@@ -0,0 +1,10 @@++/**++ * OpenVibe.Media — Database Initialization Script++ * Run: npm run init-db++ */++const db = require('./database');++++db.getDb();++console.log(`Database initialized at: ${require('../config').db.path}`);++db.close();++process.exit(0);nnew file mode 100644@@@ -0,0 +1,182 @@++-- OpenVibe.Media — Schema++-- Multi-tenant media service. Every domain table carries app_id (tenant key,++-- default 'live' so rows bulk-imported from the predecessor DB need no rewrite).++++PRAGMA journal_mode = WAL;++++-- ── Tenants ──────────────────────────────────────────────────++CREATE TABLE IF NOT EXISTS apps (++ app_id TEXT PRIMARY KEY,++ name TEXT NOT NULL DEFAULT '',++ api_key_hash TEXT NOT NULL,++ webhook_url TEXT,++ webhook_secret TEXT,++ allowed_origins TEXT DEFAULT '[]', -- JSON array of origins for browser (user-JWT) calls++ quota_bytes INTEGER DEFAULT 0, -- files quota; 0 = unlimited++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++);++++-- ── VODs ─────────────────────────────────────────────────────++-- Full column set inherited from the predecessor (health, probe, tiering,++-- clips_only, is_recording, last_accessed_at) + tenant/ingest metadata.++CREATE TABLE IF NOT EXISTS vods (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ app_id TEXT NOT NULL DEFAULT 'live',++ stream_id INTEGER,++ stream_key TEXT,++ user_id INTEGER,++ title TEXT,++ description TEXT DEFAULT '',++ file_path TEXT,++ thumbnail_url TEXT,++ master_file_path TEXT,++ file_size INTEGER DEFAULT 0,++ duration_seconds INTEGER DEFAULT 0,++ probe_duration_seconds REAL DEFAULT 0,++ probe_format_json TEXT DEFAULT '',++ health_status TEXT DEFAULT 'unknown',++ health_score INTEGER DEFAULT 0,++ health_issues_json TEXT DEFAULT '[]',++ last_health_scan_at DATETIME,++ quarantined_at DATETIME,++ is_public INTEGER DEFAULT 1,++ visibility TEXT DEFAULT 'public',++ is_recording INTEGER DEFAULT 0,++ clips_only INTEGER DEFAULT 0,++ view_count INTEGER DEFAULT 0,++ storage_tier TEXT DEFAULT 'hot', -- legacy column (pre object-store); kept for import++ storage_provider TEXT DEFAULT 'local',++ storage_key TEXT,++ last_accessed_at DATETIME,++ ai_overview TEXT, -- kept for import; Live owns AI generation++ ai_transcript TEXT,++ ai_analyzed_at DATETIME,++ meta_json TEXT DEFAULT '{}',++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++);++CREATE INDEX IF NOT EXISTS idx_vods_app ON vods(app_id, created_at DESC);++CREATE INDEX IF NOT EXISTS idx_vods_recording ON vods(is_recording);++CREATE INDEX IF NOT EXISTS idx_vods_provider ON vods(storage_provider);++++-- ── Clips ────────────────────────────────────────────────────++CREATE TABLE IF NOT EXISTS clips (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ app_id TEXT NOT NULL DEFAULT 'live',++ vod_id INTEGER,++ stream_id INTEGER,++ user_id INTEGER,++ title TEXT DEFAULT 'Untitled Clip',++ description TEXT DEFAULT '',++ file_path TEXT,++ thumbnail_url TEXT,++ start_time REAL NOT NULL DEFAULT 0,++ end_time REAL NOT NULL DEFAULT 0,++ duration_seconds REAL DEFAULT 0,++ is_public INTEGER DEFAULT 1,++ visibility TEXT DEFAULT 'public',++ status TEXT DEFAULT 'ready', -- processing | ready | failed (imports default ready)++ view_count INTEGER DEFAULT 0,++ auto_generated INTEGER DEFAULT 0,++ storage_provider TEXT DEFAULT 'local',++ storage_key TEXT,++ ai_overview TEXT,++ ai_transcript TEXT,++ ai_analyzed_at DATETIME,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++);++CREATE INDEX IF NOT EXISTS idx_clips_app ON clips(app_id, created_at DESC);++CREATE INDEX IF NOT EXISTS idx_clips_vod ON clips(vod_id);++CREATE INDEX IF NOT EXISTS idx_clips_stream ON clips(stream_id);++++-- ── Unique view tracking (IP dedup for VODs and clips) ───────++CREATE TABLE IF NOT EXISTS content_views (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')),++ content_id INTEGER NOT NULL,++ ip TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ UNIQUE(content_type, content_id, ip)++);++CREATE INDEX IF NOT EXISTS idx_content_views_lookup ON content_views(content_type, content_id);++++-- ── Pastes ───────────────────────────────────────────────────++CREATE TABLE IF NOT EXISTS pastes (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ app_id TEXT NOT NULL DEFAULT 'live',++ slug TEXT UNIQUE NOT NULL,++ user_id INTEGER,++ type TEXT DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),++ title TEXT NOT NULL DEFAULT 'Untitled',++ content TEXT,++ language TEXT DEFAULT 'text',++ visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),++ stream_id INTEGER,++ screenshot_path TEXT,++ metadata TEXT,++ burn_after_read INTEGER DEFAULT 0,++ forked_from INTEGER,++ pinned INTEGER DEFAULT 0,++ views INTEGER DEFAULT 0,++ copies INTEGER DEFAULT 0,++ likes INTEGER DEFAULT 0,++ is_nsfw INTEGER DEFAULT 0,++ ip_address TEXT,++ ai_summary TEXT, -- kept for import; Live owns AI generation++ ai_tags TEXT,++ ai_analyzed_at DATETIME,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL++);++CREATE INDEX IF NOT EXISTS idx_pastes_slug ON pastes(slug);++CREATE INDEX IF NOT EXISTS idx_pastes_app ON pastes(app_id, created_at DESC);++CREATE INDEX IF NOT EXISTS idx_pastes_user ON pastes(user_id);++CREATE INDEX IF NOT EXISTS idx_pastes_visibility ON pastes(visibility);++++CREATE TABLE IF NOT EXISTS paste_likes (++ paste_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (paste_id, user_id),++ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE++);++++CREATE TABLE IF NOT EXISTS paste_comments (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ paste_id INTEGER NOT NULL,++ user_id INTEGER,++ parent_id INTEGER,++ anon_name TEXT,++ message TEXT NOT NULL,++ ip_address TEXT,++ is_deleted INTEGER DEFAULT 0,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,++ FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE++);++CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id);++CREATE INDEX IF NOT EXISTS idx_paste_comments_ip ON paste_comments(ip_address);++++-- ── Files (new: generic per-app file storage) ────────────────++CREATE TABLE IF NOT EXISTS files (++ key TEXT PRIMARY KEY,++ app_id TEXT NOT NULL DEFAULT 'live',++ user_id INTEGER,++ original_name TEXT,++ size INTEGER NOT NULL DEFAULT 0,++ mime TEXT DEFAULT 'application/octet-stream',++ sha256 TEXT,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++);++CREATE INDEX IF NOT EXISTS idx_files_app ON files(app_id, created_at DESC);++++-- ── Service settings (storage-tier knobs, paste limits, …) ───++-- Same key names the inherited modules used in site_settings.++CREATE TABLE IF NOT EXISTS media_settings (++ key TEXT PRIMARY KEY,++ value TEXT,++ description TEXT DEFAULT '',++ type TEXT DEFAULT 'string',++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++);nnew file mode 100644@@@ -0,0 +1,186 @@++/**++ * OpenVibe.Media — Files API (mounted at /api/v1/:app/files)++ *++ * Generic per-app file storage (new subsystem — no predecessor equivalent):++ *++ * POST / multipart 'file' → { key, url, size, mime }++ * GET / list (?limit&offset)++ * GET /:key meta++ * DELETE /:key delete++ *++ * Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.++ * Public serving (Content-Type + Range) is at GET /f/:key.++ * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the sum of++ * the app's stored file sizes.++ */++'use strict';++++const express = require('express');++const crypto = require('crypto');++const path = require('path');++const fs = require('fs');++const multer = require('multer');++const db = require('../db/database');++const config = require('../config');++const { tenantAuth, tenantCors } = require('../auth');++++const router = express.Router({ mergeParams: true });++router.use(tenantCors);++++const upload = multer({++ dest: path.join(config.files.path, '.tmp'),++ limits: { fileSize: config.files.maxSizeMb * 1024 * 1024 },++});++++function sanitizeName(name) {++ const base = path.basename(String(name || 'file'));++ return base.replace(/[^a-zA-Z0-9._\-]/g, '_').slice(0, 120) || 'file';++}++++function appDir(appId) {++ const dir = path.join(config.files.path, appId);++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });++ return dir;++}++++function filePathForKey(row) {++ return path.join(config.files.path, row.app_id, row.key);++}++++function filePublic(row) {++ return {++ key: row.key,++ app_id: row.app_id,++ user_id: row.user_id,++ original_name: row.original_name,++ size: row.size,++ mime: row.mime,++ sha256: row.sha256,++ url: `/f/${row.key}`,++ created_at: row.created_at,++ };++}++++function sha256File(filePath) {++ return new Promise((resolve, reject) => {++ const hash = crypto.createHash('sha256');++ const stream = fs.createReadStream(filePath);++ stream.on('data', d => hash.update(d));++ stream.on('end', () => resolve(hash.digest('hex')));++ stream.on('error', reject);++ });++}++++// ── Upload ───────────────────────────────────────────────────++router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {++ try {++ if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });++++ // Per-app quota check against quota_bytes (0 = unlimited).++ const quota = Number(req.appRow.quota_bytes) || 0;++ if (quota > 0) {++ const used = db.appFilesBytes(req.appId);++ if (used + req.file.size > quota) {++ try { fs.unlinkSync(req.file.path); } catch { /* */ }++ return res.status(413).json({++ error: 'App file quota exceeded',++ quota_bytes: quota,++ used_bytes: used,++ });++ }++ }++++ const digest = await sha256File(req.file.path);++ const name = sanitizeName(req.file.originalname);++ const key = `${digest.slice(0, 12)}-${name}`;++++ const existing = db.getFileByKey(key);++ if (existing) {++ // Same content + name already stored (any app hash-collides only on++ // identical bytes+name for its own app; cross-app: key is global, so++ // treat a foreign owner as a conflict).++ try { fs.unlinkSync(req.file.path); } catch { /* */ }++ if (existing.app_id !== req.appId) {++ return res.status(409).json({ error: 'Key conflict — rename the file and retry' });++ }++ return res.status(200).json({ ...filePublic(existing), deduplicated: true });++ }++++ const dest = path.join(appDir(req.appId), key);++ try {++ fs.renameSync(req.file.path, dest);++ } catch {++ fs.copyFileSync(req.file.path, dest);++ try { fs.unlinkSync(req.file.path); } catch { /* */ }++ }++++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);++ db.createFile({++ key,++ app_id: req.appId,++ user_id: userId,++ original_name: req.file.originalname || name,++ size: req.file.size,++ mime: req.file.mimetype || 'application/octet-stream',++ sha256: digest,++ });++++ const row = db.getFileByKey(key, req.appId);++ console.log(`[Files] Stored ${key} for app ${req.appId} (${(req.file.size / 1024).toFixed(1)} KB)`);++ res.status(201).json(filePublic(row));++ } catch (err) {++ console.error('[Files] Upload error:', err.message);++ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }++ res.status(500).json({ error: 'Failed to store file' });++ }++});++++// ── List ─────────────────────────────────────────────────────++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ const files = db.listFiles(req.appId, { limit, offset });++ res.json({++ files: files.map(filePublic),++ used_bytes: db.appFilesBytes(req.appId),++ quota_bytes: Number(req.appRow.quota_bytes) || 0,++ limit, offset,++ });++ } catch (err) {++ res.status(500).json({ error: 'Failed to list files' });++ }++});++++// ── Meta ─────────────────────────────────────────────────────++router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const row = db.getFileByKey(String(req.params.key), req.appId);++ if (!row) return res.status(404).json({ error: 'File not found' });++ res.json(filePublic(row));++ } catch (err) {++ res.status(500).json({ error: 'Failed to get file' });++ }++});++++// ── Delete ───────────────────────────────────────────────────++router.delete('/:key', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const row = db.getFileByKey(String(req.params.key), req.appId);++ if (!row) return res.status(404).json({ error: 'File not found' });++ if (req.authType === 'user' && !(req.userId != null && row.user_id === req.userId)) {++ return res.status(403).json({ error: 'Not authorized to delete this file' });++ }++++ const filePath = filePathForKey(row);++ try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }++ db.deleteFileRow(row.key);++ res.json({ message: 'File deleted' });++ } catch (err) {++ res.status(500).json({ error: 'Failed to delete file' });++ }++});++++module.exports = router;++module.exports.filePathForKey = filePathForKey;++module.exports.filePublic = filePublic;nnew file mode 100644@@@ -0,0 +1,139 @@++/**++ * OpenVibe.Media — service entrypoint (port 4100)++ *++ * Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser++ * chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,++ * pastes, files, thumbnails, and HMAC-signed webhooks. See CONTRACTS.md++ * (Media API v1) and README.md.++ */++'use strict';++++const fs = require('fs');++const express = require('express');++const config = require('./config');++const db = require('./db/database');++const auth = require('./auth');++++// ── Bootstrap ────────────────────────────────────────────────++++for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, config.thumbnails.path, config.files.path]) {++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });++}++++db.getDb(); // init schema (WAL)++auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS++auth.startJwksRefresh();++++const app = express();++app.set('trust proxy', true);++app.use(express.json({ limit: '2mb' }));++app.use(express.urlencoded({ extended: true, limit: '2mb' }));++++// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).++app.options('/api/v1/:app/*', (req, res) => {++ const origin = req.headers.origin;++ const appRow = db.getApp(String(req.params.app || ''));++ if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Vary', 'Origin');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');++ }++ res.sendStatus(204);++});++++// ── Routes ───────────────────────────────────────────────────++++app.get('/healthz', (req, res) => {++ res.json({++ ok: true,++ service: 'openvibe-media',++ apps: db.listApps().length,++ recordings: require('./vod/recorder').activeCount(),++ uptime_s: Math.round(process.uptime()),++ });++});++++app.use('/api/v1/:app/vods', require('./vod/routes'));++app.use('/api/v1/:app/clips', require('./vod/clips-routes'));++app.use('/api/v1/:app/pastes', require('./pastes/routes'));++app.use('/api/v1/:app/files', require('./files/routes'));++app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));++app.use('/', require('./public/routes')); // /v /c /p /t /f++++// eslint-disable-next-line no-unused-vars++app.use((err, req, res, next) => {++ console.error('[HTTP] Unhandled error:', err.message);++ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Internal error' });++});++++// ── Background jobs ──────────────────────────────────────────++++const recorder = require('./vod/recorder');++const vodStorage = require('./vod/vod-storage');++const healthJob = require('./vod/health-job');++const thumbService = require('./thumbnails/thumbnail-service');++++const timers = [];++function every(ms, fn) {++ const t = setInterval(fn, ms);++ if (t.unref) t.unref();++ timers.push(t);++}++++vodStorage.checkProviders()++ .then(() => vodStorage.migrateLegacy().catch(() => {}))++ .catch(err => console.warn('[Boot] Provider check failed:', err.message));++vodStorage.start(); // tiering sweep++healthJob.start(); // health scan + quarantine cleanup++every(60 * 1000, () => recorder.checkDisk()); // disk guardian++every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs++++// Recover from an unclean shutdown: rows stuck in is_recording with no live++// ffmpeg are finalized from whatever hit the disk.++setTimeout(() => {++ try {++ const stuck = db.all('SELECT id FROM vods WHERE is_recording = 1');++ for (const row of stuck) {++ if (recorder.isRecording(row.id)) continue;++ console.log(`[Boot] Finalizing orphaned recording vod ${row.id}`);++ require('./vod/finalize').finalizeVod(row.id).catch(() => {});++ }++ } catch (err) {++ console.warn('[Boot] Orphan finalize sweep failed:', err.message);++ }++}, 5000).unref?.();++++// ── Listen + graceful shutdown ───────────────────────────────++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Media] OpenVibe.Media listening on ${config.host}:${config.port} (${config.nodeEnv})`);++ console.log(`[Media] Data: db=${config.db.path} vods=${config.vod.path} clips=${config.vod.clipsPath}`);++ console.log(`[Media] RTP ingest pool: udp ${config.rtp.portMin}-${config.rtp.portMax} (127.0.0.1)`);++});++++let shuttingDown = false;++function shutdown(signal) {++ if (shuttingDown) return;++ shuttingDown = true;++ console.log(`[Media] ${signal} received — shutting down`);++ try { recorder.stopAll(); } catch { /* */ }++ try { vodStorage.stop(); } catch { /* */ }++ try { healthJob.stop(); } catch { /* */ }++ try { auth.stopJwksRefresh(); } catch { /* */ }++ for (const t of timers) clearInterval(t);++ server.close(() => {++ try { db.close(); } catch { /* */ }++ process.exit(0);++ });++ // Recordings get STOP_GRACE_MS to flush trailers; don't hang forever.++ setTimeout(() => {++ console.warn('[Media] Forced exit after shutdown grace');++ try { db.close(); } catch { /* */ }++ process.exit(0);++ }, 70_000).unref();++}++process.on('SIGINT', () => shutdown('SIGINT'));++process.on('SIGTERM', () => shutdown('SIGTERM'));++++module.exports = { app, server };nnew file mode 100644@@@ -0,0 +1,627 @@++/**++ * OpenVibe.Media — Pastes API (mounted at /api/v1/:app/pastes)++ *++ * Pastebin-style sharing ported from the predecessor: text pastes, code++ * snippets, and screenshots (EXIF-stripped via sharp). Public pages live at++ * /p/:slug (HTML) and /p/:slug/raw.++ *++ * POST / { title?, content?, language?, user_id?, visibility?,++ * burn_after_read?, is_nsfw?, screenshot (multipart)? }++ * → { id, slug, url }++ * GET / list (?limit&offset&type&search&user_id)++ * GET /:slug paste meta/content++ * PUT /:slug update++ * DELETE /:slug delete (+ screenshot local & legacy B2)++ * POST /:slug/fork fork a text paste++ * POST /:slug/like toggle-like (user identity required)++ * POST /:slug/copy track a copy event++ * GET /:slug/comments list comments (+replies)++ * POST /:slug/comments add comment++ * DELETE /:slug/comments/:id delete comment++ *++ * Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are++ * trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)++ * but the columns remain for imported rows.++ */++'use strict';++++const express = require('express');++const crypto = require('crypto');++const path = require('path');++const fs = require('fs');++const multer = require('multer');++const sharp = require('sharp');++const db = require('../db/database');++const config = require('../config');++const { tenantAuth, tenantCors } = require('../auth');++++const router = express.Router({ mergeParams: true });++router.use(tenantCors);++++// ── Screenshot upload storage ───────────────────────────────++const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');++if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });++++const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };++const screenshotStorage = multer.diskStorage({++ destination: (_req, _file, cb) => cb(null, SCREENSHOTS_DIR),++ filename: (_req, file, cb) => {++ const ext = MIME_TO_EXT[file.mimetype] || '.png';++ cb(null, `ss-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);++ },++});++const screenshotUpload = multer({++ storage: screenshotStorage,++ limits: { fileSize: 16 * 1024 * 1024 }, // hard cap; per-app setting enforced below++ fileFilter: (_req, file, cb) => {++ if (/^image\/(png|jpeg|webp|gif)$/.test(file.mimetype)) cb(null, true);++ else cb(new Error('Only PNG, JPEG, WebP, or GIF images allowed'));++ },++});++++// ── Helpers ─────────────────────────────────────────────────++++// Word pools for readable paste slugs (adj-noun-number)++const SLUG_ADJECTIVES = [++ 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',++ 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',++ 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',++ 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',++ 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',++ 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',++ 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',++ 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',++ 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',++ 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',++ 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',++ 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',++ 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',++ 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',++ 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',++];++const SLUG_NOUNS = [++ 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',++ 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',++ 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',++ 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',++ 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',++ 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',++ 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',++ 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',++ 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',++ 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',++ 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',++ 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',++ 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',++ 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',++ 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',++ 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',++ 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',++];++++function generateSlug(attempts = 0) {++ if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');++ const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];++ const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];++ const num = crypto.randomInt(10, 100); // 10–99++ const slug = `${adj}-${noun}-${num}`;++ const existing = db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);++ if (existing) return generateSlug(attempts + 1);++ return slug;++}++++function sanitizeTitle(title) {++ return String(title || '').trim().slice(0, 200) || 'Untitled';++}++++function detectLanguage(content, hint) {++ if (hint && hint !== 'auto') return hint;++ const first = String(content || '').slice(0, 500);++ if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';++ if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';++ if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';++ if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';++ if (/^\{[\s\n]*"/.test(first)) return 'json';++ if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';++ if (/^#!\/(bin|usr)/m.test(first)) return 'bash';++ if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';++ if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';++ if (/^<\?php/m.test(first)) return 'php';++ if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';++ return 'text';++}++++function getPasteConfig() {++ return {++ maxSizeKb: Number(db.getSetting('paste_max_size_kb')) || 512,++ screenshotMaxSizeMb: Number(db.getSetting('paste_screenshot_max_size_mb')) || 8,++ cooldownSeconds: Number(db.getSetting('paste_cooldown_seconds')) || 30,++ maxPerUserPerDay: Number(db.getSetting('paste_max_per_user_per_day')) || 0,++ };++}++++function screenshotUrl(paste) {++ return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;++}++++function pastePublic(paste) {++ if (!paste) return null;++ return {++ id: paste.id,++ app_id: paste.app_id,++ slug: paste.slug,++ user_id: paste.user_id,++ type: paste.type,++ title: paste.title,++ content: paste.content,++ language: paste.language,++ visibility: paste.visibility,++ stream_id: paste.stream_id,++ screenshot_url: screenshotUrl(paste),++ metadata: paste.metadata,++ burn_after_read: !!paste.burn_after_read,++ forked_from: paste.forked_from,++ pinned: !!paste.pinned,++ views: paste.views || 0,++ copies: paste.copies || 0,++ likes: paste.likes || 0,++ is_nsfw: !!paste.is_nsfw,++ url: `/p/${paste.slug}`,++ raw_url: `/p/${paste.slug}/raw`,++ created_at: paste.created_at,++ updated_at: paste.updated_at,++ };++}++++// Effective actor for rate limits: user-JWT callers get cooldowns; app-key++// callers are trusted server-to-server (their platform enforces its own).++function _actor(req) {++ if (req.authType === 'user') return { userId: req.userId, ip: req.ip, limited: true };++ return { userId: (req.body && req.body.user_id) || null, ip: req.ip, limited: false };++}++++function _rateLimitCheck(req, res) {++ const actor = _actor(req);++ if (!actor.limited) return true;++ const cfg = getPasteConfig();++ if (cfg.cooldownSeconds > 0) {++ const lastTime = db.getLastPasteTime(req.appId, actor.userId, actor.ip);++ const elapsed = (Date.now() - lastTime) / 1000;++ if (elapsed < cfg.cooldownSeconds) {++ const wait = Math.ceil(cfg.cooldownSeconds - elapsed);++ res.status(429).json({ error: `Please wait ${wait}s before creating another paste`, cooldown: wait });++ return false;++ }++ }++ if (cfg.maxPerUserPerDay > 0) {++ const todayCount = db.countUserPastesToday(req.appId, actor.userId, actor.ip);++ if (todayCount >= cfg.maxPerUserPerDay) {++ res.status(429).json({ error: `Daily paste limit reached (${cfg.maxPerUserPerDay}/day)` });++ return false;++ }++ }++ return true;++}++++function _getPasteScoped(req, res) {++ const paste = db.getPasteBySlug(String(req.params.slug), req.appId);++ if (!paste) { res.status(404).json({ error: 'Paste not found' }); return null; }++ return paste;++}++++// Fully remove a paste's screenshot from local disk AND any legacy B2 object.++function removePasteScreenshot(paste) {++ if (!paste) return;++ if (paste.screenshot_path) {++ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }++ }++ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }++}++++// ── List pastes ─────────────────────────────────────────────++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);++ const offset = Math.max(parseInt(req.query.offset) || 0, 0);++ const type = req.query.type; // 'paste', 'screenshot', or undefined for all++ const search = req.query.search ? `%${req.query.search}%` : null;++ const userId = req.query.user_id != null ? req.query.user_id : null;++++ let sql = `SELECT * FROM pastes WHERE app_id = ? AND visibility = 'public'`;++ const params = [req.appId];++++ if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }++ if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }++ if (search) { sql += ` AND (title LIKE ? OR content LIKE ?)`; params.push(search, search); }++++ const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';++ sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;++ params.push(limit, offset);++++ const pastes = db.all(sql, params).map(p => ({++ ...pastePublic(p),++ content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list++ }));++++ let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? AND visibility = 'public'`;++ const countParams = [req.appId];++ if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }++ if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }++ if (search) { countSql += ` AND (title LIKE ? OR content LIKE ?)`; countParams.push(search, search); }++ const { total } = db.get(countSql, countParams);++++ res.json({ pastes, total, limit, offset });++ } catch (err) {++ console.error('[Pastes] List error:', err.message);++ res.status(500).json({ error: 'Failed to list pastes' });++ }++});++++// ── Get single paste by slug ────────────────────────────────++router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++++ // Private pastes: owner (or the app itself) only.++ if (paste.visibility === 'private') {++ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);++ if (!allowed) return res.status(404).json({ error: 'Paste not found' });++ }++++ paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;++ res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });++ } catch (err) {++ console.error('[Pastes] Get error:', err.message);++ res.status(500).json({ error: 'Failed to get paste' });++ }++});++++// ── Create paste (text, or screenshot via multipart) ────────++router.post('/', tenantAuth({ allowUser: true }), screenshotUpload.single('screenshot'), async (req, res) => {++ try {++ const body = req.body || {};++ const cfg = getPasteConfig();++++ if (!_rateLimitCheck(req, res)) {++ if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }++ return;++ }++++ const actor = _actor(req);++ const userId = req.authType === 'user' ? req.userId : (body.user_id != null ? body.user_id : null);++ const vis = ['unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';++ const burn = body.burn_after_read ? 1 : 0;++ const nsfw = body.is_nsfw ? 1 : 0;++ const slug = generateSlug();++++ // ── Screenshot paste ────────────────────────────────++ if (req.file) {++ if (req.file.size > cfg.screenshotMaxSizeMb * 1024 * 1024) {++ try { fs.unlinkSync(req.file.path); } catch { /* */ }++ return res.status(400).json({ error: `File too large (max ${cfg.screenshotMaxSizeMb} MB)` });++ }++++ // Strip EXIF/GPS metadata by re-encoding with sharp (GIFs kept as-is++ // so animations survive).++ const mime = req.file.mimetype;++ if (mime === 'image/png' || mime === 'image/jpeg' || mime === 'image/webp') {++ try {++ const img = sharp(req.file.path);++ let buf;++ if (mime === 'image/png') buf = await img.png().toBuffer();++ else if (mime === 'image/jpeg') buf = await img.jpeg({ quality: 90 }).toBuffer();++ else buf = await img.webp({ quality: 90 }).toBuffer();++ fs.writeFileSync(req.file.path, buf);++ req.file.size = buf.length;++ } catch (stripErr) {++ console.warn('[Pastes] Metadata strip warning:', stripErr.message);++ // Continue with original file if re-encoding fails++ }++ }++++ const metadata = JSON.stringify({++ page_url: body.page_url || null,++ user_agent: body.user_agent || req.get('user-agent') || null,++ original_name: req.file.originalname,++ size_bytes: req.file.size,++ mime_type: req.file.mimetype,++ });++++ db.run(++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, screenshot_path, metadata, burn_after_read, is_nsfw, ip_address)++ VALUES (?, ?, ?, 'screenshot', ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?)`,++ [req.appId, slug, userId, sanitizeTitle(body.title || 'Screenshot'),++ body.description || body.content || '', vis, body.stream_id || null, req.file.path, metadata, burn, nsfw, actor.ip]++ );++++ const paste = db.getPasteBySlug(slug, req.appId);++ return res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });++ }++++ // ── Text paste ──────────────────────────────────────++ const content = body.content;++ if (!content || typeof content !== 'string' || content.trim().length === 0) {++ return res.status(400).json({ error: 'Content is required' });++ }++ const maxBytes = cfg.maxSizeKb * 1024;++ if (content.length > maxBytes) {++ return res.status(400).json({ error: `Paste too large (max ${cfg.maxSizeKb} KB)` });++ }++++ const lang = detectLanguage(content, body.language);++ db.run(++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, burn_after_read, is_nsfw, ip_address)++ VALUES (?, ?, ?, 'paste', ?, ?, ?, ?, ?, ?, ?, ?)`,++ [req.appId, slug, userId, sanitizeTitle(body.title), content.trim(), lang, vis,++ body.stream_id || null, burn, nsfw, actor.ip]++ );++++ const paste = db.getPasteBySlug(slug, req.appId);++ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });++ } catch (err) {++ console.error('[Pastes] Create error:', err.message);++ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }++ res.status(500).json({ error: 'Failed to create paste' });++ }++});++++// ── Update paste ────────────────────────────────────────────++router.put('/:slug', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {++ return res.status(403).json({ error: 'Not authorized for this paste' });++ }++++ const { title, content, language, visibility, pinned, is_nsfw } = req.body || {};++ const updates = [];++ const params = [];++++ if (title !== undefined) { updates.push('title = ?'); params.push(sanitizeTitle(title)); }++ if (content !== undefined && paste.type === 'paste') {++ const cfg = getPasteConfig();++ if (content.length > cfg.maxSizeKb * 1024) return res.status(400).json({ error: 'Too large' });++ updates.push('content = ?'); params.push(content);++ updates.push('language = ?'); params.push(detectLanguage(content, language));++ }++ if (visibility !== undefined) { updates.push('visibility = ?'); params.push(['unlisted', 'private'].includes(visibility) ? visibility : 'public'); }++ if (is_nsfw !== undefined) { updates.push('is_nsfw = ?'); params.push(is_nsfw ? 1 : 0); }++ if (pinned !== undefined && req.authType === 'app') {++ updates.push('pinned = ?'); params.push(pinned ? 1 : 0);++ }++++ if (updates.length === 0) return res.status(400).json({ error: 'Nothing to update' });++++ updates.push('updated_at = CURRENT_TIMESTAMP');++ params.push(paste.id);++ db.run(`UPDATE pastes SET ${updates.join(', ')} WHERE id = ?`, params);++ res.json({ paste: pastePublic(db.getPasteBySlug(paste.slug, req.appId)) });++ } catch (err) {++ console.error('[Pastes] Update error:', err.message);++ res.status(500).json({ error: 'Failed to update paste' });++ }++});++++// ── Delete paste ────────────────────────────────────────────++router.delete('/:slug', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {++ return res.status(403).json({ error: 'Not authorized for this paste' });++ }++++ removePasteScreenshot(paste);++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);++ res.json({ success: true });++ } catch (err) {++ console.error('[Pastes] Delete error:', err.message);++ res.status(500).json({ error: 'Failed to delete paste' });++ }++});++++// ── Fork (copy) a paste ─────────────────────────────────────++router.post('/:slug/fork', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const original = _getPasteScoped(req, res);++ if (!original) return;++ if (original.type !== 'paste') return res.status(400).json({ error: 'Only text pastes can be forked' });++ if (!_rateLimitCheck(req, res)) return;++++ const actor = _actor(req);++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);++ const slug = generateSlug();++ db.run(++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, forked_from, ip_address)++ VALUES (?, ?, ?, 'paste', ?, ?, ?, 'public', ?, ?)`,++ [req.appId, slug, userId, `Fork of ${original.title}`,++ original.content, original.language, original.id, actor.ip]++ );++++ const paste = db.getPasteBySlug(slug, req.appId);++ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });++ } catch (err) {++ console.error('[Pastes] Fork error:', err.message);++ res.status(500).json({ error: 'Failed to fork paste' });++ }++});++++// ── Like / Unlike a paste ───────────────────────────────────++router.post('/:slug/like', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);++ if (userId == null) return res.status(400).json({ error: 'user_id required to like' });++++ const alreadyLiked = db.hasUserLikedPaste(paste.id, userId);++ const result = alreadyLiked ? db.unlikePaste(paste.id, userId) : db.likePaste(paste.id, userId);++ res.json({ liked: !alreadyLiked, likes: result?.likes || 0 });++ } catch (err) {++ console.error('[Pastes] Like error:', err.message);++ res.status(500).json({ error: 'Failed to toggle like' });++ }++});++++// ── Track a copy event ──────────────────────────────────────++router.post('/:slug/copy', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++ db.incrementPasteCopies(paste.slug);++ res.json({ copies: (paste.copies || 0) + 1 });++ } catch (err) {++ res.status(500).json({ error: 'Failed to track copy' });++ }++});++++// ═════════════════════════════════════════════════════════════++// ── Paste Comments (supports anonymous via app callers) ─────++// ═════════════════════════════════════════════════════════════++++// Rate-limit state: IP → timestamp of last comment++const commentCooldowns = new Map();++const _cooldownSweep = setInterval(() => {++ const now = Date.now();++ for (const [ip, ts] of commentCooldowns) {++ if (now - ts > 120_000) commentCooldowns.delete(ip);++ }++}, 600_000);++if (_cooldownSweep.unref) _cooldownSweep.unref();++++router.get('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++++ const limit = Math.min(parseInt(req.query.limit || '50'), 100);++ const offset = parseInt(req.query.offset || '0');++++ const comments = db.getPasteComments(paste.id, limit, offset);++ const total = db.getPasteCommentCount(paste.id);++++ for (const c of comments) {++ c.replies = db.getPasteCommentReplies(c.id);++ c.reply_count = c.replies.length;++ }++++ res.json({ comments, total });++ } catch (err) {++ console.error('[PasteComments] List error:', err.message);++ res.status(500).json({ error: 'Failed to load comments' });++ }++});++++router.post('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++++ const ip = req.ip || 'unknown';++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);++++ // ── Anon check ──────────────────────────────────────++ if (userId == null && db.getSetting('paste_comment_anon_allowed') === false) {++ return res.status(401).json({ error: 'You must be logged in to comment' });++ }++++ // ── Message validation ──────────────────────────────++ const message = (req.body?.message || '').trim();++ const maxLen = Number(db.getSetting('paste_comment_max_length')) || 2000;++ if (!message) return res.status(400).json({ error: 'Comment cannot be empty' });++ if (message.length > maxLen) {++ return res.status(400).json({ error: `Comment must be under ${maxLen} characters` });++ }++++ // ── Anon name validation ────────────────────────────++ let anonName = null;++ if (userId == null) {++ anonName = (req.body?.anon_name || '').trim().substring(0, 32) || 'Anonymous';++ anonName = anonName.replace(/[^a-zA-Z0-9 _\-]/g, '').trim() || 'Anonymous';++ }++++ // ── Rate limits (user-JWT/browser callers) ──────────++ if (req.authType === 'user') {++ const cooldownSec = Number(db.getSetting('paste_comment_cooldown_seconds')) || 10;++ const lastComment = commentCooldowns.get(ip);++ if (lastComment && (Date.now() - lastComment) < cooldownSec * 1000) {++ const wait = Math.ceil((cooldownSec * 1000 - (Date.now() - lastComment)) / 1000);++ return res.status(429).json({ error: `Please wait ${wait}s before commenting again` });++ }++ const recentFromIp = db.getRecentPasteCommentsByIp(ip, 60);++ if (recentFromIp.length >= 5) {++ return res.status(429).json({ error: 'Too many comments. Please slow down.' });++ }++ if (recentFromIp.length > 0 && recentFromIp[0].message === message) {++ return res.status(400).json({ error: 'Duplicate comment' });++ }++ }++++ // ── Parent comment validation ───────────────────────++ const parentId = req.body?.parent_id ? parseInt(req.body.parent_id) : null;++ let parent = null;++ if (parentId) {++ parent = db.getPasteCommentById(parentId);++ if (!parent || parent.paste_id !== paste.id) {++ return res.status(400).json({ error: 'Invalid parent comment' });++ }++ // Prevent deeply nested replies — only allow replies to top-level++ if (parent.parent_id) {++ return res.status(400).json({ error: 'Cannot reply to a reply — reply to the original comment instead' });++ }++ }++++ const result = db.createPasteComment({++ paste_id: paste.id,++ user_id: userId,++ parent_id: parentId,++ anon_name: anonName,++ message,++ ip_address: ip,++ });++++ commentCooldowns.set(ip, Date.now());++++ const comment = db.getPasteCommentById(result.lastInsertRowid);++ res.status(201).json({ comment });++ } catch (err) {++ console.error('[PasteComments] Create error:', err.message);++ res.status(500).json({ error: 'Failed to post comment' });++ }++});++++router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const paste = _getPasteScoped(req, res);++ if (!paste) return;++++ const comment = db.getPasteCommentById(parseInt(req.params.commentId));++ if (!comment || comment.paste_id !== paste.id) {++ return res.status(404).json({ error: 'Comment not found' });++ }++++ // App callers moderate freely; user callers must be the comment author++ // or the paste owner.++ if (req.authType === 'user') {++ const isAuthor = comment.user_id != null && comment.user_id === req.userId;++ const isPasteOwner = paste.user_id != null && paste.user_id === req.userId;++ if (!isAuthor && !isPasteOwner) {++ return res.status(403).json({ error: 'Not authorized to delete this comment' });++ }++ }++++ db.deletePasteComment(comment.id);++ res.json({ message: 'Comment deleted' });++ } catch (err) {++ console.error('[PasteComments] Delete error:', err.message);++ res.status(500).json({ error: 'Failed to delete comment' });++ }++});++++module.exports = router;++module.exports.pastePublic = pastePublic;++module.exports.screenshotUrl = screenshotUrl;++module.exports.removePasteScreenshot = removePasteScreenshot;++module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;nnew file mode 100644@@@ -0,0 +1,351 @@++/**++ * OpenVibe.Media — Public serving routes (no auth unless the item is private)++ *++ * GET /v/:id VOD playback: local stream w/ Range, or 302 presigned++ * B2/R2 (inherited logic). Live recordings serve the++ * fully-indexed .seekable sidecar for DVR.++ * GET /c/:id clip playback (same tiering/range logic)++ * GET /p/:slug paste HTML page (indexable)++ * GET /p/:slug/raw paste raw text++ * GET /p/:slug/screenshot paste screenshot image++ * GET /t/:id thumbnails (id = filename)++ * GET /f/:key files with correct Content-Type + Range++ *++ * /v /c /t /f carry X-Robots-Tag: noindex — the owning app has the canonical++ * page. Pastes are Media-canonical and stay indexable.++ */++'use strict';++++const express = require('express');++const path = require('path');++const fs = require('fs');++const db = require('../db/database');++const tools = require('../vod/media-tools');++const { optionalIdentity } = require('../auth');++++const router = express.Router();++++const MIME_TYPES = {++ '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',++ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',++};++++function getRequesterIp(req) {++ return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';++}++++function trackUniqueView(kind, id, req) {++ try {++ const ip = getRequesterIp(req);++ const inserted = db.run(++ 'INSERT OR IGNORE INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?)',++ [kind, id, ip]++ );++ if (inserted.changes > 0) {++ const count = db.get('SELECT COUNT(*) as c FROM content_views WHERE content_type = ? AND content_id = ?', [kind, id]);++ const table = kind === 'vod' ? 'vods' : 'clips';++ db.run(`UPDATE ${table} SET view_count = ? WHERE id = ?`, [count.c, id]);++ }++ } catch { /* non-critical */ }++}++++function canAccessPrivate(record, req) {++ // Private items: the owning app (its API key) or the owning user's JWT.++ if (req.authType === 'app' && req.appId === record.app_id) return true;++ if (req.authType === 'user' && req.userId != null && record.user_id === req.userId) return true;++ return false;++}++++function streamFileWithRange(req, res, filePath, extraHeaders = {}) {++ const stat = fs.statSync(filePath);++ const range = req.headers.range;++ const ext = path.extname(filePath).toLowerCase();++ const contentType = MIME_TYPES[ext] || 'application/octet-stream';++++ if (range) {++ const parts = range.replace(/bytes=/, '').split('-');++ const start = parseInt(parts[0], 10) || 0;++ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;++ if (start >= stat.size || end < start) {++ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...extraHeaders });++ return res.end();++ }++ const chunkSize = Math.min(end, stat.size - 1) - start + 1;++ res.writeHead(206, {++ 'Content-Range': `bytes ${start}-${Math.min(end, stat.size - 1)}/${stat.size}`,++ 'Accept-Ranges': 'bytes',++ 'Content-Length': chunkSize,++ 'Content-Type': contentType,++ ...extraHeaders,++ });++ fs.createReadStream(filePath, { start, end: Math.min(end, stat.size - 1) }).pipe(res);++ } else {++ res.writeHead(200, {++ 'Content-Length': stat.size,++ 'Content-Type': contentType,++ 'Accept-Ranges': 'bytes',++ ...extraHeaders,++ });++ fs.createReadStream(filePath).pipe(res);++ }++}++++/**++ * Serve a vods/clips row: local file (Range + live-DVR sidecar) or 302++ * presigned B2/R2 — the inherited playback logic.++ */++async function serveMediaRecord(kind, record, req, res) {++ const vodStorage = require('../vod/vod-storage');++ const noindex = { 'X-Robots-Tag': 'noindex' };++++ const visibility = record.visibility || (record.is_public ? 'public' : 'private');++ if (visibility === 'private' && !canAccessPrivate(record, req)) {++ return res.status(403).json({ error: 'This media is private' });++ }++++ trackUniqueView(kind, record.id, req);++++ // Track last access time for storage tier decisions++ if (kind === 'vod') {++ try { db.run("UPDATE vods SET last_accessed_at = datetime('now') WHERE id = ?", [record.id]); } catch {}++ }++++ // Resolve the local file. Clips keep their own absolute path; VODs resolve++ // by basename under VOD_PATH (legacy rows carry old absolute paths).++ let filePath = null;++ if (record.file_path) {++ const candidates = kind === 'vod'++ ? [vodStorage.localPathForVod(record), record.file_path]++ : [record.file_path, path.join(path.resolve(require('../config').vod.clipsPath), path.basename(record.file_path))];++ for (const p of candidates) {++ if (p && fs.existsSync(p)) { filePath = p; break; }++ }++ }++++ // Offloaded (B2/R2) — redirect to a presigned object-store URL. Range++ // requests are handled natively by the object store.++ if (!filePath && vodStorage.isRemote(record)) {++ const plan = await vodStorage.resolvePlayback(record);++ if (plan?.kind === 'redirect') {++ res.set('Cache-Control', 'private, max-age=0');++ res.set('X-Robots-Tag', 'noindex');++ return res.redirect(302, plan.url);++ }++ if (plan?.kind === 'file') filePath = plan.path;++ }++++ if (!filePath) return res.status(404).json({ error: 'Media file unavailable' });++++ // For a live recording, serve the fully-indexed seekable sidecar so DVR++ // viewers can seek anywhere.++ const seekablePath = tools.seekableSidecarPath(filePath);++ const isLiveSeekable = !!record.is_recording && !!seekablePath && seekablePath !== filePath && fs.existsSync(seekablePath);++ if (isLiveSeekable) filePath = seekablePath;++ // A still-recording MP4 grows in place — serve it no-cache so the player++ // keeps discovering new bytes (DVR to the live edge).++ const isLiveMp4 = !!record.is_recording && filePath.endsWith('.mp4');++++ const cacheHeaders = (isLiveSeekable || isLiveMp4)++ ? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }++ : {};++++ streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex });++}++++// ── VOD playback ─────────────────────────────────────────────++router.get('/v/:id', optionalIdentity, async (req, res) => {++ try {++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });++ const vod = db.getVodById(parseInt(req.params.id, 10));++ if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });++ await serveMediaRecord('vod', vod, req, res);++ } catch (err) {++ console.error('[Public] /v error:', err.message);++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });++ }++});++++// ── Clip playback ────────────────────────────────────────────++router.get('/c/:id', optionalIdentity, async (req, res) => {++ try {++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });++ const clip = db.getClipById(parseInt(req.params.id, 10));++ if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });++ await serveMediaRecord('clip', clip, req, res);++ } catch (err) {++ console.error('[Public] /c error:', err.message);++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });++ }++});++++// ── Thumbnails ───────────────────────────────────────────────++router.get('/t/:id', (req, res) => {++ require('../thumbnails/thumbnail-service').serveThumbnail(req, res);++});++++// ── Files ────────────────────────────────────────────────────++router.get('/f/:key', (req, res) => {++ try {++ const row = db.getFileByKey(String(req.params.key));++ if (!row) return res.status(404).json({ error: 'Not found' });++ const filesRoutes = require('../files/routes');++ const filePath = filesRoutes.filePathForKey(row);++ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });++++ const stat = fs.statSync(filePath);++ const range = req.headers.range;++ const contentType = row.mime || 'application/octet-stream';++ const headers = {++ 'X-Robots-Tag': 'noindex',++ 'Cache-Control': 'public, max-age=86400',++ };++ if (range) {++ const parts = range.replace(/bytes=/, '').split('-');++ const start = parseInt(parts[0], 10) || 0;++ const end = parts[1] ? Math.min(parseInt(parts[1], 10), stat.size - 1) : stat.size - 1;++ if (start >= stat.size || end < start) {++ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...headers });++ return res.end();++ }++ res.writeHead(206, {++ 'Content-Range': `bytes ${start}-${end}/${stat.size}`,++ 'Accept-Ranges': 'bytes',++ 'Content-Length': end - start + 1,++ 'Content-Type': contentType,++ ...headers,++ });++ fs.createReadStream(filePath, { start, end }).pipe(res);++ } else {++ res.writeHead(200, {++ 'Content-Length': stat.size,++ 'Content-Type': contentType,++ 'Accept-Ranges': 'bytes',++ ...headers,++ });++ fs.createReadStream(filePath).pipe(res);++ }++ } catch (err) {++ console.error('[Public] /f error:', err.message);++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });++ }++});++++// ── Pastes ───────────────────────────────────────────────────++++function escapeHtml(s) {++ return String(s ?? '')++ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')++ .replace(/"/g, '"').replace(/'/g, ''');++}++++function renderPastePage(paste) {++ const title = escapeHtml(paste.title || 'Untitled');++ const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;++ const body = isScreenshot++ ? `<figure class="shot"><img src="/p/${escapeHtml(paste.slug)}/screenshot" alt="${title}"></figure>++ ${paste.content ? `<p class="desc">${escapeHtml(paste.content)}</p>` : ''}`++ : `<pre class="code" data-language="${escapeHtml(paste.language || 'text')}"><code>${escapeHtml(paste.content || '')}</code></pre>`;++ const created = escapeHtml(String(paste.created_at || ''));++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${title} — OpenVibe.Media</title>++<meta name="description" content="${title} — shared via OpenVibe.Media pastes">++<style>++ :root { --accent: #8b5cf6; --accent-light: #a78bfa; --bg: #131318; --panel: #1b1b22; --text: #e8e8ee; --muted: #9a9aa8; }++ * { box-sizing: border-box; }++ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; }++ header { display: flex; align-items: center; gap: .6rem; padding: .8rem 1.2rem; border-bottom: 1px solid #26262f; }++ header .brand { font-weight: 700; color: var(--accent-light); text-decoration: none; font-size: 1.05rem; }++ header .brand span { color: var(--text); }++ main { max-width: 960px; margin: 0 auto; padding: 1.2rem; }++ h1 { font-size: 1.3rem; margin: .2rem 0 .3rem; }++ .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1rem; }++ .meta a { color: var(--accent-light); text-decoration: none; }++ .code { background: var(--panel); border: 1px solid #26262f; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; }++ .shot img { max-width: 100%; border-radius: 10px; border: 1px solid #26262f; }++ .desc { color: var(--muted); }++ footer { max-width: 960px; margin: 0 auto; padding: 1rem 1.2rem 2rem; color: var(--muted); font-size: .82rem; }++ footer a { color: var(--accent-light); text-decoration: none; }++</style>++</head>++<body>++<header>++ <a class="brand" href="https://openvibe.network">OpenVibe<span>.Media</span></a>++</header>++<main>++ <h1>${title}</h1>++ <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>++ ${body}++</main>++<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>++</body>++</html>`;++}++++router.get('/p/:slug', optionalIdentity, (req, res) => {++ try {++ const paste = db.getPasteBySlug(String(req.params.slug));++ if (!paste) return res.status(404).send('Paste not found');++++ // Unlisted pastes stay reachable by direct link (that's the point).++ // Private pastes: owning app/user only.++ if (paste.visibility === 'private' && !canAccessPrivate(paste, req)) {++ return res.status(404).send('Paste not found');++ }++++ // Increment view count (don't count the owner's views)++ const isOwner = req.userId != null && paste.user_id === req.userId;++ if (!isOwner) {++ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);++ paste.views += 1;++ }++++ // Burn-after-read: allow one non-owner read, then delete.++ if (paste.burn_after_read && !isOwner && paste.views > 1) {++ require('../pastes/routes').removePasteScreenshot(paste);++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);++ return res.status(410).send('This paste has been burned after reading.');++ }++++ res.type('html').send(renderPastePage(paste));++ } catch (err) {++ console.error('[Public] /p error:', err.message);++ res.status(500).send('Error');++ }++});++++router.get('/p/:slug/raw', (req, res) => {++ try {++ const paste = db.getPasteBySlug(String(req.params.slug));++ if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');++ if (paste.visibility === 'private') return res.status(404).send('Not found');++++ // Burn after read++ if (paste.burn_after_read && paste.views > 0) {++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);++ return res.status(410).send('This paste has been burned after reading.');++ }++++ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);++ res.type('text/plain').send(paste.content);++ } catch {++ res.status(500).send('Error');++ }++});++++router.get('/p/:slug/screenshot', (req, res) => {++ try {++ const paste = db.getPasteBySlug(String(req.params.slug));++ if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');++ if (paste.visibility === 'private') return res.status(404).send('Not found');++ if (!fs.existsSync(paste.screenshot_path)) return res.status(404).send('Not found');++ streamFileWithRange(req, res, paste.screenshot_path, { 'Cache-Control': 'public, max-age=86400' });++ } catch {++ res.status(500).send('Error');++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,89 @@++/**++ * OpenVibe.Media — Thumbnail API routes (mounted at /api/v1/:app/thumbnails)++ *++ * POST /:kind/:id (upload or generate) → { url }++ * kind = 'vod' | 'clip' → multipart 'thumbnail' (or JSON { image: base64 })++ * uploads a custom image; with no image, the++ * thumbnail is (re)generated from the media file.++ * kind = 'live'|'stream' → upload only; stored under a stable per-app/id++ * filename so the URL survives refreshes.++ *++ * Public serving is at GET /t/:id (see public routes).++ */++'use strict';++++const express = require('express');++const path = require('path');++const fs = require('fs');++const multer = require('multer');++const db = require('../db/database');++const thumbService = require('./thumbnail-service');++const { tenantAuth, tenantCors } = require('../auth');++++const router = express.Router({ mergeParams: true });++router.use(tenantCors);++++// Multer for raw image upload (max 2 MB)++const upload = multer({++ storage: multer.memoryStorage(),++ limits: { fileSize: 2 * 1024 * 1024 },++});++++function _imageFromRequest(req) {++ if (req.file) return req.file.buffer;++ if (req.body && req.body.image) return req.body.image;++ return null;++}++++router.post('/:kind/:id', tenantAuth({ allowUser: true }), upload.single('thumbnail'), async (req, res) => {++ try {++ const kind = String(req.params.kind || '').toLowerCase();++ const id = String(req.params.id);++ const imageData = _imageFromRequest(req);++++ // ── Live stream thumbnail (upload from the owning app's broadcaster) ──++ if (kind === 'live' || kind === 'stream') {++ if (!imageData) return res.status(400).json({ error: 'No image data provided' });++ const url = thumbService.saveLiveThumbnail(req.appId, id, imageData);++ if (!url) return res.status(400).json({ error: 'Invalid image data' });++ return res.json({ url });++ }++++ if (kind !== 'vod' && kind !== 'clip') {++ return res.status(400).json({ error: "kind must be 'vod', 'clip', or 'live'" });++ }++++ const numId = parseInt(id, 10);++ const row = kind === 'vod' ? db.getVodById(numId, req.appId) : db.getClipById(numId, req.appId);++ if (!row) return res.status(404).json({ error: `${kind} not found` });++++ // Custom upload → save as the entity's thumbnail.++ if (imageData) {++ let buffer = Buffer.isBuffer(imageData)++ ? imageData++ : Buffer.from(String(imageData).replace(/^data:image\/\w+;base64,/, ''), 'base64');++ if (buffer.length < 4) return res.status(400).json({ error: 'Invalid image data' });++ const filename = `${kind}-${numId}-${Date.now()}.jpg`;++ fs.writeFileSync(path.join(thumbService.THUMB_DIR, filename), buffer);++ const url = `/t/${filename}`;++ const table = kind === 'vod' ? 'vods' : 'clips';++ db.run(`UPDATE ${table} SET thumbnail_url = ? WHERE id = ?`, [url, numId]);++ return res.json({ url });++ }++++ // Generate from the media file (local or presigned remote).++ const vodStorage = require('../vod/vod-storage');++ const source = await vodStorage.resolveMediaSource(row);++ if (!source) return res.status(404).json({ error: 'Media file unavailable' });++ const url = kind === 'vod'++ ? await thumbService.generateVodThumbnail(numId, source.value)++ : await thumbService.generateClipThumbnail(numId, source.value);++ if (!url) return res.status(500).json({ error: 'Failed to generate thumbnail' });++ res.json({ url });++ } catch (err) {++ console.error('[Thumbnails] Error:', err.message);++ res.status(500).json({ error: 'Failed to process thumbnail' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,290 @@++/**++ * OpenVibe.Media — Universal Thumbnail Service++ *++ * Handles thumbnail generation, storage, and serving for:++ * • Live streams — JPEG frames POSTed by the owning app's broadcaster++ * (stable per-app/id filename, refreshed in place)++ * • VODs — extracted via ffmpeg at ~10% into the video++ * • Clips — extracted via ffmpeg near the first frames++ *++ * Thumbnails are stored as JPEGs in THUMBNAILS_PATH and served publicly at++ * GET /t/:id (id = filename). The vods/clips `thumbnail_url` column holds the++ * public path (e.g. "/t/vod-3-1672531200.jpg").++ *++ * The predecessor's server-side live grabbers (RTMP-FLV / JSMPEG-WS / SFU++ * PlainRTP) are dropped — live sources belong to the apps now; apps push++ * frames via POST /api/v1/:app/thumbnails/live/:id instead.++ */++'use strict';++++const path = require('path');++const fs = require('fs');++const { spawn } = require('child_process');++const config = require('../config');++const db = require('../db/database');++++// ── Constants ────────────────────────────────────────────────++const THUMB_DIR = path.resolve(config.thumbnails.path);++const THUMB_WIDTH = 640;++const THUMB_QUALITY = 6; // ffmpeg qscale:v (2=best, 31=worst, 6 is good balance)++const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;++++if (!fs.existsSync(THUMB_DIR)) {++ fs.mkdirSync(THUMB_DIR, { recursive: true });++}++++// ── Generate Thumbnail from Video File (VODs & Clips) ────────++const _activeThumbJobs = new Set(); // Dedup concurrent VOD/clip thumbnail generation++++/**++ * Extract a thumbnail frame from a video file using ffmpeg.++ * @param {string} videoPath – Absolute path (or presigned URL) of the video++ * @param {string} prefix – Filename prefix ('vod' or 'clip')++ * @param {number} entityId – DB id (vod or clip id)++ * @param {object} opts – { seekPercent?, seekSeconds? }++ * @returns {Promise<string|null>} public thumbnail URL (/t/<file>), or null++ */++function generateFromVideo(videoPath, prefix, entityId, opts = {}) {++ const jobKey = `${prefix}-${entityId}`;++ if (_activeThumbJobs.has(jobKey)) {++ return Promise.resolve(null); // Already generating for this entity++ }++ _activeThumbJobs.add(jobKey);++++ return new Promise((resolve) => {++ const isUrl = /^https?:\/\//i.test(String(videoPath || ''));++ if (!videoPath || (!isUrl && !fs.existsSync(videoPath))) {++ _activeThumbJobs.delete(jobKey);++ return resolve(null);++ }++++ const outFilename = `${prefix}-${entityId}-${Date.now()}.jpg`;++ const outPath = path.join(THUMB_DIR, outFilename);++++ // First, probe the duration++ const probe = spawn('ffprobe', [++ '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath,++ ]);++ let probeData = '';++ probe.stdout.on('data', (d) => (probeData += d));++++ probe.on('close', (probeCode) => {++ let seekTime = opts.seekSeconds || 1;++++ if (probeCode === 0 && !opts.seekSeconds) {++ try {++ const info = JSON.parse(probeData);++ const duration = parseFloat(info.format?.duration || '0');++ if (duration > 2) {++ seekTime = Math.min(++ duration * ((opts.seekPercent || 10) / 100),++ duration - 0.5++ );++ }++ } catch {}++ }++++ // Extract frame++ const args = [++ '-y',++ '-ss', String(Math.max(0, seekTime)),++ '-i', videoPath,++ '-vframes', '1',++ '-vf', `scale=${THUMB_WIDTH}:-1`,++ '-q:v', String(THUMB_QUALITY),++ outPath,++ ];++++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });++ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch {} }, isUrl ? 35000 : 20000);++ ff.on('close', (code) => {++ clearTimeout(killTimer);++ if (code === 0 && fs.existsSync(outPath)) {++ resolve(`/t/${outFilename}`);++ } else {++ resolve(null);++ }++ });++ ff.on('error', () => { clearTimeout(killTimer); resolve(null); });++ });++++ probe.on('error', () => resolve(null));++ }).finally(() => _activeThumbJobs.delete(jobKey));++}++++// ── Generate VOD Thumbnail (~10% in) ─────────────────────────++async function generateVodThumbnail(vodId, filePath) {++ const thumbUrl = await generateFromVideo(filePath, 'vod', vodId, { seekPercent: 10 });++ if (thumbUrl) {++ // Clean up the previous thumbnail for this VOD++ _removeOldThumb(db.get('SELECT thumbnail_url FROM vods WHERE id = ?', [vodId])?.thumbnail_url, thumbUrl);++ db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumbUrl, vodId]);++ }++ return thumbUrl;++}++++// ── Generate Clip Thumbnail (first frames) ───────────────────++async function generateClipThumbnail(clipId, filePath) {++ const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: 0.5 });++ if (thumbUrl) {++ _removeOldThumb(db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId])?.thumbnail_url, thumbUrl);++ db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]);++ }++ return thumbUrl;++}++++function _removeOldThumb(oldUrl, newUrl) {++ if (!oldUrl || oldUrl === newUrl) return;++ const oldFile = path.join(THUMB_DIR, path.basename(oldUrl));++ try { if (fs.existsSync(oldFile)) fs.unlinkSync(oldFile); } catch { /* */ }++}++++// ── Save Live Stream Thumbnail (POSTed by the owning app) ────++/**++ * Save a live-stream thumbnail for an app's stream. Accepts a Buffer of++ * JPEG/PNG data or a base64 string (with or without data: prefix). Stored++ * under a STABLE filename (stream-<app>-<id>.jpg) refreshed in place so the++ * URL is stable and files never accumulate.++ * @returns {string|null} public thumbnail URL, or null on failure++ */++function saveLiveThumbnail(appId, streamId, imageData) {++ try {++ const filename = `stream-${appId}-${streamId}.jpg`;++ const outPath = path.join(THUMB_DIR, filename);++++ // Rate-limit rewrites (the predecessor's client-write cooldown)++ try {++ const stat = fs.statSync(outPath);++ if (Date.now() - stat.mtimeMs < CLIENT_THUMB_WRITE_MIN_INTERVAL_MS) {++ return `/t/${filename}`;++ }++ } catch { /* no existing file */ }++++ let buffer;++ if (Buffer.isBuffer(imageData)) {++ buffer = imageData;++ } else if (typeof imageData === 'string') {++ const base64 = imageData.replace(/^data:image\/\w+;base64,/, '');++ buffer = Buffer.from(base64, 'base64');++ } else {++ return null;++ }++++ // Validate it's a JPEG (FF D8) or PNG (89 50)++ if (buffer.length < 3 || buffer[0] !== 0xFF || buffer[1] !== 0xD8) {++ if (buffer.length < 4 || buffer[0] !== 0x89 || buffer[1] !== 0x50) {++ console.warn('[Thumbnails] Invalid image data for stream', streamId);++ return null;++ }++ }++++ fs.writeFileSync(outPath, buffer);++ return `/t/${filename}`;++ } catch (err) {++ console.error('[Thumbnails] Save live thumbnail error:', err.message);++ return null;++ }++}++++// ── Serve Thumbnail File (public /t/:id) ─────────────────────++function serveThumbnail(req, res) {++ try {++ const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal++ const filePath = path.join(THUMB_DIR, filename);++++ if (!filename || !fs.existsSync(filePath)) {++ // Return a 1x1 transparent pixel JPEG as fallback++ const pixel = Buffer.from(++ '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',++ 'base64'++ );++ res.writeHead(200, {++ 'Content-Type': 'image/jpeg',++ 'Content-Length': pixel.length,++ 'Cache-Control': 'no-cache',++ 'X-Robots-Tag': 'noindex',++ });++ return res.end(pixel);++ }++++ const stat = fs.statSync(filePath);++ const ext = path.extname(filename).toLowerCase();++ const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';++++ // Cache briefly for live thumbnails, longer for VOD/clip++ const isLive = filename.startsWith('stream-');++ const maxAge = isLive ? 30 : 86400;++++ res.writeHead(200, {++ 'Content-Type': contentType,++ 'Content-Length': stat.size,++ 'Cache-Control': `public, max-age=${maxAge}`,++ 'X-Robots-Tag': 'noindex',++ });++ fs.createReadStream(filePath).pipe(res);++ } catch (err) {++ res.status(500).json({ error: 'Failed to serve thumbnail' });++ }++}++++// ── Cleanup Old Live Thumbnails ──────────────────────────────++/**++ * Remove live-stream thumbnails not refreshed within `maxAgeMs` (default 24h —++ * filenames are stable per stream, so this only reaps dead streams).++ */++function cleanupOldThumbnails(maxAgeMs = 24 * 3600000) {++ try {++ const files = fs.readdirSync(THUMB_DIR);++ const now = Date.now();++ let cleaned = 0;++++ for (const file of files) {++ // Only auto-clean live stream thumbs (vod/clip thumbs are permanent)++ if (!file.startsWith('stream-')) continue;++++ const filePath = path.join(THUMB_DIR, file);++ const stat = fs.statSync(filePath);++ if (now - stat.mtimeMs > maxAgeMs) {++ fs.unlinkSync(filePath);++ cleaned++;++ }++ }++++ if (cleaned > 0) {++ console.log(`[Thumbnails] Cleaned up ${cleaned} old live thumbnails`);++ }++ } catch (err) {++ console.error('[Thumbnails] Cleanup error:', err.message);++ }++}++++/**++ * Extract one frame to an arbitrary file. `source` may be a local path OR an++ * http(s) URL (e.g. a presigned B2/R2 URL) — with `-ss` before `-i`, ffmpeg++ * seeks via HTTP range requests and only pulls the bytes around that++ * timestamp, so remote/cold VODs work without downloading. Resolves boolean.++ */++function extractFrameToFile(source, seekSeconds, outAbsPath) {++ return new Promise((resolve) => {++ const isUrl = /^https?:\/\//i.test(String(source || ''));++ if (!source || (!isUrl && !fs.existsSync(source))) return resolve(false);++ try { fs.mkdirSync(path.dirname(outAbsPath), { recursive: true }); } catch { /* */ }++ const args = ['-y', '-ss', String(Math.max(0.5, Number(seekSeconds) || 1)), '-i', source,++ '-vframes', '1', '-vf', `scale=${THUMB_WIDTH}:-1`, '-q:v', String(THUMB_QUALITY), outAbsPath];++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, isUrl ? 35000 : 15000);++ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0 && fs.existsSync(outAbsPath)); });++ ff.on('error', () => { clearTimeout(to); resolve(false); });++ });++}++++module.exports = {++ generateFromVideo,++ generateVodThumbnail,++ generateClipThumbnail,++ saveLiveThumbnail,++ serveThumbnail,++ cleanupOldThumbnails,++ extractFrameToFile,++ THUMB_DIR,++};nnew file mode 100644@@@ -0,0 +1,115 @@++/**++ * OpenVibe.Media — server-side clip cutter++ *++ * Cuts a clip from any source ffmpeg can read (a local VOD/recording file OR a++ * presigned B2/R2 URL) and creates/updates the clip row + thumbnail. Ported++ * from the predecessor (AI overview + chat-notify hooks dropped — Live owns++ * those); no req/res coupling.++ */++'use strict';++++const fs = require('node:fs');++const path = require('node:path');++const { spawn } = require('node:child_process');++const db = require('../db/database');++const config = require('../config');++++const CLIPS_DIR = path.resolve(config.vod.clipsPath);++const MAX_CONCURRENT = parseInt(process.env.CLIP_MAX_CONCURRENT_FFMPEG || '3', 10);++let _active = 0;++++function _ffprobe(file) {++ return new Promise((resolve) => {++ const ff = spawn('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file], { stdio: ['ignore', 'pipe', 'ignore'] });++ let out = '';++ ff.stdout.on('data', d => { out += d; });++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);++ ff.on('close', () => { clearTimeout(to); try { resolve(JSON.parse(out)); } catch { resolve(null); } });++ ff.on('error', () => { clearTimeout(to); resolve(null); });++ });++}++++function isBusy() {++ return _active >= MAX_CONCURRENT;++}++++/**++ * Cut a clip file. Returns { ok, filePath, duration } or { ok: false, error }.++ * @param {object} o++ * @param {string} o.source local path OR http(s) URL ffmpeg can read++ * @param {number} o.startTime seconds into the source to start++ * @param {number} o.duration clip length in seconds++ */++async function cutClipFile({ source, startTime, duration }) {++ if (!source || !(duration >= 1)) return { ok: false, error: 'Invalid cut parameters' };++ const isUrl = /^https?:\/\//i.test(String(source));++ if (!isUrl && !fs.existsSync(source)) return { ok: false, error: 'Source missing' };++ if (isBusy()) return { ok: false, error: 'busy' };++ try { if (!fs.existsSync(CLIPS_DIR)) fs.mkdirSync(CLIPS_DIR, { recursive: true }); } catch { /* */ }++++ const ss = Math.max(0, Number(startTime) || 0);++ const dur = Math.max(1, Number(duration) || 20);++ const filename = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.webm`;++ const outPath = path.join(CLIPS_DIR, filename);++++ _active++;++ // Re-encode the (short) clip instead of stream-copying. `-ss` before `-i`++ // is a fast seek to the nearest keyframe, and because we re-encode, ffmpeg++ // then decodes to the EXACT requested start — the clip begins precisely++ // where asked with a fresh keyframe at t=0. A `-c copy` cut can only start++ // on a source keyframe, which on sparsely-keyframed live recordings means++ // an opening of black/frozen frames.++ const ok = await new Promise((resolve) => {++ const args = [++ '-y', '-ss', String(ss), '-i', source, '-t', String(dur),++ '-c:v', 'libvpx', '-b:v', '2000k', '-crf', '18', '-deadline', 'realtime', '-cpu-used', '4',++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-c:a', 'libopus', '-b:a', '128k',++ '-avoid_negative_ts', 'make_zero', '-f', 'webm', outPath,++ ];++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, Math.round(dur) * 2000 + (isUrl ? 40000 : 20000));++ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0); });++ ff.on('error', () => { clearTimeout(to); resolve(false); });++ }).finally(() => { _active = Math.max(0, _active - 1); });++++ if (!ok) { try { fs.existsSync(outPath) && fs.unlinkSync(outPath); } catch { /* */ } return { ok: false, error: 'ffmpeg cut failed' }; }++++ // Validate real decodable footage (a seek near EOF can yield a header-only file).++ const pi = await _ffprobe(outPath);++ const hasVideo = pi && (pi.streams || []).some(s => s.codec_type === 'video');++ const realDur = pi && pi.format ? Number(pi.format.duration) : 0;++ if (!hasVideo || !(realDur >= 1)) {++ try { fs.unlinkSync(outPath); } catch { /* */ }++ return { ok: false, error: 'No decodable footage in the requested window' };++ }++++ return { ok: true, filePath: outPath, duration: realDur };++}++++/**++ * Cut a clip and create its row (used by internal jobs; the API route creates++ * the row first and updates it). Returns the created clip row, or null.++ */++async function cutClip(o) {++ const { appId, source, startTime, duration, streamId, vodId = null, userId, title, description = '', isPublic = 1, autoGenerated = true } = o || {};++ if (!appId) return null;++ const cut = await cutClipFile({ source, startTime, duration });++ if (!cut.ok) return null;++++ const ss = Math.max(0, Number(startTime) || 0);++ const res = db.createClip({++ app_id: appId, vod_id: vodId, stream_id: streamId, user_id: userId,++ title: (title || 'Auto Clip').slice(0, 200), description: description || '',++ file_path: cut.filePath, start_time: ss, end_time: ss + cut.duration, duration_seconds: cut.duration,++ is_public: (isPublic === 0 || isPublic === false) ? 0 : 1,++ auto_generated: autoGenerated ? 1 : 0,++ status: 'ready',++ });++ const clipId = res && res.lastInsertRowid;++ if (!clipId) return null;++ const clip = db.getClipById(clipId);++ try { require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath).catch(() => {}); } catch { /* */ }++ return clip;++}++++module.exports = { cutClip, cutClipFile, isBusy, CLIPS_DIR };nnew file mode 100644@@@ -0,0 +1,240 @@++/**++ * OpenVibe.Media — Clips API routes (mounted at /api/v1/:app/clips)++ *++ * POST / { vod_id, start_s, end_s, title?, user_id? } → { id, status }++ * The cut runs in the background (works on the local file or a++ * presigned B2/R2 URL); webhook clip.ready|clip.failed fires on++ * completion. Also accepts a live cut against a still-recording++ * VOD (clamped to flushed footage, like the predecessor).++ * GET /:id clip meta (status: processing | ready | failed)++ * GET / list (?limit&offset&vod_id&stream_id&user_id)++ * PUT /:id update title/visibility++ * DELETE /:id delete file + offloaded objects + row++ */++'use strict';++++const express = require('express');++const fs = require('fs');++const db = require('../db/database');++const tools = require('./media-tools');++const cutter = require('./clip-cutter');++const { tenantAuth, tenantCors } = require('../auth');++const { sendWebhook } = require('../webhooks');++++const router = express.Router({ mergeParams: true });++router.use(tenantCors);++++function clipPublic(clip) {++ if (!clip) return null;++ return {++ id: clip.id,++ app_id: clip.app_id,++ vod_id: clip.vod_id,++ stream_id: clip.stream_id,++ user_id: clip.user_id,++ title: clip.title,++ description: clip.description,++ status: clip.status || (clip.file_path ? 'ready' : 'processing'),++ start_time: clip.start_time,++ end_time: clip.end_time,++ duration: clip.duration_seconds || 0,++ playback_url: `/c/${clip.id}`,++ thumbnail_url: clip.thumbnail_url || null,++ visibility: clip.visibility || 'public',++ storage_provider: clip.storage_provider || 'local',++ auto_generated: !!clip.auto_generated,++ view_count: clip.view_count || 0,++ created_at: clip.created_at,++ };++}++++/** Sanitize user-provided title: strip HTML tags, limit length */++function sanitizeClipTitle(title, fallback = 'Untitled Clip') {++ if (!title || typeof title !== 'string') return fallback;++ return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;++}++++function _getClipScoped(req, res) {++ const id = parseInt(req.params.id, 10);++ if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }++ const clip = db.getClipById(id, req.appId);++ if (!clip) { res.status(404).json({ error: 'Clip not found' }); return null; }++ return clip;++}++++// ── Create clip ──────────────────────────────────────────────++router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {++ try {++ const body = req.body || {};++ const vodId = parseInt(body.vod_id, 10);++ if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });++ const vod = db.getVodById(vodId, req.appId);++ if (!vod) return res.status(404).json({ error: 'VOD not found' });++++ let startTime = Number.parseFloat(body.start_s ?? body.start_time);++ let endTime = Number.parseFloat(body.end_s ?? body.end_time);++ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) {++ return res.status(400).json({ error: 'start_s and end_s are required' });++ }++ if (startTime < 0 || endTime < 0) return res.status(400).json({ error: 'Time values cannot be negative' });++++ let duration = endTime - startTime;++ const maxClipDuration = db.getSetting('max_clip_duration') || 60;++ if (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });++ if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });++++ // Duplicate detection — reuse a just-made clip for the same window.++ const duplicate = db.findDuplicateClip({++ appId: req.appId, vodId, streamId: vod.stream_id || null,++ startTime, endTime,++ });++ if (duplicate && duplicate.status !== 'failed') {++ return res.status(200).json({ id: duplicate.id, status: duplicate.status, deduplicated: true });++ }++++ if (cutter.isBusy()) {++ return res.status(503).json({ error: 'Server is busy processing other clips. Please try again in a few seconds.' });++ }++++ // Resolve a source ffmpeg can read: local file, or presigned B2/R2 URL.++ const vodStorage = require('./vod-storage');++ const source = await vodStorage.resolveMediaSource(vod);++ if (!source) return res.status(404).json({ error: 'VOD media unavailable' });++++ // Live recording: clamp to what's ACTUALLY on disk, not wall-clock — a++ // seek past end-of-file emits a header-only "broken clip".++ if (vod.is_recording && source.kind === 'file') {++ let recordedDur = 0;++ try { const pi = await tools.probeVodInfo(source.value); recordedDur = pi.duration || 0; } catch { /* */ }++ if (recordedDur > 0) {++ const safeEdge = Math.max(0, recordedDur - 0.5);++ endTime = Math.min(endTime, safeEdge);++ startTime = Math.max(0, Math.min(startTime, endTime - 1));++ duration = endTime - startTime;++ if (duration < 1) {++ return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });++ }++ }++ } else if (vod.duration_seconds && Number.isFinite(vod.duration_seconds)) {++ if (startTime > vod.duration_seconds + 5) return res.status(400).json({ error: 'Start time exceeds video duration' });++ if (endTime > vod.duration_seconds + 10) endTime = vod.duration_seconds;++ duration = endTime - startTime;++ if (duration < 1) return res.status(400).json({ error: 'Clip window is outside the video' });++ }++++ const userId = body.user_id != null ? body.user_id : (req.userId ?? null);++ const result = db.createClip({++ app_id: req.appId,++ vod_id: vodId,++ stream_id: vod.stream_id || null,++ user_id: userId,++ title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),++ file_path: '',++ start_time: startTime,++ end_time: endTime,++ duration_seconds: duration,++ is_public: 1,++ status: 'processing',++ });++ const clipId = result.lastInsertRowid;++ const appId = req.appId;++++ // Cut in the background; webhook on completion.++ (async () => {++ const cut = await cutter.cutClipFile({ source: source.value, startTime, duration });++ if (cut.ok) {++ db.run('UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = ? WHERE id = ?',++ [cut.filePath, cut.duration, startTime + cut.duration, 'ready', clipId]);++ try {++ await require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath);++ } catch { /* */ }++ console.log(`[Clips] Clip ${clipId} cut from vod ${vodId} (${startTime.toFixed(1)}-${(startTime + cut.duration).toFixed(1)}s)`);++ sendWebhook(appId, 'clip.ready', clipPublic(db.getClipById(clipId))).catch(() => {});++ } else {++ console.warn(`[Clips] Clip ${clipId} failed: ${cut.error}`);++ db.run('UPDATE clips SET status = ? WHERE id = ?', ['failed', clipId]);++ sendWebhook(appId, 'clip.failed', clipPublic(db.getClipById(clipId))).catch(() => {});++ }++ })().catch(err => console.error('[Clips] Background cut error:', err.message));++++ res.status(202).json({ id: clipId, status: 'processing' });++ } catch (err) {++ console.error('[Clips] Create error:', err.message);++ res.status(500).json({ error: 'Failed to create clip' });++ }++});++++// ── List ─────────────────────────────────────────────────────++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ const filters = {++ limit, offset,++ vod_id: req.query.vod_id != null ? parseInt(req.query.vod_id, 10) : null,++ stream_id: req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null,++ user_id: req.query.user_id != null ? req.query.user_id : null,++ };++ const clips = db.listClips(req.appId, filters);++ const total = db.countClips(req.appId, filters);++ res.json({ clips: clips.map(clipPublic), total, limit, offset });++ } catch (err) {++ console.error('[Clips] List error:', err.message);++ res.status(500).json({ error: 'Failed to list clips' });++ }++});++++// ── Get clip ─────────────────────────────────────────────────++router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const clip = _getClipScoped(req, res);++ if (!clip) return;++ res.json({ clip: clipPublic(clip) });++ } catch (err) {++ res.status(500).json({ error: 'Failed to get clip' });++ }++});++++// ── Update (title / visibility) ──────────────────────────────++router.put('/:id', tenantAuth(), (req, res) => {++ try {++ const clip = _getClipScoped(req, res);++ if (!clip) return;++ const { title, visibility } = req.body || {};++ if (title !== undefined) {++ const t = sanitizeClipTitle(title, '');++ if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });++ db.run('UPDATE clips SET title = ? WHERE id = ?', [t, clip.id]);++ }++ if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);++ res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });++ } catch (err) {++ res.status(500).json({ error: 'Failed to update clip' });++ }++});++++// ── Delete ───────────────────────────────────────────────────++router.delete('/:id', tenantAuth(), (req, res) => {++ try {++ const clip = _getClipScoped(req, res);++ if (!clip) return;++++ // Delete the local file + any offloaded B2/R2 object (clips carry++ // storage_provider/storage_key like VODs).++ if (clip.file_path && fs.existsSync(clip.file_path)) {++ try { fs.unlinkSync(clip.file_path); } catch { /* ignore */ }++ }++ if (clip.storage_provider && clip.storage_provider !== 'local' && clip.storage_key) {++ require('./vod-storage').deleteVodObjects(clip).catch(err =>++ console.warn(`[Clips] Remote object cleanup failed for clip ${clip.id}:`, err.message));++ }++++ db.run('DELETE FROM clips WHERE id = ?', [clip.id]);++ res.json({ message: 'Clip deleted' });++ } catch (err) {++ res.status(500).json({ error: 'Failed to delete clip' });++ }++});++++module.exports = router;++module.exports.clipPublic = clipPublic;nnew file mode 100644@@@ -0,0 +1,282 @@++/**++ * OpenVibe.Media — VOD finalization pipeline++ *++ * Ported from the predecessor's finalizeVodRecording/_doFinalize, keyed by++ * vodId (there is no stream registry here — apps own stream state):++ * merge pending chunk segments → seekable remux → probe → master recovery++ * (truncated-webm rebuild) → DB update → thumbnail → webhook vod.ready.++ *++ * Guarded against double-invocation; refuses to finalize while the recorder++ * still holds the file open (stops it gracefully instead — its ffmpeg exit++ * handler re-invokes finalize on the fully-flushed file).++ */++'use strict';++++const path = require('path');++const fs = require('fs');++const { spawn } = require('child_process');++const db = require('../db/database');++const tools = require('./media-tools');++const { sendWebhook } = require('../webhooks');++++const _finalizing = new Set();++++function isFinalizing(vodId) {++ return _finalizing.has(Number(vodId));++}++++// Public shape of a vod row for API responses + webhooks.++function vodPublic(vod) {++ if (!vod) return null;++ return {++ id: vod.id,++ app_id: vod.app_id,++ stream_id: vod.stream_id,++ user_id: vod.user_id,++ title: vod.title,++ description: vod.description,++ status: db.vodStatus(vod),++ duration: vod.duration_seconds || 0,++ file_size: vod.file_size || 0,++ playback_url: `/v/${vod.id}`,++ thumbnail_url: vod.thumbnail_url || null,++ storage_provider: vod.storage_provider || 'local',++ visibility: vod.visibility || 'public',++ health_status: vod.health_status,++ clips_only: !!vod.clips_only,++ is_recording: !!vod.is_recording,++ view_count: vod.view_count || 0,++ created_at: vod.created_at,++ meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),++ };++}++++// Re-encode the lossless master into the served WebM format. Heavy (a real++// transcode), so it only runs on the recovery path when the primary webm came++// out truncated.++function rebuildWebmFromMaster(masterPath, webmPath) {++ return new Promise((resolve) => {++ const tmp = webmPath + '.recover.webm';++ const args = ['-y', '-i', masterPath,++ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',++ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];++ let ff;++ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);++ ff.on('close', (code) => {++ clearTimeout(to);++ try {++ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {++ fs.renameSync(tmp, webmPath);++ return resolve(true);++ }++ } catch { /* fall through */ }++ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }++ resolve(false);++ });++ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });++ });++}++++function _webhookForVod(vod, event) {++ try {++ sendWebhook(vod.app_id, event, vodPublic(vod)).catch(() => {});++ } catch { /* non-critical */ }++}++++/**++ * Finalize a VOD recording. Returns the updated vod row (or null).++ * @param {number} vodId++ * @param {object} [opts]++ * @param {number} [opts.startTimeMs] wall-clock recording start (for duration sanity)++ * @param {boolean} [opts.ffmpegCorrupted] recorder flagged heavy corruption++ * @param {string} [opts.segmentPath] trailing chunk segment not yet merged++ */++async function finalizeVod(vodId, opts = {}) {++ vodId = Number(vodId);++ if (_finalizing.has(vodId)) return null;++++ // If the recorder still holds this VOD open, stop it gracefully instead of++ // locking in a truncated duration — its exit handler re-finalizes.++ try {++ const recorder = require('./recorder');++ if (recorder.isRecording(vodId)) {++ console.log(`[VOD] finalize requested for vod ${vodId} while still recording — stopping gracefully first`);++ recorder.stopRecording(vodId);++ return null;++ }++ } catch { /* proceed */ }++ _finalizing.add(vodId);++++ try {++ return await _doFinalize(vodId, opts);++ } finally {++ _finalizing.delete(vodId);++ }++}++++async function _doFinalize(vodId, opts) {++ const vod = db.getVodById(vodId);++ if (!vod) return null;++ const filePath = vod.file_path;++ const startTime = opts.startTimeMs || new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime();++++ // Ephemeral clips-only recording: it existed only to serve live clips. Never++ // published — delete the file (+ any offloaded object) and its row.++ if (vod.clips_only) {++ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }++ try { tools.cleanupSeekableFile(filePath); } catch { /* */ }++ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }++ try { if (vod.storage_provider && vod.storage_provider !== 'local') require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }++ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }++ console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${vodId})`);++ return null;++ }++++ if (!filePath || !fs.existsSync(filePath)) {++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);++ const failed = db.getVodById(vodId);++ _webhookForVod(failed, 'vod.failed');++ return failed;++ }++++ // Merge any pending browser-chunk segments before remuxing.++ if (opts.segmentPath && opts.segmentPath !== filePath) {++ await tools.concatWebmFiles(filePath, opts.segmentPath);++ }++ await tools.mergePendingSegments(filePath);++++ // A recording that never received media (e.g. an RTP ingest that was++ // started and stopped without packets) leaves a zero-byte file. Quarantine++ // it as failed — the wall-clock fallback below must never mark it ready.++ if (tools.getFileSizeSafe(filePath) === 0) {++ console.warn(`[VOD] vod ${vodId}: zero-byte recording — quarantining`);++ db.run(`UPDATE vods SET is_recording = 0, health_status = 'zero_byte', health_issues_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,++ [JSON.stringify(['zero_byte']), vodId]);++ const failed = db.getVodById(vodId);++ _webhookForVod(failed, 'vod.failed');++ return failed;++ }++++ // Remux for proper seeking support (fast copy-mode, no re-encode)++ await tools.remuxForSeeking(filePath);++++ // Clean up the live seekable copy (no longer needed after final remux)++ tools.cleanupSeekableFile(filePath);++++ // Probe actual duration with ffprobe++ const wallClockSeconds = Math.max(0, Math.round((Date.now() - startTime) / 1000));++ let durationSeconds = wallClockSeconds;++ let probeFormatJson = JSON.stringify({});++ try {++ const probeInfo = await tools.probeVodInfo(filePath);++ if (probeInfo.duration > 0) {++ // Guard against a corrupt/inflated container duration: real footage can++ // never be meaningfully longer than the wall-clock recording time.++ if (wallClockSeconds > 0 && probeInfo.duration > wallClockSeconds * 1.5 + 30) {++ console.warn(`[VOD] vod ${vodId}: probed duration ${probeInfo.duration}s >> wall-clock ${wallClockSeconds}s — using wall-clock (inflated/corrupt container)`);++ durationSeconds = wallClockSeconds;++ } else {++ durationSeconds = probeInfo.duration;++ }++ }++ probeFormatJson = JSON.stringify(probeInfo.format || {});++ } catch (probeErr) {++ console.warn(`[VOD] ffprobe failed for vod ${vodId}:`, probeErr.message);++ }++++ // ── Recover from the lossless master if the served webm came out truncated ──++ // Only WebM recordings have a separate lossless master; an RTMP/H.264 MP4 is++ // ITSELF a lossless stream-copy, so the .webm regex must NOT fall through.++ const masterPath = filePath.endsWith('.webm')++ ? (vod.master_file_path || filePath.replace(/\.webm$/, '.master.mkv'))++ : null;++ let masterDur = 0;++ if (masterPath && fs.existsSync(masterPath)) {++ try { const mi = await tools.probeVodInfo(masterPath); masterDur = mi.duration || 0; } catch { /* */ }++ if (masterDur > 0 && masterDur > durationSeconds + 15 && masterDur > durationSeconds * 1.15) {++ console.warn(`[VOD] vod ${vodId}: webm ${durationSeconds}s is short vs master ${masterDur}s — rebuilding webm from master`);++ const ok = await rebuildWebmFromMaster(masterPath, filePath);++ if (ok) {++ try { await tools.remuxForSeeking(filePath); tools.cleanupSeekableFile(filePath); } catch { /* */ }++ try {++ const mi2 = await tools.probeVodInfo(filePath);++ if (mi2.duration > durationSeconds) { durationSeconds = mi2.duration; probeFormatJson = JSON.stringify(mi2.format || {}); }++ } catch { /* */ }++ console.log(`[VOD] vod ${vodId}: recovered from master → ${durationSeconds}s`);++ } else {++ console.warn(`[VOD] vod ${vodId}: master recovery failed — keeping master for manual recovery`);++ }++ }++ }++++ if (opts.ffmpegCorrupted) {++ console.warn(`[VOD] Finalized VOD ${vodId} marked corrupt by FFmpeg diagnostics; quarantining without deletion`);++ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,++ ['corrupt', JSON.stringify(['ffmpeg-corruption-detected']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);++ const failed = db.getVodById(vodId);++ _webhookForVod(failed, 'vod.failed');++ return failed;++ }++++ // Very short recordings are quarantined for review instead of deleted.++ const MIN_VOD_SECONDS = parseInt(process.env.MIN_VOD_SECONDS || '3', 10);++ if (durationSeconds < MIN_VOD_SECONDS) {++ console.log(`[VOD] Quarantining short vod ${vodId}: duration ${durationSeconds}s`);++ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,++ ['needs_review', JSON.stringify(['short_duration']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);++ const failed = db.getVodById(vodId);++ _webhookForVod(failed, 'vod.failed');++ return failed;++ }++++ let stat;++ try {++ stat = fs.statSync(filePath);++ } catch (err) {++ console.error(`[VOD] Failed to stat finalized VOD ${vodId}:`, err.message);++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);++ return null;++ }++ db.run('UPDATE vods SET is_recording = 0, duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, probe_format_json = ?, health_status = ? WHERE id = ?',++ [durationSeconds, stat.size, durationSeconds, probeFormatJson, 'ok', vodId]);++++ console.log(`[VOD] Finalized: vod ${vodId}, ${durationSeconds}s, ${(stat.size / 1024 / 1024).toFixed(1)}MB`);++++ // The lossless .master.mkv archive is only a fallback. Delete it ONLY once++ // the served webm is confirmed complete; if recovery failed, KEEP it.++ if (masterPath) {++ try {++ const webmComplete = !masterDur || durationSeconds >= masterDur - 8;++ if (fs.existsSync(masterPath)) {++ if (webmComplete) {++ fs.unlinkSync(masterPath);++ console.log(`[VOD] Removed master archive for vod ${vodId} (${path.basename(masterPath)})`);++ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);++ } else {++ console.warn(`[VOD] vod ${vodId}: KEEPING master (webm ${durationSeconds}s still < master ${masterDur}s)`);++ }++ } else {++ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);++ }++ } catch (e) {++ console.warn(`[VOD] Master cleanup failed for vod ${vodId}:`, e.message);++ }++ }++++ // Thumbnail, then webhook vod.ready (thumbnail awaited so the payload has it;++ // failure is non-fatal).++ try {++ const thumbService = require('../thumbnails/thumbnail-service');++ await thumbService.generateVodThumbnail(vodId, filePath);++ } catch (err) {++ console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);++ }++++ const finalVod = db.getVodById(vodId);++ _webhookForVod(finalVod, 'vod.ready');++ return finalVod;++}++++module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster };nnew file mode 100644@@@ -0,0 +1,242 @@++/**++ * OpenVibe.Media — health-job: keep the VOD library healthy in the background.++ *++ * Two responsibilities, both throttled and concurrency-safe:++ * 1. SCAN finished VODs that were never health-checked (or not checked in a++ * while) for corruption / zero-byte / bad-duration. On a problem it first++ * tries to REPAIR from the lossless .master.mkv archive (and repairs a++ * missing duration); only genuinely unrecoverable files are quarantined++ * (hidden, is_public=0) — never silently deleted.++ * 2. CLEAN UP VODs quarantined as unrecoverably-broken for a grace period:++ * one last recovery attempt, else delete the dead files + row.++ *++ * Design: a single serialized worker, at most one ffmpeg pass at a time, small++ * batches. Backs off to lighter (probe-only) work while any INGEST recording++ * is active so it never competes with live ffmpeg (the predecessor keyed this++ * off live streams; here active recordings are the equivalent signal).++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const db = require('../db/database');++const config = require('../config');++const scanner = require('./health-scanner');++++const TICK_MS = 5 * 60 * 1000; // scan pass every 5 minutes++const SCAN_STALE_DAYS = 45; // re-scan a healthy VOD at most this often++const SCAN_BATCH_IDLE = 3; // VODs per pass when nothing is recording++const SCAN_BATCH_LIVE = 1; // ...and while recording (probe-only, cheap)++const QUARANTINE_GRACE_DAYS = 21; // clean up unrecoverable quarantined VODs after this++const CLEANUP_BATCH = 3;++++let _running = false;++let _timer = null;++let _busy = false;++++function _anyRecording() {++ try { return require('./recorder').activeCount() > 0; }++ catch { return false; }++}++++// Scan (and, if needed, repair) a single VOD. The routine scan is PROBE-based++// (fast, non-destructive); the expensive full decode + re-encode only runs to++// REPAIR a file that already looks broken, and only while idle (`deep`).++async function _scanOne(vod, { deep }) {++ let scan = await scanner.scanVod(vod, { decode: false, repairDuration: true, quarantineBad: false });++++ const broken = ['corrupt', 'zero_byte', 'missing_file', 'needs_review'].includes(scan.status)++ || (scan.issues || []).some(i => /decode_failed|probe_failed|invalid_duration/.test(i));++++ if (!broken) {++ // Healthy (or duration just repaired) — record the clean scan.++ try { db.updateVodHealth(vod.id, { status: 'ok', issues: scan.issues || [] }); } catch { /* */ }++ // The lossless .master.mkv is only a finalize-time recovery fallback. This++ // VOD probed healthy and is long finished — reclaim a lingering master.++ try {++ // Only WebM recordings have a separate .master.mkv. Guard the extension:++ // for an MP4 the regex wouldn't match and `master` would resolve to the++ // VOD file itself — deleting the recording.++ const master = vod.master_file_path++ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);++ if (master && fs.existsSync(master)) {++ const freedMb = (fs.statSync(master).size / 1024 / 1024).toFixed(0);++ fs.unlinkSync(master);++ if (vod.master_file_path) { try { db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vod.id]); } catch { /* */ } }++ console.log(`[VOD-Health] Reclaimed orphaned master for vod ${vod.id} (${freedMb}MB)`);++ }++ } catch { /* */ }++ return { id: vod.id, status: 'ok' };++ }++++ // Broken but recordings are active → defer the heavy recovery/quarantine to++ // the next idle pass. Don't stamp last_health_scan_at, so it stays first in++ // the queue and is handled promptly once idle.++ if (!deep) return { id: vod.id, status: 'deferred' };++++ // Broken → try to rebuild from the lossless master before quarantining.++ let recovered = false;++ if (scan.status !== 'missing_file') {++ try {++ const r = await scanner.recoverFromMaster(vod);++ if (r.recovered) {++ // Re-scan the rebuilt file to confirm it's actually good now.++ const fresh = db.getVodById(vod.id) || vod;++ const rescan = await scanner.scanVod(fresh, { decode: deep, repairDuration: true, quarantineBad: false });++ recovered = !['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)++ && !(rescan.issues || []).some(i => /decode_failed|probe_failed/.test(i));++ if (recovered) {++ console.log(`[VOD-Health] Recovered vod ${vod.id} from master (${Math.round(r.duration)}s)`);++ return { id: vod.id, status: 'recovered' };++ }++ }++ } catch (e) { console.warn(`[VOD-Health] master recovery error for vod ${vod.id}:`, e.message); }++ }++++ // Still broken → quarantine (hide, keep the file for possible manual recovery).++ // 'needs_review' (e.g. very short) is flagged but NOT hidden — it may be watchable.++ const terminal = ['corrupt', 'zero_byte', 'missing_file'].includes(scan.status);++ try {++ db.updateVodHealth(vod.id, {++ status: scan.status,++ issues: scan.issues || [],++ probeDuration: scan.probe ? scan.probe.duration : undefined,++ probeFormat: scan.probe ? scan.probe.format : undefined,++ quarantine: terminal, // only hide genuinely-broken files++ keepPublic: !terminal,++ });++ } catch { /* */ }++ if (terminal) console.warn(`[VOD-Health] Quarantined vod ${vod.id} — ${scan.status} (${(scan.issues || []).slice(0, 3).join(', ')})`);++ return { id: vod.id, status: scan.status };++}++++async function _scanPass(deep) {++ const limit = deep ? SCAN_BATCH_IDLE : SCAN_BATCH_LIVE;++ let vods = [];++ try { vods = db.getVodsNeedingHealthScan({ staleDays: SCAN_STALE_DAYS, limit }); } catch { return; }++ for (const vod of vods) {++ try { await _scanOne(vod, { deep }); }++ catch (e) { console.warn(`[VOD-Health] scan error for vod ${vod.id}:`, e.message); }++ }++}++++// Delete a VOD's files everywhere + its DB row. Mirrors the manual delete route.++function _hardDeleteVod(vod) {++ try {++ if (vod.file_path) {++ try { require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }++ // Local file + sidecars (.seekable.webm / .seekable.mp4, .master.mkv).++ for (const p of [vod.file_path, vod.file_path.replace(/\.webm$/, '.seekable.webm'), vod.file_path.replace(/\.mp4$/, '.seekable.mp4'), vod.file_path.replace(/\.webm$/, '.master.mkv'), vod.master_file_path].filter(Boolean)) {++ try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* */ }++ }++ }++ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);++ return true;++ } catch (e) {++ console.warn(`[VOD-Health] cleanup delete failed for vod ${vod.id}:`, e.message);++ return false;++ }++}++++async function _cleanupPass(deep) {++ let vods = [];++ try { vods = db.getQuarantinedVodsForCleanup({ graceDays: QUARANTINE_GRACE_DAYS, limit: CLEANUP_BATCH }); } catch { return; }++ for (const vod of vods) {++ // One last recovery attempt before deleting — the master may have survived++ // even if the served file didn't. If it recovers, un-quarantine and keep it.++ if (deep && vod.file_path && vod.health_status !== 'missing_file') {++ try {++ const r = await scanner.recoverFromMaster(vod);++ if (r.recovered) {++ const fresh = db.getVodById(vod.id) || vod;++ const rescan = await scanner.scanVod(fresh, { decode: true, repairDuration: true, quarantineBad: false });++ if (!['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)) {++ db.run("UPDATE vods SET quarantined_at = NULL, health_status = 'ok' WHERE id = ?", [vod.id]);++ console.log(`[VOD-Health] Cleanup recovered vod ${vod.id} from master — un-quarantined`);++ continue;++ }++ }++ } catch { /* fall through to delete */ }++ }++ if (_hardDeleteVod(vod)) {++ console.log(`[VOD-Health] Cleaned up unrecoverable vod ${vod.id} (quarantined ${vod.health_status}, ${vod.quarantined_at})`);++ }++ }++}++++// Filesystem sweep for orphaned .master.mkv files: reclaim masters whose VOD is++// gone or finished-and-healthy. PROTECTS masters that are still needed — any++// recording, and any broken/quarantined VOD whose master is a recovery source —++// plus a 30-min mtime grace so a just-finished finalize is never raced.++function _masterSweep() {++ let dir, files;++ try {++ dir = path.resolve(config.vod.path);++ files = fs.readdirSync(dir).filter(f => f.endsWith('.master.mkv'));++ } catch { return; }++ if (!files.length) return;++++ const protectedNames = new Set();++ try {++ const rows = db.all(`SELECT file_path, master_file_path FROM vods++ WHERE COALESCE(is_recording,0)=1++ OR quarantined_at IS NOT NULL++ OR health_status IN ('corrupt','zero_byte','needs_review')`);++ for (const v of rows) {++ if (v.master_file_path) protectedNames.add(path.basename(v.master_file_path));++ if (v.file_path) protectedNames.add(path.basename(v.file_path.replace(/\.webm$/, '.master.mkv')));++ }++ } catch { /* on error, protect nothing extra — the mtime guard still applies */ }++ // Belt-and-suspenders: never touch a master an active recording holds right now.++ try {++ const rec = require('./recorder');++ for (const [, r] of (rec.activeRecordings || new Map())) if (r && r.masterFilePath) protectedNames.add(path.basename(r.masterFilePath));++ } catch { /* */ }++++ const now = Date.now();++ let n = 0, freed = 0;++ for (const f of files) {++ if (protectedNames.has(f)) continue;++ const fp = path.join(dir, f);++ try {++ const st = fs.statSync(fp);++ if (now - st.mtimeMs < 30 * 60 * 1000) continue; // too fresh — finalize may still need it++ fs.unlinkSync(fp);++ freed += st.size; n++;++ if (n >= 25) break; // cap per sweep++ } catch { /* */ }++ }++ if (n) console.log(`[VOD-Health] Master sweep: removed ${n} orphaned master(s), freed ${(freed / 1024 / 1024).toFixed(0)}MB`);++}++++async function _tick() {++ if (_busy) return;++ _busy = true;++ try {++ const deep = !_anyRecording(); // full decode + recovery only while idle++ await _scanPass(deep);++ if (deep) { await _cleanupPass(deep); _masterSweep(); }++ } catch (e) {++ console.warn('[VOD-Health] tick error:', e.message);++ } finally {++ _busy = false;++ }++}++++function start() {++ if (_running) return;++ _running = true;++ // First pass shortly after boot (idle window), then on the interval.++ const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);++ if (first.unref) first.unref();++ _timer = setInterval(() => { _tick().catch(() => {}); }, TICK_MS);++ if (_timer.unref) _timer.unref();++ console.log('[VOD] Health job started (scan + master-recovery + quarantine cleanup)');++}++++function stop() {++ _running = false;++ if (_timer) { clearInterval(_timer); _timer = null; }++}++++module.exports = { start, stop, _tick };nnew file mode 100644@@@ -0,0 +1,250 @@++/**++ * OpenVibe.Media — VOD health scanner++ *++ * Ported from the predecessor: probe / decode / remux / master-recovery++ * primitives used by the background health job.++ */++'use strict';++++const { spawn } = require('child_process');++const fs = require('fs');++const db = require('../db/database');++++function probeMediaInfo(filePath) {++ return new Promise((resolve) => {++ const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);++ let out = '';++ proc.stdout.on('data', d => out += d.toString());++ proc.on('close', () => {++ try {++ const info = JSON.parse(out);++ const duration = parseFloat(info.format?.duration || '0');++ resolve({++ ok: true,++ duration: Number.isFinite(duration) ? duration : 0,++ format: info.format || {},++ streams: info.streams || [],++ });++ } catch {++ resolve({ ok: false, duration: 0, format: null, streams: [] });++ }++ });++ proc.on('error', () => resolve({ ok: false, duration: 0, format: null, streams: [] }));++ setTimeout(() => { try { proc.kill(); } catch {} }, 10000);++ });++}++++function decodeVodFile(filePath) {++ return new Promise((resolve) => {++ const proc = spawn('ffmpeg', ['-v', 'warning', '-xerror', '-i', filePath, '-map', '0:v:0', '-f', 'null', '-']);++ let stderr = '';++ proc.stderr.on('data', d => stderr += d.toString());++ proc.on('close', (code) => {++ const warnings = stderr.split('\n').filter(line => /error|warn|corrupt|invalid|timestamp|RTP|concealing|Non-monotonous DTS/i.test(line));++ resolve({ code, stderr, warnings, ok: code === 0 });++ });++ proc.on('error', () => resolve({ code: -1, stderr: '', warnings: [], ok: false }));++ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);++ });++}++++function remuxForSeeking(filePath) {++ return new Promise((resolve) => {++ // Match the tmp container to the source so an H.264 .mp4 VOD isn't++ // force-written into a VP8-only WebM (which just fails). For .mp4,++ // faststart makes it instantly seekable.++ const isMp4 = filePath.toLowerCase().endsWith('.mp4');++ const tmpPath = isMp4 ? `${filePath}.remux.tmp.mp4` : `${filePath}.remux.tmp.webm`;++ const args = isMp4++ ? ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', '-movflags', '+faststart', tmpPath]++ : ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', tmpPath];++ const proc = spawn('ffmpeg', args);++ proc.on('close', (code) => {++ if (code === 0 && fs.existsSync(tmpPath)) {++ try {++ fs.renameSync(tmpPath, filePath);++ resolve({ ok: true });++ } catch (err) {++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);++ resolve({ ok: false, error: err.message });++ }++ } else {++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);++ resolve({ ok: false, error: `ffmpeg remux failed code ${code}` });++ }++ });++ proc.on('error', (err) => {++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);++ resolve({ ok: false, error: err.message });++ });++ setTimeout(() => { try { proc.kill(); } catch {} }, 30000);++ });++}++++// Re-encode a served .webm from the lossless .master.mkv archive. Used by the++// health job to REPAIR a broken VOD before resorting to quarantine.++function _rebuildWebmFromMaster(masterPath, webmPath) {++ return new Promise((resolve) => {++ const tmp = webmPath + '.recover.webm';++ const args = ['-y', '-i', masterPath,++ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',++ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];++ let ff;++ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);++ ff.on('close', (code) => {++ clearTimeout(to);++ try {++ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {++ fs.renameSync(tmp, webmPath);++ return resolve(true);++ }++ } catch { /* fall through */ }++ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }++ resolve(false);++ });++ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });++ });++}++++// Attempt to recover a broken VOD from its master archive. Rebuilds the webm,++// remuxes it for seeking, re-probes, and (if longer) repairs the stored++// duration. Returns { recovered: bool, duration }.++async function recoverFromMaster(vod) {++ // Only WebM recordings have a separate .master.mkv to rebuild from. An MP4++ // has no master (it's itself a lossless copy); guard the extension so the++ // regex can't fall through to the VOD's own path and overwrite the mp4.++ const masterPath = vod.master_file_path++ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);++ if (!masterPath || !fs.existsSync(masterPath) || !vod.file_path) return { recovered: false, duration: 0 };++ let masterDur = 0;++ try { const mi = await probeMediaInfo(masterPath); masterDur = mi.ok ? mi.duration : 0; } catch { /* */ }++ if (!(masterDur > 1)) return { recovered: false, duration: 0 };++ const ok = await _rebuildWebmFromMaster(masterPath, vod.file_path);++ if (!ok) return { recovered: false, duration: 0 };++ try { await remuxForSeeking(vod.file_path); } catch { /* */ }++ let dur = masterDur;++ try { const mi2 = await probeMediaInfo(vod.file_path); if (mi2.ok && mi2.duration > 0) dur = mi2.duration; } catch { /* */ }++ try {++ const stat = fs.statSync(vod.file_path);++ db.repairVodDuration(vod.id, Math.round(dur), stat.size);++ db.updateVodHealth(vod.id, { status: 'ok', issues: ['recovered_from_master'], probeDuration: dur });++ } catch { /* */ }++ return { recovered: true, duration: dur };++}++++async function scanVod(vod, options = {}) {++ const filePath = vod.file_path;++ const result = {++ vodId: vod.id,++ filePath,++ status: 'unknown',++ issues: [],++ probe: null,++ decode: null,++ remux: null,++ durationMatch: false,++ };++++ if (!filePath || !fs.existsSync(filePath)) {++ // Offloaded to object storage (B2/R2) — the upload was size-verified,++ // so skip local file checks instead of flagging it missing.++ try {++ if (require('./vod-storage').isRemote(vod)) {++ result.status = 'offloaded';++ return result;++ }++ } catch { /* fall through to missing_file */ }++ result.status = 'missing_file';++ result.issues.push('missing_file');++ return result;++ }++++ let stat;++ try {++ stat = fs.statSync(filePath);++ if (stat.size <= 0) {++ result.status = 'zero_byte';++ result.issues.push('zero_byte');++ return result;++ }++ } catch (err) {++ result.status = 'missing_file';++ result.issues.push('stat_error');++ return result;++ }++++ const probeInfo = await probeMediaInfo(filePath);++ result.probe = probeInfo;++ if (!probeInfo.ok) {++ result.status = 'needs_review';++ result.issues.push('probe_failed');++ } else {++ if (!probeInfo.duration || probeInfo.duration <= 0) {++ result.status = 'needs_review';++ result.issues.push('invalid_duration');++ }++ if (vod.duration_seconds <= 0 && probeInfo.duration > 0) {++ result.durationMatch = false;++ result.issues.push('duration_repair_available');++ } else if (Math.abs((vod.duration_seconds || 0) - probeInfo.duration) > 2) {++ result.durationMatch = false;++ result.issues.push('duration_mismatch');++ } else {++ result.durationMatch = true;++ }++ }++++ if (options.decode && probeInfo.ok && probeInfo.duration > 0) {++ const decodeResult = await decodeVodFile(filePath);++ result.decode = decodeResult;++ if (!decodeResult.ok) {++ result.issues.push('decode_failed');++ }++ if (decodeResult.warnings.length > 0) {++ result.issues.push(...decodeResult.warnings.map(w => w.trim()).filter(Boolean));++ }++ }++++ if (options.remux && probeInfo.ok) {++ result.remux = await remuxForSeeking(filePath);++ if (!result.remux.ok) {++ result.issues.push('remux_failed');++ }++ }++++ if (result.issues.length === 0) {++ result.status = 'ok';++ } else if (result.status === 'unknown') {++ result.status = result.issues.includes('decode_failed') || result.issues.some(i => /invalid|corrupt|failed/.test(i))++ ? 'corrupt'++ : 'needs_review';++ }++++ if (options.repairDuration && probeInfo.ok && probeInfo.duration > 0 && (vod.duration_seconds || 0) <= 0) {++ result.repair = db.repairVodDuration(vod.id, Math.round(probeInfo.duration), stat.size);++ result.issues.push('duration_repaired');++ result.status = 'duration_repaired';++ }++++ if (options.quarantineBad && ['corrupt', 'missing_file', 'zero_byte'].includes(result.status)) {++ db.updateVodHealth(vod.id, {++ status: result.status,++ issues: result.issues,++ probeDuration: probeInfo.duration,++ probeFormat: probeInfo.format,++ quarantine: true,++ });++ }++++ return result;++}++++module.exports = {++ probeMediaInfo,++ decodeVodFile,++ remuxForSeeking,++ recoverFromMaster,++ scanVod,++};nnew file mode 100644@@@ -0,0 +1,315 @@++/**++ * OpenVibe.Media — shared ffmpeg/ffprobe helpers++ *++ * Extracted from the predecessor's VOD routes: seekable remux (WebM cues /++ * MP4 faststart), DVR sidecar remux for live recordings, probes, and the++ * browser-chunk segment concat used by the chunked upload flow.++ */++'use strict';++++const path = require('path');++const fs = require('fs');++const { spawn } = require('child_process');++++/**++ * Remux a WebM/MP4 file with ffmpeg to add proper seek metadata.++ * WebM files from MediaRecorder lack Cues and often have Inf duration,++ * which prevents browser-side seeking. A fast copy-remux fixes this.++ * MP4: rewrite the fragmented recording into a plain, faststart (moov-at-front)++ * MP4 so finished-VOD playback seeks instantly. Replaces the original in-place.++ */++function remuxForSeeking(filePath) {++ return new Promise((resolve) => {++ const ext = path.extname(filePath).toLowerCase();++ if (ext !== '.webm' && ext !== '.mp4') return resolve(false); // WebM + MP4 only++++ const tmpPath = filePath + '.remux' + ext;++ const outArgs = ext === '.mp4'++ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']++ : ['-c', 'copy', '-fflags', '+genpts'];++ const proc = spawn('ffmpeg', [++ '-y', '-i', filePath,++ ...outArgs,++ tmpPath,++ ], { stdio: ['ignore', 'ignore', 'pipe'] });++++ let stderr = '';++ proc.stderr.on('data', d => stderr += d);++++ proc.on('close', (code) => {++ if (code === 0 && fs.existsSync(tmpPath)) {++ try {++ fs.renameSync(tmpPath, filePath);++ console.log(`[VOD] Remuxed for seeking: ${path.basename(filePath)}`);++ resolve(true);++ } catch (err) {++ console.warn(`[VOD] Remux rename failed:`, err.message);++ try { fs.unlinkSync(tmpPath); } catch {}++ resolve(false);++ }++ } else {++ console.warn(`[VOD] Remux failed (code ${code}): ${stderr.slice(-200)}`);++ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}++ resolve(false);++ }++ });++++ proc.on('error', () => resolve(false));++++ // Timeout: 60s should be plenty for copy-mode remux++ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);++ });++}++++/** Probe a media file's start_time (seconds), or 0 if probing fails. */++function probeStartTime(filePath) {++ return new Promise((resolve) => {++ const probe = spawn('ffprobe', [++ '-v', 'quiet', '-print_format', 'json',++ '-show_entries', 'format=start_time',++ filePath,++ ]);++ let out = '';++ probe.stdout.on('data', d => out += d);++ probe.on('close', () => {++ try {++ const info = JSON.parse(out);++ const startTime = parseFloat(info.format?.start_time || '0');++ resolve(Number.isFinite(startTime) && startTime > 0 ? startTime : 0);++ } catch { resolve(0); }++ });++ probe.on('error', () => resolve(0));++ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 5000);++ });++}++++function probeVodDuration(filePath) {++ return new Promise((resolve) => {++ const probe = spawn('ffprobe', [++ '-v', 'quiet', '-print_format', 'json',++ '-show_format', filePath,++ ]);++ let out = '';++ probe.stdout.on('data', d => out += d);++ probe.on('close', () => {++ try {++ const info = JSON.parse(out);++ const duration = Math.round(parseFloat(info.format?.duration || '0'));++ resolve(duration > 0 ? duration : 0);++ } catch { resolve(0); }++ });++ probe.on('error', () => resolve(0));++ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 10000);++ });++}++++function probeVodInfo(filePath) {++ return new Promise((resolve) => {++ const probe = spawn('ffprobe', [++ '-v', 'quiet', '-print_format', 'json',++ '-show_format', '-show_streams',++ filePath,++ ]);++ let out = '';++ probe.stdout.on('data', d => out += d);++ probe.on('close', () => {++ try {++ const info = JSON.parse(out);++ const duration = Math.round(parseFloat(info.format?.duration || '0'));++ resolve({++ duration: duration > 0 ? duration : 0,++ format: info.format || null,++ streams: info.streams || [],++ });++ } catch { resolve({ duration: 0, format: null, streams: [] }); }++ });++ probe.on('error', () => resolve({ duration: 0, format: null, streams: [] }));++ setTimeout(() => { try { probe.kill(); } catch {} resolve({ duration: 0, format: null, streams: [] }); }, 10000);++ });++}++++function getFileSizeSafe(filePath) {++ try { return filePath && fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; } catch { return 0; }++}++++function cleanupTempFile(filePath) {++ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* ignore */ }++}++++// ── Live DVR sidecar ─────────────────────────────────────────++++const _liveRemuxInProgress = new Set();++++function seekableSidecarPath(filePath) {++ if (!filePath) return null;++ if (filePath.endsWith('.webm')) return filePath.replace(/\.webm$/, '.seekable.webm');++ if (filePath.endsWith('.mp4')) return filePath.replace(/\.mp4$/, '.seekable.mp4');++ return null;++}++++/**++ * Produce a fully-indexed seekable snapshot of the growing recording so DVR++ * viewers can seek anywhere without the whole file. WebM: copy-remux to write++ * cues. MP4: copy-remux with +faststart so a complete moov sits at the front.++ * Writes to <file>.seekable.<ext> WITHOUT touching the growing original.++ */++function remuxForLiveSeeking(filePath) {++ const seekablePath = seekableSidecarPath(filePath);++ if (!seekablePath) return Promise.resolve(false);++ if (_liveRemuxInProgress.has(filePath)) return Promise.resolve(false);++ _liveRemuxInProgress.add(filePath);++++ const isMp4 = filePath.endsWith('.mp4');++ const tmpPath = filePath + (isMp4 ? '.live-remux.tmp.mp4' : '.live-remux.tmp.webm');++ const outArgs = isMp4++ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']++ : ['-c', 'copy', '-fflags', '+genpts'];++++ return new Promise((resolve) => {++ const proc = spawn('ffmpeg', [++ '-y', '-i', filePath,++ ...outArgs,++ tmpPath,++ ], { stdio: ['ignore', 'ignore', 'pipe'] });++++ let stderr = '';++ proc.stderr.on('data', d => stderr += d);++++ proc.on('close', (code) => {++ _liveRemuxInProgress.delete(filePath);++ if (code === 0 && fs.existsSync(tmpPath)) {++ try {++ fs.renameSync(tmpPath, seekablePath);++ resolve(true);++ } catch (err) {++ console.warn(`[VOD] Live remux rename failed:`, err.message);++ try { fs.unlinkSync(tmpPath); } catch {}++ resolve(false);++ }++ } else {++ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}++ resolve(false);++ }++ });++++ proc.on('error', () => {++ _liveRemuxInProgress.delete(filePath);++ resolve(false);++ });++++ setTimeout(() => {++ _liveRemuxInProgress.delete(filePath);++ try { proc.kill(); } catch {}++ }, 30000);++ });++}++++/**++ * Clean up the .seekable.* sidecar after finalization. Guard: the sidecar path++ * must differ from the VOD itself (a bad regex fall-through would otherwise++ * delete the recording); seekableSidecarPath returns null for unknown exts.++ */++function cleanupSeekableFile(filePath) {++ const seekablePath = seekableSidecarPath(filePath);++ if (!seekablePath || seekablePath === filePath) return;++ try { if (fs.existsSync(seekablePath)) fs.unlinkSync(seekablePath); } catch {}++}++++// ── Browser-chunk segments (chunked upload flow) ─────────────++++function makeSegmentPath(filePath, segmentId) {++ const base = filePath.replace(/\.webm$/, '');++ return `${base}.seg-${segmentId}-${Date.now()}.webm`;++}++++function getPendingSegmentFiles(filePath) {++ const dir = path.dirname(filePath);++ const base = path.basename(filePath).replace(/\.webm$/, '');++ try {++ return fs.readdirSync(dir)++ .filter((name) => name.startsWith(`${base}.seg-`) && name.endsWith('.webm'))++ .map((name) => path.join(dir, name))++ .sort((a, b) => {++ const am = /\.seg-(\d+)-(\d+)\.webm$/.exec(a);++ const bm = /\.seg-(\d+)-(\d+)\.webm$/.exec(b);++ const aSeg = parseInt(am?.[1] || '0', 10);++ const bSeg = parseInt(bm?.[1] || '0', 10);++ if (aSeg !== bSeg) return aSeg - bSeg;++ const aTs = parseInt(am?.[2] || '0', 10);++ const bTs = parseInt(bm?.[2] || '0', 10);++ return aTs - bTs;++ });++ } catch {++ return [];++ }++}++++function concatWebmFiles(basePath, appendPath) {++ if (!appendPath || !fs.existsSync(appendPath)) return Promise.resolve(true);++ if (!basePath || !fs.existsSync(basePath)) {++ fs.renameSync(appendPath, basePath);++ return Promise.resolve(true);++ }++++ const listPath = `${basePath}.concat.${Date.now()}.txt`;++ const tmpPath = `${basePath}.concat.tmp.webm`;++ const escapedBase = basePath.replace(/'/g, `'\\''`);++ const escapedAppend = appendPath.replace(/'/g, `'\\''`);++ fs.writeFileSync(listPath, `file '${escapedBase}'\nfile '${escapedAppend}'\n`, 'utf8');++++ const runConcat = (args) => new Promise((resolve) => {++ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });++ let stderr = '';++ proc.stderr.on('data', (d) => stderr += d);++ proc.on('close', (code) => resolve({ code, stderr }));++ proc.on('error', () => resolve({ code: -1, stderr: 'spawn error' }));++ setTimeout(() => { try { proc.kill(); } catch {} }, 120000);++ });++++ return (async () => {++ try {++ let result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-fflags', '+genpts', tmpPath]);++ if (result.code !== 0 || !fs.existsSync(tmpPath)) {++ result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c:v', 'libvpx-vp9', '-crf', '32', '-b:v', '0', '-c:a', 'libopus', '-b:a', '128k', tmpPath]);++ }++ if (result.code === 0 && fs.existsSync(tmpPath)) {++ fs.renameSync(tmpPath, basePath);++ cleanupTempFile(appendPath);++ cleanupTempFile(listPath);++ return true;++ }++ cleanupTempFile(tmpPath);++ cleanupTempFile(listPath);++ return false;++ } catch {++ cleanupTempFile(tmpPath);++ cleanupTempFile(listPath);++ return false;++ }++ })();++}++++async function mergePendingSegments(filePath) {++ for (const segmentPath of getPendingSegmentFiles(filePath)) {++ const ok = await concatWebmFiles(filePath, segmentPath);++ if (!ok) {++ console.warn(`[VOD] Failed to merge segment into ${path.basename(filePath)}: ${path.basename(segmentPath)}`);++ }++ }++}++++module.exports = {++ remuxForSeeking,++ remuxForLiveSeeking,++ seekableSidecarPath,++ cleanupSeekableFile,++ probeStartTime,++ probeVodDuration,++ probeVodInfo,++ getFileSizeSafe,++ cleanupTempFile,++ makeSegmentPath,++ getPendingSegmentFiles,++ concatWebmFiles,++ mergePendingSegments,++};nnew file mode 100644@@@ -0,0 +1,569 @@++/**++ * OpenVibe.Media — Stream Recorder (RTMP pull + RTP ingest)++ *++ * Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps++ * own their stream state and point ingest at a VOD they created):++ *++ * RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL, lossless++ * stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero++ * CPU, live-seekable, kill-tolerant, and IS its own lossless master).++ * RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair++ * from the pool (RTP_PORT_MIN..MAX on 127.0.0.1), writes an SDP and++ * starts ffmpeg listening. Codec passthrough (no decode → no re-encode):++ * H.264 → fragmented .mp4 (video copy, audio→AAC); VP8/VP9 → .webm++ * (video + Opus copied). Anything exotic falls back to a libvpx++ * re-encode (with an H.264 lossless master when applicable).++ *++ * Do NOT hardcode .webm — the served extension follows the copied container.++ *++ * A periodic remux writes a .seekable sidecar every 60s so DVR viewers can++ * seek into the growing recording (see media-tools.remuxForLiveSeeking).++ */++'use strict';++++const { spawn } = require('child_process');++const path = require('path');++const fs = require('fs');++const os = require('os');++const db = require('../db/database');++const config = require('../config');++const tools = require('./media-tools');++++// Grace period for ffmpeg to flush the trailer/cues after SIGINT before we++// force-kill. Generous on purpose — a long recording's trailer flush must not++// be cut short (that truncates the VOD). Cleared the instant ffmpeg exits.++const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;++++// Disk guardian thresholds (free bytes on the VOD volume).++const DISK_WARN_BYTES = (parseFloat(process.env.VOD_DISK_WARN_GB) || 15) * 1024 * 1024 * 1024;++const DISK_CRIT_BYTES = (parseFloat(process.env.VOD_DISK_CRIT_GB) || 5) * 1024 * 1024 * 1024;++++function _isControlledFfmpegError(line, expectedShutdown) {++ if (!line || !expectedShutdown) return false;++ const normalized = line.toLowerCase();++ return /demux.*timeout|timeout|broken pipe|connection.*reset|closed|end of file|sigterm|sigint|error while reading/i.test(normalized);++}++++function _isFfmpegCorruptionLine(line) {++ if (!line) return false;++ const normalized = line.toLowerCase();++ return /error while decoding|concealing|non[- ]monotonically increasing dts|missing picture in access unit|invalid .* header|invalid .* nal unit|could not find codec parameters|moov atom|invalid packet/i.test(normalized);++}++++function _trackFfmpegDiagnostics(line, recording) {++ if (!recording || !_isFfmpegCorruptionLine(line)) return;++ recording.ffmpegCorruptionWarnings = (recording.ffmpegCorruptionWarnings || 0) + 1;++ if (recording.ffmpegCorruptionWarnings >= 5) {++ recording._ffmpegCorrupted = true;++ }++}++++// ── RTP port pool (UDP 12000-12199 on 127.0.0.1 per CONTRACTS) ──++++const _usedPorts = new Set();++++/** Allocate an even base port with base+1 free (RTP + RTCP). */++function allocatePortPair() {++ let start = config.rtp.portMin;++ if (start % 2 !== 0) start += 1;++ for (let p = start; p + 1 <= config.rtp.portMax; p += 2) {++ if (!_usedPorts.has(p) && !_usedPorts.has(p + 1)) {++ _usedPorts.add(p);++ _usedPorts.add(p + 1);++ return p;++ }++ }++ return null;++}++++function releasePortPair(basePort) {++ if (!basePort) return;++ _usedPorts.delete(basePort);++ _usedPorts.delete(basePort + 1);++}++++// ── SDP for ffmpeg receiving RTP (PlainRtpTransport senders) ──++++function _formatFmtpParameters(params) {++ return Object.entries(params || {}).map(([k, v]) => `${k}=${v}`).join(';');++}++++/**++ * Build an SDP string for FFmpeg to receive RTP.++ * video/audio descriptors: { payloadType, codec, clockRate, channels?, parameters?, ssrc?, rtcpFeedback? }++ */++function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtcpPort) {++ const lines = [++ 'v=0',++ 'o=- 0 0 IN IP4 127.0.0.1',++ 's=OpenVibe.Media VOD Recording',++ 'c=IN IP4 127.0.0.1',++ 't=0 0',++ ];++++ const vPT = video.payloadType;++ const vCodecName = String(video.codec || 'VP8').split('/').pop();++ const vFeedback = Array.isArray(video.rtcpFeedback) ? video.rtcpFeedback : [];++ const videoProtocol = vFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';++ lines.push(`m=video ${videoPort} ${videoProtocol} ${vPT}`);++ lines.push(`a=rtpmap:${vPT} ${vCodecName}/${video.clockRate || 90000}`);++ if (videoRtcpPort) lines.push(`a=rtcp:${videoRtcpPort} IN IP4 127.0.0.1`);++ if (video.ssrc) lines.push(`a=ssrc:${video.ssrc} cname:record-video`);++ if (video.parameters) {++ const fmtp = _formatFmtpParameters(video.parameters);++ if (fmtp) lines.push(`a=fmtp:${vPT} ${fmtp}`);++ }++ for (const fb of vFeedback) {++ if (!fb || !fb.type) continue;++ lines.push(`a=rtcp-fb:${vPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);++ }++ lines.push('a=recvonly');++++ if (audio && audioPort) {++ const aPT = audio.payloadType;++ const aCodecName = String(audio.codec || 'opus').split('/').pop();++ const channels = audio.channels || 2;++ const aFeedback = Array.isArray(audio.rtcpFeedback) ? audio.rtcpFeedback : [];++ const audioProtocol = aFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';++ lines.push(`m=audio ${audioPort} ${audioProtocol} ${aPT}`);++ lines.push(`a=rtpmap:${aPT} ${aCodecName}/${audio.clockRate || 48000}/${channels}`);++ if (audioRtcpPort) lines.push(`a=rtcp:${audioRtcpPort} IN IP4 127.0.0.1`);++ if (audio.ssrc) lines.push(`a=ssrc:${audio.ssrc} cname:record-audio`);++ if (audio.parameters) {++ const fmtp = _formatFmtpParameters(audio.parameters);++ if (fmtp) lines.push(`a=fmtp:${aPT} ${fmtp}`);++ }++ for (const fb of aFeedback) {++ if (!fb || !fb.type) continue;++ lines.push(`a=rtcp-fb:${aPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);++ }++ lines.push('a=recvonly');++ }++ lines.push('');++ return lines.join('\r\n');++}++++class StreamRecorder {++ constructor() {++ /** @type {Map<number, object>} vodId → recording state */++ this.activeRecordings = new Map();++ this._diskState = 'ok'; // 'ok' | 'warning' | 'critical'++++ const vodDir = path.resolve(config.vod.path);++ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });++ }++++ isRecording(vodId) {++ return this.activeRecordings.has(Number(vodId));++ }++++ activeCount() {++ return this.activeRecordings.size;++ }++++ _cleanupFailedVod(vodId, filePath) {++ if (!filePath || !fs.existsSync(filePath)) {++ try {++ db.run('DELETE FROM vods WHERE id = ?', [vodId]);++ console.log(`[VOD] Deleted stale failed VOD ${vodId}`);++ } catch (err) {++ console.warn(`[VOD] Failed to delete stale VOD ${vodId}:`, err.message);++ }++ return;++ }++ try {++ db.run(++ 'UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = datetime(\'now\'), is_public = 0 WHERE id = ?',++ ['corrupt', JSON.stringify(['failed_recording_start']), vodId]++ );++ console.log(`[VOD] Marked failed VOD ${vodId} as corrupt`);++ } catch (err) {++ console.warn(`[VOD] Failed to mark VOD ${vodId} as corrupt:`, err.message);++ }++ }++++ _guardCanRecord(vod) {++ if (!vod) return { ok: false, error: 'VOD not found' };++ if (this.activeRecordings.has(vod.id)) return { ok: false, error: 'Already recording this VOD' };++ // Disk guardian: when the VOD volume is critically low, refuse to start++ // rather than risk filling the disk (which corrupts every active recording).++ if (this._diskState === 'critical') {++ return { ok: false, error: 'Disk critically low — recording refused' };++ }++ return { ok: true };++ }++++ _registerCommon(vod, proc, filePath, masterPath, extra = {}) {++ const recording = {++ vodId: vod.id,++ appId: vod.app_id,++ process: proc,++ filePath,++ masterFilePath: masterPath || null,++ startTime: Date.now(),++ remuxTimer: null,++ _expectedShutdown: false,++ ffmpegCorruptionWarnings: 0,++ _ffmpegCorrupted: false,++ ...extra,++ };++++ // Periodic live-seeking remux: .seekable sidecar every 60s so DVR viewers++ // can seek into the growing recording; first pass at 30s for early DVR.++ recording.remuxTimer = setInterval(() => this._periodicRemux(vod.id), 60000);++ setTimeout(() => {++ if (this.activeRecordings.has(vod.id)) this._periodicRemux(vod.id);++ }, 30000);++++ this.activeRecordings.set(vod.id, recording);++ db.run('UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?', [filePath, vod.id]);++ if (masterPath) db.run('UPDATE vods SET master_file_path = ? WHERE id = ?', [masterPath, vod.id]);++ return recording;++ }++++ _wireProcess(recording, label) {++ const { process: proc, vodId } = recording;++++ proc.stderr?.on('data', (data) => {++ const line = data.toString();++ const rec = this.activeRecordings.get(vodId);++ _trackFfmpegDiagnostics(line, rec);++ if (line.includes('Error') || line.includes('error')) {++ if (_isControlledFfmpegError(line, rec?._expectedShutdown)) return;++ console.error(`[VOD] FFmpeg error (${label}, vod ${vodId}):`, line.trim());++ }++ });++++ proc.on('exit', (code, signal) => {++ console.log(`[VOD] FFmpeg exited for vod ${vodId} (${label}, code: ${code}, signal: ${signal})`);++ const rec = this.activeRecordings.get(vodId);++ const finalizeOpts = {++ startTimeMs: rec?.startTime,++ ffmpegCorrupted: rec?._ffmpegCorrupted || false,++ };++ this._teardown(vodId);++ // Let finalizeVod handle remux, probe, thumbnail, webhook.++ // Short delay to ensure the file is fully flushed to disk.++ setTimeout(() => {++ require('./finalize').finalizeVod(vodId, finalizeOpts).catch(err => {++ console.error(`[VOD] Finalization failed for vod ${vodId}:`, err.message);++ });++ }, 2000);++ });++++ proc.on('error', (err) => {++ console.error(`[VOD] FFmpeg spawn error (${label}, vod ${vodId}):`, err.message);++ const rec = this.activeRecordings.get(vodId);++ const filePath = rec?.filePath;++ this._teardown(vodId);++ require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);++ });++ if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);++ });++ }++++ _teardown(vodId) {++ const rec = this.activeRecordings.get(vodId);++ if (!rec) return;++ if (rec._killTimer) { clearTimeout(rec._killTimer); rec._killTimer = null; }++ if (rec.remuxTimer) { clearInterval(rec.remuxTimer); rec.remuxTimer = null; }++ if (rec.sdpPath) { try { fs.unlinkSync(rec.sdpPath); } catch { /* */ } }++ if (rec.rtpVideoPort) releasePortPair(rec.rtpVideoPort);++ if (rec.rtpAudioPort) releasePortPair(rec.rtpAudioPort);++ this.activeRecordings.delete(vodId);++ }++++ /**++ * Run periodic live-seeking remux and update DB duration/file size.++ */++ _periodicRemux(vodId) {++ const rec = this.activeRecordings.get(vodId);++ if (!rec || !rec.filePath || !fs.existsSync(rec.filePath)) return;++++ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);++ try {++ const stat = fs.statSync(rec.filePath);++ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?',++ [elapsed, stat.size, rec.vodId]);++ } catch {}++++ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});++ }++++ // ── RTMP ingest ──────────────────────────────────────────++++ /**++ * Pull an RTMP URL into a fragmented MP4 via lossless stream-copy.++ * Exactly the predecessor's RTMP path — no re-encode, always real-time,++ * live-seekable, kill-tolerant, its own lossless master.++ */++ startRtmp(vod, rtmpUrl) {++ const guard = this._guardCanRecord(vod);++ if (!guard.ok) return guard;++ if (!/^rtmps?:\/\//i.test(String(rtmpUrl || ''))) {++ return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };++ }++++ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}.mp4`;++ const filePath = path.resolve(config.vod.path, filename);++++ const ffmpegArgs = [++ '-y',++ '-rw_timeout', '15000000',++ '-i', rtmpUrl,++ // ── Lossless stream-copy → fragmented MP4 ──++ // No re-encode (H.264/AAC pass straight through from the RTMP feed), so++ // this ALWAYS keeps real-time regardless of source resolution/bitrate.++ // Fragmented (frag_keyframe/empty_moov/default_base_moof) so the growing++ // file is seekable live for DVR and survives an abrupt kill without a++ // moov-atom rewrite.++ '-c', 'copy',++ '-fflags', '+genpts',++ '-max_muxing_queue_size', '1024',++ '-movflags', '+frag_keyframe+empty_moov+default_base_moof',++ '-f', 'mp4',++ filePath,++ ];++++ let proc;++ try {++ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });++ } catch (err) {++ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };++ }++++ const recording = this._registerCommon(vod, proc, filePath, null, { protocol: 'rtmp' });++ this._wireProcess(recording, 'rtmp');++++ console.log(`[VOD] Recording started: vod ${vod.id} → ${filename} (rtmp pull)`);++ return { ok: true, filePath };++ }++++ // ── RTP ingest ───────────────────────────────────────────++++ /**++ * Allocate ports, write SDP, start ffmpeg listening for RTP.++ * @param {object} vod vods row++ * @param {object} video { payloadType, codec, clockRate, ssrc?, parameters?, rtcpFeedback? }++ * @param {object} [audio] same shape (codec e.g. 'opus')++ * @returns {ok, videoPort, audioPort} — caller points PlainRtpTransports at 127.0.0.1++ */++ startRtp(vod, video, audio) {++ const guard = this._guardCanRecord(vod);++ if (!guard.ok) return guard;++ if (!video || video.payloadType == null || !video.codec) {++ return { ok: false, error: 'video { payloadType, codec, clockRate } required' };++ }++++ const videoPort = allocatePortPair();++ if (videoPort == null) return { ok: false, error: 'RTP port pool exhausted' };++ const videoRtcpPort = videoPort + 1;++ let audioPort = null;++ let audioRtcpPort = null;++ if (audio && audio.payloadType != null) {++ audioPort = allocatePortPair();++ if (audioPort == null) {++ releasePortPair(videoPort);++ return { ok: false, error: 'RTP port pool exhausted' };++ }++ audioRtcpPort = audioPort + 1;++ }++++ const sdpContent = buildRtpSdp(video, audioPort ? audio : null, videoPort, videoRtcpPort, audioPort, audioRtcpPort);++ const sdpPath = path.join(os.tmpdir(), `openvibe-media-vod-${vod.id}-${Date.now()}.sdp`);++ try {++ fs.writeFileSync(sdpPath, sdpContent, 'utf8');++ } catch (err) {++ releasePortPair(videoPort);++ releasePortPair(audioPort);++ return { ok: false, error: `SDP write failed: ${err.message}` };++ }++++ const ffmpegArgs = [++ '-y',++ '-use_wallclock_as_timestamps', '1',++ '-protocol_whitelist', 'file,rtp,udp',++ '-thread_queue_size', '2048',++ '-analyzeduration', '10000000',++ '-probesize', '5000000',++ '-avoid_negative_ts', 'make_zero',++ // Don't honor any rotation display-matrix the source may (mis)send++ // mid-stream — that flipped VODs 90° partway through.++ '-noautorotate',++ '-i', sdpPath,++ '-fflags', '+genpts+discardcorrupt+nobuffer+igndts',++ '-err_detect', 'ignore_err',++ ];++++ // ── Codec-passthrough recording (no decode → no re-encode) ──────────++ // Copying the bitstream straight through (exactly like the proven RTMP++ // path) removes the decode entirely: zero re-encode CPU, no rotation/++ // shear/melt (there is no decode to desync), no separate .master.mkv++ // (the copied file IS lossless), kill-tolerant fragmented output.++ // Mapping: H.264 → fragmented .mp4 (video copy, audio → AAC for universal++ // browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +++ // Opus copied, both native to WebM). Anything exotic falls back to the++ // legacy libvpx re-encode (+ lossless master when H.264/Opus).++ const vCodec = String(video.codec || '').toLowerCase();++ const isH264 = vCodec.includes('h264');++ const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');++ const passthrough = isH264 || isVpx;++++ const ext = isH264 ? '.mp4' : '.webm';++ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;++ const filePath = path.resolve(config.vod.path, filename);++ let masterPath = null;++++ const normalizeVod = process.env.VOD_NO_NORMALIZE !== '1';++ const VOD_SCALE_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1';++++ if (passthrough) {++ ffmpegArgs.push('-map', '0:v:0', '-c:v', 'copy');++ if (audioPort) {++ ffmpegArgs.push('-map', '0:a?');++ if (isH264) {++ ffmpegArgs.push('-c:a', 'aac', '-b:a', '160k'); // Opus → AAC (cheap, audio-only)++ } else {++ ffmpegArgs.push('-c:a', 'copy'); // Opus is native to WebM++ }++ } else {++ ffmpegArgs.push('-an');++ }++ if (isH264) {++ // Fragmented MP4: seekable while still growing (live DVR) and it++ // survives an abrupt kill without a moov rewrite — same rationale++ // as the RTMP path, which is why neither needs a lossless master.++ ffmpegArgs.push('-max_muxing_queue_size', '1024',++ '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', filePath);++ } else {++ ffmpegArgs.push('-f', 'webm', filePath);++ }++ } else {++ // ── Legacy fallback: decode + re-encode to VP8/WebM, with a lossless++ // stream-copy .master.mkv alongside (recovery fallback) unless the++ // disk is under pressure. H.264/VP8/VP9 never reach this branch —++ // they passthrough above — so this only handles exotic codecs.++ masterPath = this._diskState === 'ok' ? filePath.replace(/\.webm$/, '.master.mkv') : null;++ const libvpxVideo = [++ ...(normalizeVod ? ['-vf', VOD_SCALE_VF] : []),++ '-c:v', 'libvpx',++ '-b:v', '2000k',++ '-crf', '18',++ '-deadline', 'realtime',++ '-cpu-used', '4',++ // Keyframe every 2s → seekable WebM++ '-force_key_frames', 'expr:gte(t,n_forced*2)',++ '-g', '240',++ ];++ if (masterPath) {++ ffmpegArgs.push('-map', '0', '-c:v', 'copy', '-c:a', 'copy', '-f', 'matroska', masterPath, '-map', '0', ...libvpxVideo);++ } else {++ ffmpegArgs.push(...libvpxVideo);++ }++ if (audioPort) {++ ffmpegArgs.push('-c:a', 'libopus', '-b:a', '128k', '-application', 'audio');++ } else {++ ffmpegArgs.push('-an');++ }++ ffmpegArgs.push('-f', 'webm', filePath);++ }++++ let proc;++ try {++ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });++ } catch (err) {++ try { fs.unlinkSync(sdpPath); } catch { /* */ }++ releasePortPair(videoPort);++ releasePortPair(audioPort);++ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };++ }++++ const recording = this._registerCommon(vod, proc, filePath, masterPath, {++ protocol: 'rtp',++ sdpPath,++ rtpVideoPort: videoPort,++ rtpAudioPort: audioPort,++ });++ this._wireProcess(recording, 'rtp');++++ const recMode = passthrough ? `passthrough ${isH264 ? 'H.264→mp4' : 'VP8/9→webm'} copy` : 'libvpx re-encode';++ console.log(`[VOD] RTP recording started: vod ${vod.id} → ${filename} [${recMode}] (video:${videoPort}${audioPort ? ` audio:${audioPort}` : ''})`);++ return { ok: true, videoPort, audioPort, filePath };++ }++++ /**++ * Gracefully stop recording a VOD.++ * FFmpeg SIGINT triggers trailer write → exit handler → finalizeVod.++ */++ stopRecording(vodId) {++ vodId = Number(vodId);++ const recording = this.activeRecordings.get(vodId);++ if (!recording) return false;++++ console.log(`[VOD] Stopping recording for vod ${vodId}`);++ recording._expectedShutdown = true;++++ if (recording.remuxTimer) {++ clearInterval(recording.remuxTimer);++ recording.remuxTimer = null;++ }++++ if (!recording.process) {++ this._teardown(vodId);++ this._cleanupFailedVod(vodId, recording.filePath);++ return true;++ }++++ try {++ // SIGINT lets FFmpeg write cues/trailer for seekability++ recording.process.kill('SIGINT');++ } catch {++ try { recording.process.kill('SIGTERM'); } catch { /* ignore */ }++ }++++ // Safety net: force-kill only if FFmpeg is REALLY stuck. Force-killing too++ // early truncates the file and produces short/unseekable VODs.++ recording._killTimer = setTimeout(() => {++ try {++ if (recording.process && !recording.process.killed) {++ console.warn(`[VOD] FFmpeg didn't exit within grace for vod ${vodId} — force-killing (VOD may be truncated)`);++ recording.process.kill('SIGKILL');++ }++ } catch { /* ignore */ }++ }, STOP_GRACE_MS);++ return true;++ }++++ /**++ * Disk guardian: sample free space on the VOD volume. Under 'critical' no++ * new recordings start (existing ones keep running). Called on an interval.++ */++ checkDisk() {++ let free = 0;++ try { free = require('./vod-storage').diskUsage(config.vod.path).available || 0; } catch { return; }++ if (!free) return;++ const prev = this._diskState;++ const state = free < DISK_CRIT_BYTES ? 'critical' : free < DISK_WARN_BYTES ? 'warning' : 'ok';++ this._diskState = state;++ if (state !== prev) {++ const gb = (free / 1024 / 1024 / 1024).toFixed(1);++ if (state === 'ok') console.log(`[VOD] Disk recovered — ${gb}GB free (recordings back to normal)`);++ else console.warn(`[VOD] Disk ${state.toUpperCase()} — only ${gb}GB free on the VOD volume`);++ }++ if (state === 'ok') return;++++ // Under pressure: nudge the storage sweep to offload cold VODs.++ try { const vs = require('./vod-storage'); if (typeof vs.runSweep === 'function') vs.runSweep().catch(() => {}); } catch { /* */ }++ }++++ /** Stop all active recordings (for graceful shutdown). */++ stopAll() {++ for (const [vodId] of this.activeRecordings) {++ this.stopRecording(vodId);++ }++ }++}++++module.exports = new StreamRecorder();++module.exports.buildRtpSdp = buildRtpSdp;nnew file mode 100644@@@ -0,0 +1,336 @@++/**++ * OpenVibe.Media — VOD API routes (mounted at /api/v1/:app/vods)++ *++ * POST / create VOD → { id }++ * POST /:id/ingest/rtmp { rtmp_url } → 202 (ffmpeg pulls it)++ * POST /:id/ingest/rtp/start { video, audio } → { videoPort, audioPort }++ * POST /:id/ingest/rtp/stop finalize RTP recording → 200++ * POST /:id/chunks multipart chunk append (user JWT ok)++ * POST /:id/complete finalize chunked upload++ * POST /:id/finalize close recording, kick off thumbnail+probe++ * GET /:id VOD meta++ * GET / list (?limit&offset&user_id&stream_id)++ * PUT /:id update title/description/visibility++ * DELETE /:id delete everywhere (local + B2 + R2)++ */++'use strict';++++const express = require('express');++const path = require('path');++const fs = require('fs');++const multer = require('multer');++const db = require('../db/database');++const config = require('../config');++const recorder = require('./recorder');++const tools = require('./media-tools');++const { finalizeVod, vodPublic } = require('./finalize');++const { tenantAuth, tenantCors } = require('../auth');++++const router = express.Router({ mergeParams: true });++router.use(tenantCors);++++// ── Chunk upload storage (browser MediaRecorder chunks) ──────++const VOD_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };++++/** Strip codec params from MIME types like "video/webm;codecs=vp9,opus" → "video/webm" */++function baseMediaType(mime) {++ return (mime || '').split(';')[0].trim().toLowerCase();++}++++const chunkStorage = multer.diskStorage({++ destination: (req, file, cb) => {++ const vodDir = path.resolve(config.vod.path);++ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });++ cb(null, vodDir);++ },++ filename: (req, file, cb) => {++ const ext = VOD_MIME_TO_EXT[baseMediaType(file.mimetype)] || '.webm';++ cb(null, `chunk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);++ },++});++const chunkUpload = multer({++ storage: chunkStorage,++ limits: { fileSize: config.vod.maxSizeMb * 1024 * 1024 },++ fileFilter: (req, file, cb) => {++ const base = baseMediaType(file.mimetype);++ if (VOD_MIME_TO_EXT[base] || base === 'application/octet-stream' || base === '' || base.startsWith('video/')) cb(null, true);++ else cb(new Error('Only video chunks are allowed'));++ },++});++++/**++ * Active chunked uploads: vodId → { filePath, startTime, chunkCount,++ * currentSegmentId, currentSegmentPath }. Rebuilt from the DB row if the++ * server restarted mid-upload.++ */++const activeChunkUploads = new Map();++++function _getVodScoped(req, res) {++ const id = parseInt(req.params.id, 10);++ if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }++ const vod = db.getVodById(id, req.appId);++ if (!vod) { res.status(404).json({ error: 'VOD not found' }); return null; }++ return vod;++}++++// ── Create VOD ───────────────────────────────────────────────++router.post('/', tenantAuth(), (req, res) => {++ try {++ const { title, stream_id, stream_key, user_id, meta, visibility, clips_only } = req.body || {};++ const result = db.createVod({++ app_id: req.appId,++ stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,++ stream_key: stream_key || null,++ user_id: user_id != null ? user_id : null,++ title: (title || 'Recording').toString().slice(0, 300),++ meta,++ });++ const id = result.lastInsertRowid;++ if (clips_only) db.run('UPDATE vods SET clips_only = 1 WHERE id = ?', [id]);++ if (visibility) db.setVodVisibility(id, visibility);++ res.status(201).json({ id });++ } catch (err) {++ console.error('[VOD] Create error:', err.message);++ res.status(500).json({ error: 'Failed to create VOD' });++ }++});++++// ── RTMP ingest ──────────────────────────────────────────────++router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++ const result = recorder.startRtmp(vod, req.body?.rtmp_url);++ if (!result.ok) return res.status(409).json({ error: result.error });++ res.status(202).json({ id: vod.id, status: 'recording' });++ } catch (err) {++ console.error('[VOD] RTMP ingest error:', err.message);++ res.status(500).json({ error: 'Failed to start RTMP ingest' });++ }++});++++// ── RTP ingest ───────────────────────────────────────────────++router.post('/:id/ingest/rtp/start', tenantAuth(), (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++ const { video, audio } = req.body || {};++ const result = recorder.startRtp(vod, video, audio);++ if (!result.ok) return res.status(409).json({ error: result.error });++ res.json({ videoPort: result.videoPort, audioPort: result.audioPort || null });++ } catch (err) {++ console.error('[VOD] RTP start error:', err.message);++ res.status(500).json({ error: 'Failed to start RTP ingest' });++ }++});++++router.post('/:id/ingest/rtp/stop', tenantAuth(), (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++ const stopped = recorder.stopRecording(vod.id);++ if (!stopped && !vod.is_recording) return res.status(409).json({ error: 'VOD is not recording' });++ // Finalization runs from the ffmpeg exit handler.++ res.json({ id: vod.id, status: 'finalizing' });++ } catch (err) {++ console.error('[VOD] RTP stop error:', err.message);++ res.status(500).json({ error: 'Failed to stop RTP ingest' });++ }++});++++// ── Chunked upload (browser MediaRecorder; user JWT ok) ──────++router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('chunk'), async (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }++ if (!req.file) return res.status(400).json({ error: 'No chunk data' });++ const segmentId = Math.max(1, parseInt(req.body?.segmentId || req.query.segmentId || '1', 10) || 1);++++ let rec = activeChunkUploads.get(vod.id);++ if (!rec && vod.file_path && fs.existsSync(vod.file_path)) {++ // Server restarted mid-upload — resume against the existing file.++ rec = {++ filePath: vod.file_path,++ startTime: new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime(),++ chunkCount: 0,++ currentSegmentId: segmentId,++ currentSegmentPath: vod.file_path,++ };++ activeChunkUploads.set(vod.id, rec);++ }++++ if (!rec) {++ // First chunk — establish the VOD file (contains the WebM header).++ const ext = VOD_MIME_TO_EXT[baseMediaType(req.file.mimetype)] || '.webm';++ const filename = `vod-${req.appId}-${vod.id}-${Date.now()}${ext}`;++ const filePath = path.resolve(config.vod.path, filename);++ fs.copyFileSync(req.file.path, filePath);++ tools.cleanupTempFile(req.file.path);++++ db.run('UPDATE vods SET file_path = ?, file_size = ?, is_recording = 1 WHERE id = ?',++ [filePath, fs.statSync(filePath).size, vod.id]);++++ rec = { filePath, startTime: Date.now(), chunkCount: 1, currentSegmentId: segmentId, currentSegmentPath: filePath };++ activeChunkUploads.set(vod.id, rec);++++ console.log(`[VOD] Chunked recording started: vod ${vod.id} (${req.appId})`);++ return res.json({ vodId: vod.id, chunkIndex: 0, status: 'created' });++ }++++ if (rec.currentSegmentId !== segmentId) {++ // New MediaRecorder segment (fresh WebM header) — buffer separately,++ // concat into the base file (ffmpeg concat demuxer) between segments.++ if (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath) {++ await tools.concatWebmFiles(rec.filePath, rec.currentSegmentPath);++ }++ rec.currentSegmentId = segmentId;++ rec.currentSegmentPath = tools.makeSegmentPath(rec.filePath, segmentId);++ fs.copyFileSync(req.file.path, rec.currentSegmentPath);++ tools.cleanupTempFile(req.file.path);++ rec.chunkCount++;++ } else {++ // Append chunk to the current file/segment++ const targetPath = rec.currentSegmentPath || rec.filePath;++ const chunkData = fs.readFileSync(req.file.path);++ fs.appendFileSync(targetPath, chunkData);++ tools.cleanupTempFile(req.file.path);++ rec.chunkCount++;++ }++++ // Update file size and duration estimate in DB++ const size = tools.getFileSizeSafe(rec.filePath)++ + (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath ? tools.getFileSizeSafe(rec.currentSegmentPath) : 0);++ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);++ db.run('UPDATE vods SET file_size = ?, duration_seconds = ? WHERE id = ?', [size, elapsed, vod.id]);++++ // Seekable sidecar for live DVR (every 2 chunks ≈ ~60s)++ if (rec.chunkCount >= 2 && rec.chunkCount % 2 === 0) {++ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});++ }++++ res.json({ vodId: vod.id, chunkIndex: rec.chunkCount, status: 'appended' });++ } catch (err) {++ console.error('[VOD] Chunk upload error:', err.message);++ if (req.file) tools.cleanupTempFile(req.file.path);++ res.status(500).json({ error: 'Failed to save chunk' });++ }++});++++async function _finalizeHandler(req, res) {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++++ // Live ffmpeg recording → stop gracefully; its exit handler finalizes.++ if (recorder.isRecording(vod.id)) {++ recorder.stopRecording(vod.id);++ return res.status(202).json({ id: vod.id, status: 'finalizing' });++ }++++ const chunkRec = activeChunkUploads.get(vod.id);++ activeChunkUploads.delete(vod.id);++ const result = await finalizeVod(vod.id, {++ startTimeMs: chunkRec?.startTime,++ segmentPath: chunkRec && chunkRec.currentSegmentPath !== chunkRec.filePath ? chunkRec.currentSegmentPath : null,++ });++ if (!result) return res.status(409).json({ error: 'Nothing to finalize (finalization already in progress or VOD discarded)' });++ res.json({ vod: vodPublic(result) });++ } catch (err) {++ console.error('[VOD] Finalize error:', err.message);++ res.status(500).json({ error: 'Failed to finalize VOD' });++ }++}++++router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);++router.post('/:id/finalize', tenantAuth(), _finalizeHandler);++++// ── List ─────────────────────────────────────────────────────++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {++ try {++ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ const user_id = req.query.user_id != null ? req.query.user_id : null;++ const stream_id = req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null;++ const vods = db.listVods(req.appId, { limit, offset, user_id, stream_id });++ const total = db.countVods(req.appId, { user_id, stream_id });++ res.json({ vods: vods.map(vodPublic), total, limit, offset });++ } catch (err) {++ console.error('[VOD] List error:', err.message);++ res.status(500).json({ error: 'Failed to list VODs' });++ }++});++++// ── Get VOD meta ─────────────────────────────────────────────++router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++++ // Self-heal a missing duration on a finished local VOD.++ if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {++ const duration = await tools.probeVodDuration(vod.file_path);++ if (duration > 0) {++ const fileSize = tools.getFileSizeSafe(vod.file_path);++ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?', [duration, fileSize, vod.id]);++ vod.duration_seconds = duration;++ vod.file_size = fileSize;++ }++ }++++ res.json({ vod: vodPublic(vod) });++ } catch (err) {++ console.error('[VOD] Get error:', err.message);++ res.status(500).json({ error: 'Failed to get VOD' });++ }++});++++// ── Update ───────────────────────────────────────────────────++router.put('/:id', tenantAuth(), (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++ const { title, description, visibility } = req.body || {};++ const updates = [];++ const params = [];++ if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }++ if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }++ if (updates.length) {++ params.push(vod.id);++ db.run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);++ }++ if (visibility !== undefined) db.setVodVisibility(vod.id, visibility);++ res.json({ vod: vodPublic(db.getVodById(vod.id, req.appId)) });++ } catch (err) {++ console.error('[VOD] Update error:', err.message);++ res.status(500).json({ error: 'Failed to update VOD' });++ }++});++++// ── Delete ───────────────────────────────────────────────────++router.delete('/:id', tenantAuth(), (req, res) => {++ try {++ const vod = _getVodScoped(req, res);++ if (!vod) return;++++ if (recorder.isRecording(vod.id)) recorder.stopRecording(vod.id);++ activeChunkUploads.delete(vod.id);++++ // Delete media everywhere (local + B2 + R2)++ if (vod.file_path) {++ require('./vod-storage').deleteVodObjects(vod).catch((err) => {++ console.warn(`[VOD] Object cleanup failed for VOD ${vod.id}:`, err.message);++ });++ tools.cleanupSeekableFile(vod.file_path);++ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }++ }++++ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);++ res.json({ message: 'VOD deleted' });++ } catch (err) {++ console.error('[VOD] Delete error:', err.message);++ res.status(500).json({ error: 'Failed to delete VOD' });++ }++});++++router.activeChunkUploads = activeChunkUploads;++module.exports = router;nnew file mode 100644@@@ -0,0 +1,851 @@++/**++ * OpenVibe.Media — VOD Storage Engine (local / Backblaze B2 / Cloudflare R2)++ *++ * Ported from the predecessor's object-storage tier manager:++ *++ * local — ./data/vods recordings + recent VODs (fast, limited)++ * b2 — Backblaze B2 bucket canonical cold tier: unpopular VODs are++ * uploaded here and deleted locally++ * r2 — Cloudflare R2 bucket popularity cache: high-traffic VODs get a++ * copy here (free egress) and play from R2++ *++ * Invariants:++ * - vods.storage_provider ∈ ('local','b2','r2') = where playback comes from++ * - every offloaded VOD has a canonical B2 object at vods/<basename>++ * - provider 'r2' implies the B2 canonical object also exists; demoting++ * from R2 just deletes the R2 copy and flips back to 'b2'++ *++ * Playback for b2/r2 is a 302 redirect to a presigned GET URL (range++ * requests are handled by the object store; R2 egress is free, B2 gives++ * 3x stored volume per day free).++ *++ * The periodic sweep:++ * 1. offloads unpopular local VODs to B2 (age + views + last access);++ * under disk pressure it drains oldest/least-watched VODs down to a++ * low-water mark regardless of the popularity thresholds++ * 2. promotes popular VODs to R2++ * 3. demotes stale R2 VODs back to B2-only++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const { execSync } = require('child_process');++const config = require('../config');++const db = require('../db/database');++++let S3; // @aws-sdk/client-s3 (lazy)++let LibStorage; // @aws-sdk/lib-storage (lazy)++let Presigner; // @aws-sdk/s3-request-presigner (lazy)++function loadSdk() {++ if (!S3) {++ S3 = require('@aws-sdk/client-s3');++ LibStorage = require('@aws-sdk/lib-storage');++ Presigner = require('@aws-sdk/s3-request-presigner');++ }++}++++// ── Provider configuration (env) ─────────────────────────────++const PROVIDER_ENV = {++ b2: {++ endpoint: process.env.MEDIA_B2_ENDPOINT || '',++ region: process.env.MEDIA_B2_REGION || 'us-west-004',++ bucket: process.env.MEDIA_B2_BUCKET || '',++ accessKeyId: process.env.MEDIA_B2_KEY_ID || '',++ secretAccessKey: process.env.MEDIA_B2_APP_KEY || '',++ forcePathStyle: true,++ },++ r2: {++ endpoint: process.env.MEDIA_R2_ENDPOINT || '',++ region: process.env.MEDIA_R2_REGION || 'auto',++ bucket: process.env.MEDIA_R2_BUCKET || '',++ accessKeyId: process.env.MEDIA_R2_ACCESS_KEY_ID || '',++ secretAccessKey: process.env.MEDIA_R2_SECRET_ACCESS_KEY || '',++ forcePathStyle: false,++ },++};++++const REMOTE_PROVIDERS = ['b2', 'r2'];++const KEY_PREFIX = 'vods/';++++// ── Settings (media_settings, JSON values under storage_tier.*) ──++const DEFAULTS = {++ enabled: true,++ // B2 cold-offload eligibility (a VOD must meet ALL of these)++ minAgeDays: 7,++ maxViewsForCold: 5,++ minLastAccessDays: 3,++ sweepIntervalMs: 15 * 60 * 1000,++ // Disk pressure: above this % the sweep drains aggressively…++ hotDiskPressurePct: 70,++ // …until local disk is back under this %++ localLowWaterPct: 60,++ maxPerSweep: 40,++ // R2 popularity tier++ r2Enabled: true,++ r2MinViews: 20,++ r2RecentAccessDays: 3,++ r2MaxIdleDays: 14,++ r2MaxPerSweep: 5,++};++++let sweepTimer = null;++let sweepRunning = false;++const clients = {}; // providerName → S3Client++const providerHealthy = {}; // providerName → bool++++function getSetting(key) {++ try {++ const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);++ if (row) return JSON.parse(row.value);++ } catch { /* use default */ }++ return DEFAULTS[key];++}++++function getSettings() {++ const s = {};++ for (const key of Object.keys(DEFAULTS)) s[key] = getSetting(key);++ return s;++}++++function setSetting(key, value) {++ if (!(key in DEFAULTS)) return;++ const dbKey = `storage_tier.${key}`;++ const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);++ if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);++ else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);++}++++// ── Provider clients ─────────────────────────────────────────++++function providerConfigured(name) {++ const p = PROVIDER_ENV[name];++ return !!(p && p.endpoint && p.bucket && p.accessKeyId && p.secretAccessKey);++}++++function clientFor(name) {++ if (!providerConfigured(name)) return null;++ if (!clients[name]) {++ loadSdk();++ const p = PROVIDER_ENV[name];++ clients[name] = new S3.S3Client({++ region: p.region,++ endpoint: p.endpoint,++ forcePathStyle: p.forcePathStyle,++ credentials: { accessKeyId: p.accessKeyId, secretAccessKey: p.secretAccessKey },++ });++ }++ return clients[name];++}++++function keyForVod(vod) {++ if (vod.storage_key) return vod.storage_key;++ return KEY_PREFIX + path.basename(vod.file_path || '');++}++++function localPathForVod(vod) {++ return path.join(path.resolve(config.vod.path), path.basename(vod.file_path || ''));++}++++function providerOf(vod) {++ const p = String(vod?.storage_provider || 'local').toLowerCase();++ return REMOTE_PROVIDERS.includes(p) ? p : 'local';++}++++function isRemote(vod) {++ return providerOf(vod) !== 'local';++}++++// ── S3 primitives ────────────────────────────────────────────++++async function headObject(provider, key) {++ const client = clientFor(provider);++ if (!client) return null;++ try {++ const res = await client.send(new S3.HeadObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));++ return { size: Number(res.ContentLength || 0), etag: res.ETag || null };++ } catch (err) {++ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NotFound' || err?.name === 'NoSuchKey') return null;++ throw err;++ }++}++++async function uploadFile(provider, key, filePath, contentType = 'video/webm') {++ const client = clientFor(provider);++ if (!client) throw new Error(`Provider ${provider} not configured`);++ loadSdk();++ const upload = new LibStorage.Upload({++ client,++ params: {++ Bucket: PROVIDER_ENV[provider].bucket,++ Key: key,++ Body: fs.createReadStream(filePath),++ ContentType: contentType,++ },++ partSize: 64 * 1024 * 1024,++ queueSize: 3,++ leavePartsOnError: false,++ });++ await upload.done();++ // Verify size before anything destructive happens++ const localSize = fs.statSync(filePath).size;++ const head = await headObject(provider, key);++ if (!head || head.size !== localSize) {++ throw new Error(`Upload verification failed for ${provider}:${key} (local ${localSize}, remote ${head ? head.size : 'missing'})`);++ }++ return { size: localSize };++}++++async function copyBetweenProviders(srcProvider, dstProvider, key) {++ const src = clientFor(srcProvider);++ const dst = clientFor(dstProvider);++ if (!src || !dst) throw new Error(`Provider not configured for copy ${srcProvider}→${dstProvider}`);++ loadSdk();++ const obj = await src.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[srcProvider].bucket, Key: key }));++ const upload = new LibStorage.Upload({++ client: dst,++ params: {++ Bucket: PROVIDER_ENV[dstProvider].bucket,++ Key: key,++ Body: obj.Body,++ ContentType: obj.ContentType || 'video/webm',++ },++ partSize: 64 * 1024 * 1024,++ queueSize: 3,++ leavePartsOnError: false,++ });++ await upload.done();++ const expected = Number(obj.ContentLength || 0);++ const head = await headObject(dstProvider, key);++ if (!head || (expected && head.size !== expected)) {++ throw new Error(`Copy verification failed for ${key} → ${dstProvider}`);++ }++ return { size: head.size };++}++++async function deleteObject(provider, key) {++ const client = clientFor(provider);++ if (!client) return;++ try {++ await client.send(new S3.DeleteObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));++ } catch (err) {++ console.warn(`[VodStorage] Delete ${provider}:${key} failed:`, err.message);++ }++}++++/**++ * Best-effort removal of a paste's legacy B2 screenshot object(s). Legacy paste++ * screenshots live in B2 under `community.attachments/objects/<date>/…paste-++ * screenshot-<pasteId>.<ext>` (date-partitioned, key not stored in the DB), so we++ * list the prefix and delete anything matching the paste id. Silent no-op if B2 is off.++ */++async function deleteLegacyPasteScreenshot(pasteId) {++ const client = clientFor('b2');++ if (!client || !pasteId) return;++ const marker = `paste-screenshot-${pasteId}.`;++ try {++ let token;++ do {++ const r = await client.send(new S3.ListObjectsV2Command({++ Bucket: PROVIDER_ENV.b2.bucket, Prefix: 'community.attachments/', ContinuationToken: token, MaxKeys: 1000,++ }));++ for (const o of (r.Contents || [])) {++ if (o.Key.split('/').pop().includes(marker)) await deleteObject('b2', o.Key);++ }++ token = r.IsTruncated ? r.NextContinuationToken : null;++ } while (token);++ } catch (err) {++ console.warn(`[VodStorage] Legacy paste screenshot cleanup failed for paste ${pasteId}:`, err.message);++ }++}++++async function presignGet(provider, key, expiresInSeconds = 900) {++ const client = clientFor(provider);++ if (!client) return null;++ loadSdk();++ // Force the response MIME so the browser plays the media even when the stored++ // object metadata is generic (legacy clips were uploaded as octet-stream).++ const ext = path.extname(key || '').toLowerCase();++ const mime = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext];++ return Presigner.getSignedUrl(client, new S3.GetObjectCommand({++ Bucket: PROVIDER_ENV[provider].bucket,++ Key: key,++ ...(mime ? { ResponseContentType: mime } : {}),++ }), { expiresIn: expiresInSeconds });++}++++// ── Playback resolution ──────────────────────────────────────++++/**++ * Returns { kind: 'file', path } for local VODs or++ * { kind: 'redirect', url, provider } for offloaded ones.++ * Falls back through r2 → b2 → local if an object is missing.++ */++async function resolvePlayback(vod) {++ const provider = providerOf(vod);++ const key = keyForVod(vod);++++ if (provider !== 'local') {++ const order = provider === 'r2' ? ['r2', 'b2'] : ['b2', 'r2'];++ for (const p of order) {++ if (!providerConfigured(p) || providerHealthy[p] === false) continue;++ try {++ const url = await presignGet(p, key);++ if (url) return { kind: 'redirect', url, provider: p };++ } catch (err) {++ console.warn(`[VodStorage] Presign failed for ${p}:${key}:`, err.message);++ }++ }++ }++++ const local = localPathForVod(vod);++ if (fs.existsSync(local)) return { kind: 'file', path: local };++ return null;++}++++/**++ * A source ffmpeg/ffprobe can consume: local path, or a presigned https URL.++ */++async function resolveMediaSource(vod) {++ const local = localPathForVod(vod);++ if (fs.existsSync(local)) return { kind: 'file', value: local };++ if (isRemote(vod)) {++ const plan = await resolvePlayback(vod);++ if (plan?.kind === 'redirect') return { kind: 'url', value: plan.url };++ }++ return null;++}++++// ── Tier moves ───────────────────────────────────────────────++++function cleanupSidecar(localPath) {++ for (const seekable of [localPath.replace(/\.webm$/, '.seekable.webm'), localPath.replace(/\.mp4$/, '.seekable.mp4')]) {++ if (seekable !== localPath && fs.existsSync(seekable)) {++ try { fs.unlinkSync(seekable); } catch { /* ignore */ }++ }++ }++}++++/** Offload a local VOD to B2 (cold). */++async function moveToCold(vodId) {++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };++ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };++ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };++++ const key = keyForVod(vod);++ const local = localPathForVod(vod);++++ try {++ if (!fs.existsSync(local)) {++ // Not local — maybe already offloaded++ if (await headObject('b2', key)) {++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);++ return { ok: true, already: true };++ }++ return { ok: false, error: 'Source file missing' };++ }++++ const existing = await headObject('b2', key);++ const localSize = fs.statSync(local).size;++ if (!existing || existing.size !== localSize) {++ await uploadFile('b2', key, local);++ }++++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);++ try { fs.unlinkSync(local); } catch (err) {++ console.error(`[VodStorage] Uploaded but failed to remove local file for VOD ${vodId}:`, err.message);++ }++ cleanupSidecar(local);++++ console.log(`[VodStorage] VOD ${vodId} offloaded to B2: ${key} (${(localSize / 1048576).toFixed(1)} MB)`);++ return { ok: true, bytes: localSize };++ } catch (err) {++ console.error(`[VodStorage] Offload of VOD ${vodId} failed:`, err.message);++ return { ok: false, error: err.message };++ }++}++++/** Restore an offloaded VOD to local disk. */++async function moveToHot(vodId) {++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };++++ const key = keyForVod(vod);++ const local = localPathForVod(vod);++++ if (fs.existsSync(local)) {++ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);++ return { ok: true, already: true };++ }++++ const srcProvider = ['b2', 'r2'].find(p => providerConfigured(p));++ if (!srcProvider) return { ok: false, error: 'No remote provider configured' };++++ try {++ loadSdk();++ let provider = providerOf(vod) === 'r2' ? 'r2' : 'b2';++ let head = await headObject(provider, key);++ if (!head) {++ provider = provider === 'r2' ? 'b2' : 'r2';++ head = providerConfigured(provider) ? await headObject(provider, key) : null;++ }++ if (!head) return { ok: false, error: 'Object missing from remote storage' };++++ const client = clientFor(provider);++ const obj = await client.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));++ const tmp = local + '.download';++ await new Promise((resolve, reject) => {++ const out = fs.createWriteStream(tmp);++ obj.Body.pipe(out);++ obj.Body.on('error', reject);++ out.on('error', reject);++ out.on('finish', resolve);++ });++ if (fs.statSync(tmp).size !== head.size) {++ try { fs.unlinkSync(tmp); } catch { /* ignore */ }++ return { ok: false, error: 'Download verification failed' };++ }++ fs.renameSync(tmp, local);++++ // Restoring to local keeps the B2 canonical copy; drop any R2 copy.++ if (providerOf(vod) === 'r2' && providerConfigured('r2')) await deleteObject('r2', key);++ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);++ console.log(`[VodStorage] VOD ${vodId} restored to local (${(head.size / 1048576).toFixed(1)} MB)`);++ return { ok: true, bytes: head.size };++ } catch (err) {++ console.error(`[VodStorage] Restore of VOD ${vodId} failed:`, err.message);++ return { ok: false, error: err.message };++ }++}++++/** Promote a popular VOD to R2 (free egress). Keeps the B2 canonical copy. */++async function promoteToR2(vodId) {++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };++ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };++ if (!providerConfigured('r2') || providerHealthy.r2 === false) return { ok: false, error: 'R2 not available' };++ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };++++ const key = keyForVod(vod);++ const local = localPathForVod(vod);++++ try {++ // Ensure canonical B2 copy first++ let b2Head = await headObject('b2', key);++ if (!b2Head) {++ if (!fs.existsSync(local)) return { ok: false, error: 'No source available for promotion' };++ await uploadFile('b2', key, local);++ b2Head = await headObject('b2', key);++ }++++ // Put the R2 copy++ const r2Head = await headObject('r2', key);++ if (!r2Head || r2Head.size !== b2Head.size) {++ if (fs.existsSync(local)) await uploadFile('r2', key, local);++ else await copyBetweenProviders('b2', 'r2', key);++ }++++ db.run("UPDATE vods SET storage_provider = 'r2', storage_key = ? WHERE id = ?", [key, vodId]);++++ // Popular VODs live in R2+B2; free the local copy++ let freed = 0;++ if (fs.existsSync(local)) {++ freed = fs.statSync(local).size;++ try { fs.unlinkSync(local); cleanupSidecar(local); } catch { freed = 0; }++ }++++ console.log(`[VodStorage] VOD ${vodId} promoted to R2: ${key}`);++ return { ok: true, bytes: freed };++ } catch (err) {++ console.error(`[VodStorage] R2 promotion of VOD ${vodId} failed:`, err.message);++ return { ok: false, error: err.message };++ }++}++++/** Demote a stale R2 VOD back to B2-only. */++async function demoteFromR2(vodId) {++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);++ if (!vod) return { ok: false, error: 'VOD not found' };++ if (providerOf(vod) !== 'r2') return { ok: true, already: true };++++ const key = keyForVod(vod);++ try {++ // Canonical must exist in B2 before we delete the R2 copy++ const b2Head = await headObject('b2', key);++ if (!b2Head) {++ const copied = await copyBetweenProviders('r2', 'b2', key).catch(() => null);++ if (!copied) return { ok: false, error: 'No B2 canonical and copy-back failed' };++ }++ await deleteObject('r2', key);++ db.run("UPDATE vods SET storage_provider = 'b2' WHERE id = ?", [vodId]);++ console.log(`[VodStorage] VOD ${vodId} demoted from R2 to B2`);++ return { ok: true };++ } catch (err) {++ return { ok: false, error: err.message };++ }++}++++/** Delete a VOD's (or clip's — same columns) media everywhere (local + B2 + R2). */++async function deleteVodObjects(vod) {++ if (!vod?.file_path) return;++ const local = localPathForVod(vod);++ if (fs.existsSync(local)) {++ try { fs.unlinkSync(local); } catch { /* ignore */ }++ }++ // Clips live in the clips dir — also try the record's own absolute path.++ if (vod.file_path !== local && fs.existsSync(vod.file_path)) {++ try { fs.unlinkSync(vod.file_path); } catch { /* ignore */ }++ }++ cleanupSidecar(local);++ const key = keyForVod(vod);++ for (const p of REMOTE_PROVIDERS) {++ if (providerConfigured(p)) await deleteObject(p, key);++ }++}++++// ── Disk usage ───────────────────────────────────────────────++++function diskUsage(targetPath) {++ try {++ const resolved = path.resolve(targetPath);++ const output = execSync(`df -B1 "${resolved}" 2>/dev/null | tail -1`, { encoding: 'utf8' });++ const parts = output.trim().split(/\s+/);++ if (parts.length >= 6) {++ return {++ total: parseInt(parts[1], 10) || 0,++ used: parseInt(parts[2], 10) || 0,++ available: parseInt(parts[3], 10) || 0,++ usePct: parseFloat(parts[4]) || 0,++ mount: parts[5] || '/',++ };++ }++ } catch { /* ignore */ }++ return { total: 0, used: 0, available: 0, usePct: 0, mount: '/' };++}++++function dirStats(dirPath) {++ let bytes = 0, files = 0;++ try {++ const resolved = path.resolve(dirPath);++ if (!fs.existsSync(resolved)) return { bytes: 0, files: 0 };++ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {++ if (entry.isFile()) {++ try { bytes += fs.statSync(path.join(resolved, entry.name)).size; files++; } catch { /* ignore */ }++ }++ }++ } catch { /* ignore */ }++ return { bytes, files };++}++++// ── Sweep ────────────────────────────────────────────────────++++async function runSweep() {++ if (sweepRunning) return { skipped: true, reason: 'already running' };++ sweepRunning = true;++++ try {++ const settings = getSettings();++ if (!settings.enabled) return { skipped: true, reason: 'disabled' };++ if (!providerConfigured('b2')) return { skipped: true, reason: 'B2 not configured' };++ if (providerHealthy.b2 === false) return { skipped: true, reason: 'B2 unhealthy' };++++ let migrated = 0, bytesFreed = 0, promoted = 0, demoted = 0;++ const errors = [];++++ const disk = diskUsage(config.vod.path);++ const underPressure = disk.usePct >= settings.hotDiskPressurePct;++++ // 1) Cold offload to B2++ let candidates;++ if (underPressure) {++ console.log(`[VodStorage] Disk at ${disk.usePct}% (pressure ≥ ${settings.hotDiskPressurePct}%) — draining to ${settings.localLowWaterPct}%`);++ candidates = db.all(`++ SELECT id, file_path, file_size FROM vods++ WHERE COALESCE(storage_provider, 'local') = 'local'++ AND COALESCE(is_recording, 0) = 0++ AND created_at <= datetime('now', '-1 day')++ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC++ LIMIT 100++ `);++ } else {++ candidates = db.all(`++ SELECT id, file_path, file_size FROM vods++ WHERE COALESCE(storage_provider, 'local') = 'local'++ AND COALESCE(is_recording, 0) = 0++ AND created_at <= datetime('now', ?)++ AND COALESCE(view_count, 0) <= ?++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))++ ORDER BY view_count ASC, file_size DESC++ LIMIT ?++ `, [`-${settings.minAgeDays} days`, settings.maxViewsForCold, `-${settings.minLastAccessDays} days`, settings.maxPerSweep]);++ }++++ for (const vod of candidates) {++ if (underPressure && diskUsage(config.vod.path).usePct <= settings.localLowWaterPct) break;++ const result = await moveToCold(vod.id);++ if (result.ok && !result.already) { migrated++; bytesFreed += result.bytes || 0; }++ else if (!result.ok) errors.push({ id: vod.id, error: result.error });++ }++++ // 2) R2 promotion for popular VODs++ if (settings.r2Enabled && providerConfigured('r2') && providerHealthy.r2 !== false) {++ const popular = db.all(`++ SELECT id FROM vods++ WHERE COALESCE(storage_provider, 'local') IN ('local', 'b2')++ AND COALESCE(is_recording, 0) = 0++ AND COALESCE(view_count, 0) >= ?++ AND last_accessed_at IS NOT NULL++ AND last_accessed_at >= datetime('now', ?)++ LIMIT ?++ `, [settings.r2MinViews, `-${settings.r2RecentAccessDays} days`, settings.r2MaxPerSweep]);++ for (const vod of popular) {++ const result = await promoteToR2(vod.id);++ if (result.ok && !result.already) { promoted++; bytesFreed += result.bytes || 0; }++ else if (!result.ok) errors.push({ id: vod.id, error: result.error });++ }++++ // 3) R2 demotion for stale VODs++ const stale = db.all(`++ SELECT id FROM vods++ WHERE storage_provider = 'r2'++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))++ LIMIT 10++ `, [`-${settings.r2MaxIdleDays} days`]);++ for (const vod of stale) {++ const result = await demoteFromR2(vod.id);++ if (result.ok && !result.already) demoted++;++ }++ }++++ const summary = {++ checked: candidates.length,++ migrated,++ promoted,++ demoted,++ bytesFreed,++ underPressure,++ errors: errors.length ? errors : undefined,++ timestamp: new Date().toISOString(),++ };++ if (migrated || promoted || demoted) {++ console.log(`[VodStorage] Sweep: ${migrated} → B2, ${promoted} → R2, ${demoted} R2→B2, ${(bytesFreed / 1048576).toFixed(1)} MB freed locally`);++ }++ return summary;++ } catch (err) {++ console.error('[VodStorage] Sweep error:', err.message);++ return { error: err.message };++ } finally {++ sweepRunning = false;++ }++}++++// ── Health + lifecycle ───────────────────────────────────────++++async function checkProviders() {++ if (!providerConfigured('b2') && !providerConfigured('r2')) {++ providerHealthy.b2 = false;++ providerHealthy.r2 = false;++ console.log('[VodStorage] No object-storage providers configured — local-only mode');++ return { ...providerHealthy };++ }++ loadSdk();++ for (const name of REMOTE_PROVIDERS) {++ if (!providerConfigured(name)) { providerHealthy[name] = false; continue; }++ try {++ await clientFor(name).send(new S3.HeadBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));++ providerHealthy[name] = true;++ } catch (err) {++ // R2 bucket may not exist yet — try to create it++ if (name === 'r2') {++ try {++ await clientFor(name).send(new S3.CreateBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));++ providerHealthy[name] = true;++ console.log('[VodStorage] Created R2 bucket:', PROVIDER_ENV[name].bucket);++ continue;++ } catch (createErr) {++ console.warn('[VodStorage] R2 bucket create failed:', createErr.message);++ }++ }++ providerHealthy[name] = false;++ console.warn(`[VodStorage] Provider ${name} unavailable:`, err.message);++ }++ }++ console.log(`[VodStorage] Providers — b2: ${providerHealthy.b2 ? 'ok' : 'unavailable'}, r2: ${providerHealthy.r2 ? 'ok' : 'unavailable'}`);++ return { ...providerHealthy };++}++++/**++ * One-time migration from the legacy hot/cold columns: rows imported with++ * storage_tier='cold' were uploaded to B2 at vods/<basename> by the old rclone++ * mount — verify and flip them to provider 'b2'.++ */++async function migrateLegacy() {++ if (!providerConfigured('b2')) return;++ const legacy = db.all(`++ SELECT id, file_path FROM vods++ WHERE storage_tier = 'cold' AND COALESCE(storage_provider, 'local') = 'local'++ `);++ if (!legacy.length) return;++ let flipped = 0, restoredLocal = 0;++ for (const vod of legacy) {++ const key = KEY_PREFIX + path.basename(vod.file_path || '');++ try {++ const head = await headObject('b2', key);++ if (head) {++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vod.id]);++ flipped++;++ } else if (fs.existsSync(localPathForVod(vod))) {++ restoredLocal++; // still local, sweep will re-offload++ } else {++ console.warn(`[VodStorage] Legacy cold VOD ${vod.id} missing from B2 and local disk`);++ }++ } catch (err) {++ console.warn(`[VodStorage] Legacy migration check failed for VOD ${vod.id}:`, err.message);++ }++ }++ console.log(`[VodStorage] Legacy migration: ${flipped} cold VOD(s) mapped to B2${restoredLocal ? `, ${restoredLocal} still local` : ''}`);++}++++function start() {++ stop();++ const settings = getSettings();++ if (!settings.enabled) {++ console.log('[VodStorage] Disabled — not starting sweep timer');++ return;++ }++ console.log(`[VodStorage] Starting sweep timer (every ${(settings.sweepIntervalMs / 60000).toFixed(0)} min)`);++ setTimeout(() => {++ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));++ sweepTimer = setInterval(() => {++ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));++ }, settings.sweepIntervalMs);++ if (sweepTimer.unref) sweepTimer.unref();++ }, 30_000).unref?.();++}++++function stop() {++ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }++}++++// ── Status ───────────────────────────────────────────────────++++function getStatus() {++ const settings = getSettings();++ const localDisk = diskUsage(config.vod.path);++ const localStats = dirStats(config.vod.path);++++ const counts = db.get(`++ SELECT++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,++ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,++ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count,++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) as localBytes,++ SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) as b2Bytes,++ SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) as r2Bytes++ FROM vods++ `) || {};++++ let clipCounts = {};++ try {++ clipCounts = db.get(`++ SELECT++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,++ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,++ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count++ FROM clips++ `) || {};++ } catch { clipCounts = {}; }++++ return {++ settings,++ engine: 'local+b2+r2',++ providers: {++ b2: { configured: providerConfigured('b2'), healthy: providerHealthy.b2 !== false, bucket: PROVIDER_ENV.b2.bucket },++ r2: { configured: providerConfigured('r2'), healthy: providerHealthy.r2 !== false, bucket: PROVIDER_ENV.r2.bucket },++ },++ local: { disk: localDisk, vods: { bytes: localStats.bytes, files: localStats.files } },++ tiers: {++ local: { count: counts.localCount || 0, bytes: counts.localBytes || 0 },++ b2: { count: counts.b2Count || 0, bytes: counts.b2Bytes || 0 },++ r2: { count: counts.r2Count || 0, bytes: counts.r2Bytes || 0 },++ },++ clipTiers: {++ local: { count: clipCounts.localCount || 0 },++ b2: { count: clipCounts.b2Count || 0 },++ r2: { count: clipCounts.r2Count || 0 },++ },++ sweepRunning,++ };++}++++module.exports = {++ DEFAULTS,++ providerOf,++ isRemote,++ keyForVod,++ localPathForVod,++ resolvePlayback,++ resolveMediaSource,++ moveToCold,++ moveToHot,++ promoteToR2,++ demoteFromR2,++ deleteVodObjects,++ deleteObject,++ deleteLegacyPasteScreenshot,++ presignGet,++ runSweep,++ checkProviders,++ migrateLegacy,++ start,++ stop,++ getStatus,++ getSettings,++ setSetting,++ diskUsage,++ dirStats,++};++++// ── CLI: node server/vod/vod-storage.js <check|migrate-legacy|drain [pct]> ──++if (require.main === module) {++ (async () => {++ const cmd = process.argv[2];++ if (cmd === 'check') {++ await checkProviders();++ console.log(JSON.stringify(getStatus().tiers, null, 2));++ process.exit(0);++ }++ if (cmd === 'migrate-legacy') {++ await checkProviders();++ await migrateLegacy();++ process.exit(0);++ }++ if (cmd === 'drain') {++ await checkProviders();++ const target = Number(process.argv[3] || getSetting('localLowWaterPct'));++ console.log(`[VodStorage] CLI drain to ${target}% disk usage`);++ for (let round = 0; round < 50; round++) {++ const disk = diskUsage(config.vod.path);++ console.log(`[VodStorage] Disk at ${disk.usePct}%`);++ if (disk.usePct <= target) break;++ const eligible = db.all(`++ SELECT id FROM vods++ WHERE COALESCE(storage_provider, 'local') = 'local'++ AND COALESCE(is_recording, 0) = 0++ AND created_at <= datetime('now', '-1 day')++ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC++ LIMIT 10++ `);++ if (!eligible.length) { console.log('[VodStorage] Nothing left to drain'); break; }++ for (const vod of eligible) {++ const r = await moveToCold(vod.id);++ if (!r.ok) console.warn(` VOD ${vod.id}: ${r.error}`);++ }++ }++ process.exit(0);++ }++ console.log('Usage: node server/vod/vod-storage.js <check|migrate-legacy|drain [targetPct]>');++ process.exit(1);++ })().catch(err => { console.error(err); process.exit(1); });++}nnew file mode 100644@@@ -0,0 +1,68 @@++/**++ * OpenVibe.Media — Outbound webhooks++ *++ * POSTs { event, app_id, data } to the app's configured webhook_url with++ * `X-OVMedia-Signature: sha256=<hmac>` — HMAC-SHA256 of the raw body using the++ * app's webhook_secret (per CONTRACTS.md). Fire-and-forget with small retries.++ *++ * Events: vod.ready | vod.failed | clip.ready | clip.failed++ */++'use strict';++++const crypto = require('crypto');++const db = require('./db/database');++++const MAX_ATTEMPTS = 3;++const RETRY_BASE_MS = 3000;++const TIMEOUT_MS = 10000;++++function sign(secret, rawBody) {++ return 'sha256=' + crypto.createHmac('sha256', String(secret || '')).update(rawBody).digest('hex');++}++++async function _post(url, rawBody, signature) {++ const res = await fetch(url, {++ method: 'POST',++ headers: {++ 'Content-Type': 'application/json',++ 'X-OVMedia-Signature': signature,++ },++ body: rawBody,++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (!res.ok) throw new Error(`HTTP ${res.status}`);++}++++/**++ * Send an event to an app's webhook. Silently no-ops when the app has no++ * webhook_url. Never throws.++ * @param {string|object} appOrId app_id or apps row++ * @param {string} event e.g. 'vod.ready'++ * @param {object} data event payload++ */++async function sendWebhook(appOrId, event, data) {++ let app = appOrId;++ if (typeof appOrId === 'string') {++ try { app = db.getApp(appOrId); } catch { app = null; }++ }++ if (!app || !app.webhook_url) return false;++++ const rawBody = JSON.stringify({ event, app_id: app.app_id, data });++ const signature = sign(app.webhook_secret, rawBody);++++ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {++ try {++ await _post(app.webhook_url, rawBody, signature);++ return true;++ } catch (err) {++ if (attempt === MAX_ATTEMPTS) {++ console.warn(`[Webhooks] ${event} → ${app.app_id} failed after ${MAX_ATTEMPTS} attempts: ${err.message}`);++ return false;++ }++ await new Promise(r => setTimeout(r, RETRY_BASE_MS * attempt));++ }++ }++ return false;++}++++module.exports = { sendWebhook, sign };nnew file mode 100644@@@ -0,0 +1,676 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Account Switcher++// Google-style multi-account management with anonymous mode.++// Persists sessions in localStorage, coordinates with server++// for token swapping. Integrates with OpenVibeNavbar dropdown.++// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const STORAGE_KEY = 'openvibe_accounts';++ const TOKEN_KEY = 'ov_token';++ const ACTIVE_KEY = 'openvibe_active_account';++ const ANON_KEY = 'openvibe_anon_token';++ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>++ const MAX_ACCOUNTS = 10;++++ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };++ let _panelEl = null;++++ function isAnonId(id) {++ return String(id) === 'anon' || String(id).startsWith('anon_');++ }++++ /** Store of session tokens per anon identity */++ function getAnonTokens() {++ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }++ }++ function saveAnonTokens(tokens) {++ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));++ }++ function setAnonToken(anonId, token) {++ const tokens = getAnonTokens();++ tokens[anonId] = token;++ saveAnonTokens(tokens);++ }++ function getAnonToken(anonId) {++ return getAnonTokens()[anonId] || null;++ }++ function removeAnonToken(anonId) {++ const tokens = getAnonTokens();++ delete tokens[anonId];++ saveAnonTokens(tokens);++ }++++ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on++ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is++ // shared across the tool satellites.++ function onToolsDomain() {++ const host = (typeof location !== 'undefined' && location.hostname) || '';++ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');++ }++++ function clearAuthCookie() {++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';++ if (onToolsDomain()) {++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';++ }++ }++++ function setAuthCookie(token) {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ if (onToolsDomain()) {++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;++ } else {++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;++ }++ }++++ function normalizeAccount(user, token) {++ const isAnon = !!user?.is_anon || isAnonId(user?.id);++ const anonNum = user.anon_number || null;++ // Each anon gets a unique ID based on its anon_number++ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;++ return {++ id,++ username: user.username,++ display_name: user.display_name || user.username,++ avatar_url: user.avatar_url || null,++ email: user.email || null,++ is_anon: isAnon,++ anon_number: anonNum,++ token,++ added_at: Date.now(),++ };++ }++++ function getAnonAccount(id) {++ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;++ return getAccounts().find(a => a && a.is_anon) || null;++ }++++ function getAnonAccounts() {++ return getAccounts().filter(a => a && a.is_anon);++ }++++ // ─── Account Store ─────────────────────────────────────────++ function getAccounts() {++ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }++ }++++ function saveAccounts(accounts) {++ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));++ }++++ function getActiveId() {++ return localStorage.getItem(ACTIVE_KEY);++ }++++ function setActiveId(id) {++ localStorage.setItem(ACTIVE_KEY, id);++ }++++ function addAccount(user, token) {++ const accounts = getAccounts();++ const entry = normalizeAccount(user, token);++ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));++ if (existing !== -1) {++ accounts[existing] = entry;++ } else {++ if (accounts.length >= MAX_ACCOUNTS) {++ accounts.shift(); // Remove oldest++ }++ accounts.push(entry);++ }++ saveAccounts(accounts);++ return accounts;++ }++++ function removeAccount(id) {++ const targetId = String(id);++ const accounts = getAccounts().filter(a => String(a.id) !== targetId);++ saveAccounts(accounts);++ // Clean up anon token if removing an anon identity++ if (isAnonId(targetId)) removeAnonToken(targetId);++ if (getActiveId() === targetId) {++ const fallback = accounts.find(a => !a.is_anon) || accounts[0];++ if (fallback) switchTo(fallback.id);++ else logout();++ }++ return accounts;++ }++++ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */++ function getCookieToken() {++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);++ return m ? decodeURIComponent(m[1]) : null;++ }++++ // ─── API ───────────────────────────────────────────────────++ async function apiFetch(path, opts = {}) {++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();++ const headers = { 'Content-Type': 'application/json', ...opts.headers };++ if (token) headers.Authorization = `Bearer ${token}`;++ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ }++++ async function switchTo(accountId) {++ if (isAnonId(accountId)) return switchToAnon(accountId);++++ const accounts = getAccounts();++ const target = accounts.find(a => String(a.id) === String(accountId));++ if (target?.is_anon) return switchToAnon(accountId);++ if (!target || !target.token) {++ // Token expired — need re-login++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;++ return;++ }++++ // Validate token is still good++ try {++ const headers = { Authorization: `Bearer ${target.token}` };++ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });++ if (!res.ok) throw new Error('Token invalid');++ } catch {++ // Token expired++ removeAccount(target.id);++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;++ return;++ }++++ localStorage.setItem(TOKEN_KEY, target.token);++ setActiveId(target.id);++ setAuthCookie(target.token);++++ if (_config.onSwitch) _config.onSwitch(target);++ else window.location.reload();++ }++++ /**++ * Switch to an anonymous identity. If anonId is provided, switch to that++ * specific anon. Otherwise use the most recently active or create a new one.++ */++ async function switchToAnon(anonId) {++ async function fetchAnonUser(token) {++ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });++ if (!res.ok) throw new Error('Anon session invalid');++ const data = await res.json();++ return data.user || null;++ }++++ let anonToken = null;++ let anonUser = null;++++ // If switching to a specific anon identity, look up its token++ if (anonId && anonId !== 'anon') {++ anonToken = getAnonToken(anonId);++ }++++ // Fallback to legacy single-anon token++ if (!anonToken) {++ anonToken = localStorage.getItem(ANON_KEY);++ }++++ // Try to validate the existing token++ if (anonToken) {++ try {++ anonUser = await fetchAnonUser(anonToken);++ } catch {++ // Token invalid — clear it++ if (anonId) removeAnonToken(anonId);++ localStorage.removeItem(ANON_KEY);++ anonToken = null;++ }++ }++++ // Create new anon session if we don't have a valid one++ if (!anonToken) {++ try {++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: '{}',++ credentials: 'include',++ }).then(async (res) => {++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ });++ anonToken = data.token;++ anonUser = data.user || null;++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);++ return;++ }++ }++++ if (!anonUser) {++ try {++ anonUser = await fetchAnonUser(anonToken);++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);++ return;++ }++ }++++ // Store the anon account and its token++ if (anonUser) {++ const entry = addAccount(anonUser, anonToken);++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';++ setAnonToken(realId, anonToken);++ // Also keep legacy key for backward compat++ localStorage.setItem(ANON_KEY, anonToken);++++ localStorage.removeItem(TOKEN_KEY);++ setActiveId(realId);++ clearAuthCookie();++++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });++ else window.location.reload();++ }++ }++++ /**++ * Create a brand-new anonymous identity (even if one already exists).++ * Useful for the "New Anon Identity" button.++ */++ async function createNewAnon() {++ try {++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ force_new: true }),++ credentials: 'include',++ }).then(async (res) => {++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ });++ const anonToken = data.token;++ const anonUser = data.user;++ if (!anonUser) throw new Error('No user returned');++++ const entry = addAccount(anonUser, anonToken);++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';++ setAnonToken(realId, anonToken);++++ localStorage.removeItem(TOKEN_KEY);++ localStorage.setItem(ANON_KEY, anonToken);++ setActiveId(realId);++ clearAuthCookie();++++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });++ else window.location.reload();++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);++ }++ }++++ function logout() {++ const activeId = getActiveId();++ if (activeId) removeAccount(activeId);++ localStorage.removeItem(TOKEN_KEY);++ localStorage.removeItem(ACTIVE_KEY);++ clearAuthCookie();++ if (_config.onSwitch) _config.onSwitch(null);++ else window.location.reload();++ }++++ function logoutAll() {++ localStorage.removeItem(STORAGE_KEY);++ localStorage.removeItem(TOKEN_KEY);++ localStorage.removeItem(ACTIVE_KEY);++ localStorage.removeItem(ANON_KEY);++ localStorage.removeItem(ANON_TOKENS_KEY);++ clearAuthCookie();++ if (_config.onSwitch) _config.onSwitch(null);++ else window.location.reload();++ }++++ // ─── Switcher Panel UI ─────────────────────────────────────++ function injectStyles() {++ if (document.getElementById('openvibe-switcher-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-switcher-styles';++ s.textContent = `++ .openvibe-switcher-overlay {++ position: fixed; inset: 0; z-index: 20000;++ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);++ display: flex; align-items: center; justify-content: center;++ animation: openvibe-sw-fadein .2s ease;++ }++ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }++++ .openvibe-switcher-panel {++ width: 380px; max-height: 520px;++ background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 16px;++ box-shadow: 0 20px 60px rgba(0,0,0,0.6);++ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);++ }++ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }++++ .openvibe-switcher-header {++ text-align: center; padding: 20px 16px 12px;++ border-bottom: 1px solid var(--border, #333340);++ }++ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }++ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }++++ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }++++ .openvibe-switcher-item {++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;++ border-radius: 10px; cursor: pointer; transition: background .15s;++ position: relative;++ }++ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }++ .openvibe-switcher-item.active::after {++ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);++ color: var(--accent, #8b5cf6); font-weight: 700; font-size: 16px;++ }++ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }++ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }++ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }++ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }++ .openvibe-switcher-item .remove {++ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);++ cursor: pointer; padding: 4px 6px; border-radius: 4px;++ transition: opacity .15s, background .15s;++ }++ .openvibe-switcher-item:hover .remove { opacity: .6; }++ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }++++ .openvibe-switcher-anon {++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;++ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;++ border: 1px dashed var(--border, #333340);++ transition: all .15s; color: var(--text-muted, #707080);++ }++ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }++ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }++++ .openvibe-switcher-actions {++ padding: 8px 12px; border-top: 1px solid var(--border, #333340);++ display: flex; gap: 8px; justify-content: center;++ }++ .openvibe-switcher-actions button {++ flex: 1; padding: 8px; border: none; border-radius: 8px;++ font-size: 12px; font-weight: 600; cursor: pointer;++ transition: all .15s;++ }++ .openvibe-switcher-actions .btn-add {++ background: var(--accent, #8b5cf6); color: #fff;++ }++ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }++ .openvibe-switcher-actions .btn-signout {++ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);++ }++ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }++ `;++ document.head.appendChild(s);++ }++++ function showPanel() {++ if (_panelEl) return;++ injectStyles();++++ const accounts = getAccounts();++ const activeId = getActiveId();++ const isAnonActive = isAnonId(activeId);++ const loggedAccounts = accounts.filter(a => !a.is_anon);++ const anonAccounts = accounts.filter(a => a.is_anon);++++ const overlay = document.createElement('div');++ overlay.className = 'openvibe-switcher-overlay';++ overlay.addEventListener('click', e => {++ if (e.target === overlay) closePanel();++ });++++ const panel = document.createElement('div');++ panel.className = 'openvibe-switcher-panel';++++ panel.innerHTML = `++ <div class="openvibe-switcher-header">++ <h3>\uD83D\uDD25 Switch Account</h3>++ <p>Choose an account or browse anonymously</p>++ </div>++ <div class="openvibe-switcher-list">++ ${loggedAccounts.map(a => `++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">++ <div class="info">++ <div class="name">${a.display_name || a.username}</div>++ <div class="sub">${a.email || `@${a.username}`}</div>++ </div>++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}++ </div>++ `).join('')}++++ ${anonAccounts.length > 0 ? `++ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>++ ${anonAccounts.map(a => `++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">++ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>++ <div class="info">++ <div class="name">Anonymous #${a.anon_number || '?'}</div>++ <div class="sub">anon_${a.anon_number || '?'}</div>++ </div>++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}++ </div>++ `).join('')}++ ` : ''}++ </div>++ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">++ <span class="anon-icon">+</span>++ <div class="info">++ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>++ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>++ </div>++ </div>++ <div class="openvibe-switcher-actions">++ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>++ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>++ </div>++ `;++++ overlay.appendChild(panel);++ document.body.appendChild(overlay);++ _panelEl = overlay;++++ // Wire events++ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {++ el.addEventListener('click', e => {++ if (e.target.closest('.remove')) return;++ switchTo(el.dataset.id);++ closePanel();++ });++ });++++ panel.querySelectorAll('[data-remove-id]').forEach(el => {++ el.addEventListener('click', e => {++ e.stopPropagation();++ removeAccount(el.dataset.removeId);++ closePanel();++ showPanel(); // Re-render++ });++ });++++ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {++ if (anonAccounts.length > 0) {++ createNewAnon();++ } else {++ switchToAnon();++ }++ closePanel();++ });++++ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {++ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ });++++ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {++ logoutAll();++ closePanel();++ });++++ // Esc to close++ const escHandler = e => { if (e.key === 'Escape') closePanel(); };++ document.addEventListener('keydown', escHandler);++ overlay._escHandler = escHandler;++ }++++ function closePanel() {++ if (!_panelEl) return;++ document.removeEventListener('keydown', _panelEl._escHandler);++ _panelEl.remove();++ _panelEl = null;++ }++++ // ─── Token Refresh ───────────────────────────────────────────++ // Decode JWT payload without verification (client-side can't verify RS256).++ // Used only to check `exp` claim for proactive refresh.++ function decodeJwtPayload(token) {++ try {++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');++ return JSON.parse(atob(payload));++ } catch { return null; }++ }++++ let _refreshTimer = null;++ let _refreshInProgress = false;++++ /**++ * Silently refresh the JWT if it expires within the next 2 hours,++ * or if it has already expired (7-day grace period on the server).++ * Updates localStorage, cookie, and account list.++ */++ async function silentRefresh() {++ if (_refreshInProgress) return;++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();++ if (!token) return;++ const payload = decodeJwtPayload(token);++ if (!payload || !payload.exp) return;++++ const expiresAt = payload.exp * 1000;++ const now = Date.now();++ const twoHours = 2 * 60 * 60 * 1000;++++ // Only refresh if token expires within 2 hours or is already expired++ if (expiresAt - now > twoHours) return;++++ _refreshInProgress = true;++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {++ method: 'POST',++ headers: {++ 'Content-Type': 'application/json',++ Authorization: `Bearer ${token}`,++ },++ credentials: 'include',++ });++ if (!res.ok) {++ // If refresh fails with 401, token is truly dead — don't retry++ if (res.status === 401) {++ console.warn('[OpenVibeAuth] Token refresh failed — session expired');++ }++ return;++ }++ const data = await res.json();++ if (data.token) {++ // Update localStorage++ localStorage.setItem(TOKEN_KEY, data.token);++ // Update cookie++ setAuthCookie(data.token);++ // Update the stored account entry++ const activeId = getActiveId();++ if (activeId && !isAnonId(activeId)) {++ const accounts = getAccounts();++ const idx = accounts.findIndex(a => String(a.id) === String(activeId));++ if (idx !== -1) {++ accounts[idx].token = data.token;++ saveAccounts(accounts);++ }++ }++ console.log('[OpenVibeAuth] Token refreshed silently');++ }++ } catch (err) {++ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);++ } finally {++ _refreshInProgress = false;++ }++ }++++ /**++ * Start a periodic timer that checks token expiry and refreshes when needed.++ * Checks every 15 minutes. Also runs an immediate check.++ */++ function startRefreshTimer() {++ if (_refreshTimer) return;++ silentRefresh(); // Immediate check++ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min++ }++++ function stopRefreshTimer() {++ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }++ }++++ // ─── Listen for navbar events ──────────────────────────────++ document.addEventListener('openvibe-switch-account', e => {++ switchTo(e.detail.accountId);++ });++++ // ─── Public API ────────────────────────────────────────────++ const OpenVibeAccountSwitcher = {++ init(opts = {}) {++ Object.assign(_config, opts);++ // Start silent token refresh for logged-in users++ const activeId = getActiveId();++ if (activeId && !isAnonId(activeId)) {++ startRefreshTimer();++ }++ },++++ /** Call after login success to store this account. */++ addAccount,++ removeAccount,++ getAccounts,++ getActiveId,++ switchTo,++ switchToAnon,++ createNewAnon,++ getAnonAccounts,++ logout,++ logoutAll,++ silentRefresh,++ startRefreshTimer,++ stopRefreshTimer,++++ /** Show the full-screen account switcher panel. */++ showPanel,++ closePanel,++++ /** Quick check: is user on an anonymous session? */++ isAnonymous() {++ const id = getActiveId();++ return isAnonId(id);++ },++++ /** Get the current active account object. */++ getActive() {++ const id = getActiveId();++ if (!id) return null;++ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;++ return getAccounts().find(a => String(a.id) === String(id)) || null;++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;++ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,1066 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Unified Analytics Module++// Shared analytics tracking for all OpenVibe services.++// Each service stores events in its own SQLite DB using the++// same schema. The admin panel aggregates via internal API.++// ═══════════════════════════════════════════════════════════════++++// ── Bot Detection ────────────────────────────────────────────++++const BOT_USER_AGENTS = [++ /googlebot/i, /bingbot/i, /slurp/i, /duckduckbot/i, /baiduspider/i,++ /yandexbot/i, /facebot/i, /ia_archiver/i, /semrushbot/i, /ahrefsbot/i,++ /mj12bot/i, /dotbot/i, /petalbot/i, /rogerbot/i, /screaming frog/i,++ /seznambot/i, /sogou/i, /exabot/i, /archive\.org_bot/i, /crawler/i,++ /spider/i, /python-requests/i, /python-urllib/i, /httpx/i,++ /go-http-client/i, /java\//i, /libwww-perl/i, /wget/i, /curl/i,++ /headlesschrome/i, /phantomjs/i, /scrapy/i, /node-fetch/i,++ /axios/i, /postman/i, /insomnia/i, /lighthouse/i, /pagespeed/i,++ /gptbot/i, /chatgpt-user/i, /claudebot/i, /anthropic/i,++ /bytespider/i, /amazonbot/i, /applebot/i, /twitterbot/i,++ /facebookexternalhit/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i,++ /discordbot/i, /slackbot/i, /uptimerobot/i, /pingdom/i,++ /statuscake/i, /monitoring/i, /health.?check/i,++];++++const SUSPICIOUS_PATTERNS = {++ // Hits per minute thresholds++ highRequestRate: 60,++ // Pages per session in under a minute++ rapidPageViews: 20,++ // No JS execution (missing beacon)++ noJsExecution: true,++ // Accessing well-known bot traps++ honeypotPaths: ['/wp-login.php', '/xmlrpc.php', '/wp-admin', '/.env', '/admin.php', '/phpmyadmin'],++};++++/**++ * Classify whether a request is from a bot.++ * Returns: { isBot: boolean, botType: string|null, confidence: number }++ */++function classifyRequest(req) {++ const ua = req.headers['user-agent'] || '';++ const ip = req.ip || req.connection?.remoteAddress || '';++++ // 1. Known bot user agents++ for (const pattern of BOT_USER_AGENTS) {++ if (pattern.test(ua)) {++ return { isBot: true, botType: 'known_crawler', confidence: 0.95 };++ }++ }++++ // 2. Missing or empty user agent++ if (!ua || ua.length < 10) {++ return { isBot: true, botType: 'no_useragent', confidence: 0.85 };++ }++++ // 3. Honeypot paths++ const path = req.path || req.url || '';++ if (SUSPICIOUS_PATTERNS.honeypotPaths.some(p => path.toLowerCase().startsWith(p))) {++ return { isBot: true, botType: 'honeypot_hit', confidence: 0.90 };++ }++++ // 4. Looks human (default)++ return { isBot: false, botType: null, confidence: 0.1 };++}++++++// ── Schema ───────────────────────────────────────────────────++++const ANALYTICS_SCHEMA = `++ -- Page views / API hits++ CREATE TABLE IF NOT EXISTS analytics_events (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ service TEXT NOT NULL,++ event_type TEXT NOT NULL DEFAULT 'pageview',++ path TEXT,++ method TEXT DEFAULT 'GET',++ status_code INTEGER,++ response_time_ms INTEGER,++ user_id INTEGER,++ session_id TEXT,++ ip TEXT,++ country TEXT,++ city TEXT,++ user_agent TEXT,++ referer TEXT,++ is_bot INTEGER DEFAULT 0,++ bot_type TEXT,++ device_type TEXT,++ browser TEXT,++ os TEXT,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++++ -- Aggregated hourly stats (materialized by cron/timer)++ CREATE TABLE IF NOT EXISTS analytics_hourly (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ service TEXT NOT NULL,++ hour TEXT NOT NULL,++ pageviews INTEGER DEFAULT 0,++ api_calls INTEGER DEFAULT 0,++ unique_visitors INTEGER DEFAULT 0,++ unique_users INTEGER DEFAULT 0,++ bot_hits INTEGER DEFAULT 0,++ avg_response_ms INTEGER DEFAULT 0,++ error_count INTEGER DEFAULT 0,++ bandwidth_bytes INTEGER DEFAULT 0,++ top_paths TEXT,++ top_referers TEXT,++ UNIQUE(service, hour)++ );++++ -- Daily aggregates++ CREATE TABLE IF NOT EXISTS analytics_daily (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ service TEXT NOT NULL,++ date TEXT NOT NULL,++ pageviews INTEGER DEFAULT 0,++ api_calls INTEGER DEFAULT 0,++ unique_visitors INTEGER DEFAULT 0,++ unique_users INTEGER DEFAULT 0,++ new_users INTEGER DEFAULT 0,++ bot_hits INTEGER DEFAULT 0,++ avg_response_ms INTEGER DEFAULT 0,++ error_count INTEGER DEFAULT 0,++ top_paths TEXT,++ top_referers TEXT,++ top_countries TEXT,++ device_breakdown TEXT,++ browser_breakdown TEXT,++ UNIQUE(service, date)++ );++++ -- Rate tracking for bot detection++ CREATE TABLE IF NOT EXISTS analytics_rate_tracking (++ ip TEXT NOT NULL,++ window_start INTEGER NOT NULL,++ hit_count INTEGER DEFAULT 1,++ PRIMARY KEY (ip, window_start)++ );++++ -- Indexes++ CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_events_ip ON analytics_events(ip, created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_events_user ON analytics_events(user_id, created_at);++ CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);++ CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);++`;++++++// ── Device/Browser Parsing (lightweight) ─────────────────────++++function parseUserAgent(ua) {++ if (!ua) return { device: 'unknown', browser: 'unknown', os: 'unknown' };++++ // Device++ let device = 'desktop';++ if (/mobile|android|iphone|ipod/i.test(ua)) device = 'mobile';++ else if (/ipad|tablet/i.test(ua)) device = 'tablet';++++ // Browser++ let browser = 'other';++ if (/edg\//i.test(ua)) browser = 'edge';++ else if (/opr\/|opera/i.test(ua)) browser = 'opera';++ else if (/firefox\//i.test(ua)) browser = 'firefox';++ else if (/chrome\//i.test(ua) && !/chromium/i.test(ua)) browser = 'chrome';++ else if (/safari\//i.test(ua) && !/chrome/i.test(ua)) browser = 'safari';++ else if (/trident|msie/i.test(ua)) browser = 'ie';++++ // OS++ let os = 'other';++ if (/windows/i.test(ua)) os = 'windows';++ else if (/macintosh|mac os/i.test(ua)) os = 'macos';++ else if (/linux/i.test(ua) && !/android/i.test(ua)) os = 'linux';++ else if (/android/i.test(ua)) os = 'android';++ else if (/iphone|ipad|ipod/i.test(ua)) os = 'ios';++++ return { device, browser, os };++}++++++// ── Analytics Tracker Class ──────────────────────────────────++++class AnalyticsTracker {++ /**++ * @param {object} db - better-sqlite3 database instance++ * @param {string} service - service name (e.g., 'live', 'openvibe-network', 'tools')++ */++ constructor(db, service) {++ this.db = db;++ this.service = service;++ this._buffer = [];++ this._flushInterval = null;++ this._aggregateInterval = null;++++ // Initialize schema++ this.db.exec(ANALYTICS_SCHEMA);++++ // Prepared statements++ this._insertEvent = this.db.prepare(`++ INSERT INTO analytics_events++ (service, event_type, path, method, status_code, response_time_ms,++ user_id, session_id, ip, country, city, user_agent, referer,++ is_bot, bot_type, device_type, browser, os)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ `);++++ this._insertMany = this.db.transaction((events) => {++ for (const e of events) {++ this._insertEvent.run(++ e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,++ e.user_id, e.session_id, e.ip, e.country, e.city, e.user_agent, e.referer,++ e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os++ );++ }++ });++++ this._updateRate = this.db.prepare(`++ INSERT INTO analytics_rate_tracking (ip, window_start, hit_count)++ VALUES (?, ?, 1)++ ON CONFLICT(ip, window_start) DO UPDATE SET hit_count = hit_count + 1++ `);++++ this._getRate = this.db.prepare(++ 'SELECT SUM(hit_count) as total FROM analytics_rate_tracking WHERE ip = ? AND window_start >= ?'++ );++++ this._cleanRates = this.db.prepare(++ 'DELETE FROM analytics_rate_tracking WHERE window_start < ?'++ );++++ // Start flush timer (write buffered events every 5s)++ this._flushInterval = setInterval(() => this.flush(), 5000);++++ // Hourly aggregation timer++ this._aggregateInterval = setInterval(() => this.aggregate(), 60 * 60 * 1000);++++ // Initial aggregation on startup (catch up)++ setTimeout(() => this.aggregate(), 10_000);++++ // Clean old rate tracking every 10 min++ setInterval(() => {++ const cutoff = Math.floor(Date.now() / 1000) - 300;++ try { this._cleanRates.run(cutoff); } catch {}++ }, 10 * 60 * 1000);++ }++++ /**++ * Express middleware for automatic request tracking.++ * Tracks page views and API calls with response times.++ */++ middleware() {++ return (req, res, next) => {++ // Skip health checks and static assets++ const path = req.path || '';++ if (path === '/api/health' || path === '/health' ||++ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path)) {++ return next();++ }++++ const startTime = Date.now();++++ // Hook into response finish++ const originalEnd = res.end;++ res.end = (...args) => {++ res.end = originalEnd;++ res.end(...args);++++ const responseTime = Date.now() - startTime;++ const botCheck = classifyRequest(req);++ const parsed = parseUserAgent(req.headers['user-agent'] || '');++++ const isApi = path.startsWith('/api/') || path.startsWith('/internal/') || path.startsWith('/oauth/');++ const eventType = isApi ? 'api_call' : 'pageview';++++ // Rate-based bot detection++ const ip = req.ip || '';++ const windowStart = Math.floor(Date.now() / 60000); // 1-minute windows++ try {++ this._updateRate.run(ip, windowStart);++ if (!botCheck.isBot) {++ const rateData = this._getRate.get(ip, windowStart - 1);++ if (rateData && rateData.total > SUSPICIOUS_PATTERNS.highRequestRate) {++ botCheck.isBot = true;++ botCheck.botType = 'rate_limit';++ botCheck.confidence = 0.80;++ }++ }++ } catch {}++++ // Get session ID from cookie or generate from IP+UA++ const sessionId = req.cookies?.openvibe_session ||++ req.cookies?.ov_token?.slice(-16) ||++ null;++++ this._buffer.push({++ service: this.service,++ event_type: eventType,++ path: path.substring(0, 500),++ method: req.method,++ status_code: res.statusCode,++ response_time_ms: responseTime,++ user_id: req.user?.id || req.user?.sub || null,++ session_id: sessionId,++ ip: ip,++ country: req.headers['cf-ipcountry'] || null,++ city: null,++ user_agent: (req.headers['user-agent'] || '').substring(0, 500),++ referer: (req.headers['referer'] || '').substring(0, 500),++ is_bot: botCheck.isBot,++ bot_type: botCheck.botType,++ device_type: parsed.device,++ browser: parsed.browser,++ os: parsed.os,++ });++++ // Auto-flush if buffer gets large++ if (this._buffer.length >= 100) {++ this.flush();++ }++ };++++ next();++ };++ }++++ /**++ * Track a custom event (not from HTTP request).++ */++ trackEvent(eventType, data = {}) {++ this._buffer.push({++ service: this.service,++ event_type: eventType,++ path: data.path || null,++ method: data.method || null,++ status_code: data.status_code || null,++ response_time_ms: data.response_time_ms || null,++ user_id: data.user_id || null,++ session_id: data.session_id || null,++ ip: data.ip || null,++ country: data.country || null,++ city: data.city || null,++ user_agent: data.user_agent || null,++ referer: data.referer || null,++ is_bot: data.is_bot || false,++ bot_type: data.bot_type || null,++ device_type: data.device_type || null,++ browser: data.browser || null,++ os: data.os || null,++ });++ }++++ /**++ * Flush buffered events to database.++ */++ flush() {++ if (this._buffer.length === 0) return;++ const events = this._buffer.splice(0);++ try {++ this._insertMany(events);++ } catch (err) {++ console.error(`[Analytics:${this.service}] Flush error:`, err.message);++ }++ }++++ /**++ * Run hourly + daily aggregation.++ */++ aggregate() {++ try {++ this._aggregateHourly();++ this._aggregateDaily();++ // Prune raw events older than 90 days++ this.db.prepare("DELETE FROM analytics_events WHERE created_at < datetime('now', '-90 days')").run();++ } catch (err) {++ console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);++ }++ }++++ _aggregateHourly() {++ // Aggregate last 2 hours (to catch stragglers)++ const hours = [];++ const now = new Date();++ for (let i = 0; i < 2; i++) {++ const d = new Date(now.getTime() - i * 3600000);++ hours.push(d.toISOString().slice(0, 13) + ':00:00');++ }++++ const upsert = this.db.prepare(`++ INSERT OR REPLACE INTO analytics_hourly++ (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ `);++++ for (const hour of hours) {++ const nextHour = new Date(new Date(hour).getTime() + 3600000).toISOString().slice(0, 19).replace('T', ' ');++ const hourStart = hour.replace('T', ' ');++++ const stats = this.db.prepare(`++ SELECT++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,++ COUNT(DISTINCT ip) AS unique_visitors,++ COUNT(DISTINCT user_id) AS unique_users,++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at < ?++ `).get(this.service, hourStart, nextHour);++++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;++++ const topPaths = this.db.prepare(`++ SELECT path, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0++ GROUP BY path ORDER BY cnt DESC LIMIT 10++ `).all(this.service, hourStart, nextHour);++++ const topReferers = this.db.prepare(`++ SELECT referer, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0++ GROUP BY referer ORDER BY cnt DESC LIMIT 10++ `).all(this.service, hourStart, nextHour);++++ upsert.run(++ this.service, hourStart,++ stats.pageviews, stats.api_calls, stats.unique_visitors, stats.unique_users,++ stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,++ JSON.stringify(topPaths), JSON.stringify(topReferers)++ );++ }++ }++++ _aggregateDaily() {++ // Aggregate last 2 days++ const days = [];++ const now = new Date();++ for (let i = 0; i < 2; i++) {++ const d = new Date(now.getTime() - i * 86400000);++ days.push(d.toISOString().slice(0, 10));++ }++++ const upsert = this.db.prepare(`++ INSERT OR REPLACE INTO analytics_daily++ (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,++ avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ `);++++ for (const date of days) {++ const dayStart = date + ' 00:00:00';++ const dayEnd = date + ' 23:59:59';++++ const stats = this.db.prepare(`++ SELECT++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,++ COUNT(DISTINCT ip) AS unique_visitors,++ COUNT(DISTINCT user_id) AS unique_users,++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ?++ `).get(this.service, dayStart, dayEnd);++++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;++++ const topPaths = this.db.prepare(`++ SELECT path, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0++ GROUP BY path ORDER BY cnt DESC LIMIT 15++ `).all(this.service, dayStart, dayEnd);++++ const topReferers = this.db.prepare(`++ SELECT referer, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0++ GROUP BY referer ORDER BY cnt DESC LIMIT 10++ `).all(this.service, dayStart, dayEnd);++++ const topCountries = this.db.prepare(`++ SELECT country, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0++ GROUP BY country ORDER BY cnt DESC LIMIT 10++ `).all(this.service, dayStart, dayEnd);++++ const devices = this.db.prepare(`++ SELECT device_type, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0++ GROUP BY device_type ORDER BY cnt DESC++ `).all(this.service, dayStart, dayEnd);++++ const browsers = this.db.prepare(`++ SELECT browser, COUNT(*) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0++ GROUP BY browser ORDER BY cnt DESC++ `).all(this.service, dayStart, dayEnd);++++ // New users (first seen this day)++ const newUsers = this.db.prepare(`++ SELECT COUNT(DISTINCT user_id) as cnt FROM analytics_events++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND user_id IS NOT NULL++ AND user_id NOT IN (++ SELECT DISTINCT user_id FROM analytics_events++ WHERE service = ? AND created_at < ? AND user_id IS NOT NULL++ )++ `).get(this.service, dayStart, dayEnd, this.service, dayStart);++++ upsert.run(++ this.service, date, stats.pageviews, stats.api_calls,++ stats.unique_visitors, stats.unique_users,++ newUsers?.cnt || 0, stats.bot_hits,++ stats.avg_response_ms || 0, stats.error_count,++ JSON.stringify(topPaths), JSON.stringify(topReferers),++ JSON.stringify(topCountries), JSON.stringify(devices),++ JSON.stringify(browsers)++ );++ }++ }++++ /**++ * Get analytics data for the admin dashboard.++ * @param {object} options++ * @param {number} options.days - Number of days to look back (default 30)++ * @param {number} options.hours - If set, overrides days with an hour-based range (e.g. 1, 12, 24, 72)++ * @param {string} options.service - Service name override++ */++ getStats(options = {}) {++ const { days = 30, hours, service } = options;++ const svc = service || this.service;++++ // Compute cutoff timestamp for raw event queries++ const rangeMs = hours ? hours * 3600000 : days * 86400000;++ const rangeCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 19).replace('T', ' ');++ const isSubDay = hours && hours < 24;++ const effectiveDays = hours ? hours / 24 : days;++++ // Daily trend (use daily aggregates for multi-day, raw events for sub-day)++ let daily = [];++ if (!isSubDay) {++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);++ daily = this.db.prepare(`++ SELECT * FROM analytics_daily++ WHERE service = ? AND date >= ?++ ORDER BY date ASC++ `).all(svc, dateCutoff);++ }++++ // Hourly trend (always useful — scope to the requested range)++ const hourlyLimit = hours ? Math.min(hours, 168) : 24;++ const hourlyCutoff = new Date(Date.now() - hourlyLimit * 3600000).toISOString().slice(0, 19).replace('T', ' ');++ const hourly = this.db.prepare(`++ SELECT * FROM analytics_hourly++ WHERE service = ? AND hour >= ?++ ORDER BY hour ASC++ `).all(svc, hourlyCutoff);++++ // For sub-day ranges, generate fine-grained time buckets from raw events++ let timeBuckets = [];++ if (isSubDay) {++ // 5-minute buckets for ≤1h, 15-min for ≤12h, 30-min for ≤24h++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;++ timeBuckets = this.db.prepare(`++ SELECT++ strftime('%Y-%m-%d %H:', created_at) ||++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS unique_visitors,++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ GROUP BY bucket++ ORDER BY bucket ASC++ `).all(svc, rangeCutoff);++ }++++ // Summary totals — for sub-day ranges, compute from raw events++ let summary;++ if (hours && hours < 48) {++ summary = this.db.prepare(`++ SELECT++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS total_unique_visitors,++ COUNT(DISTINCT user_id) FILTER (WHERE is_bot = 0 AND user_id IS NOT NULL) AS total_unique_users,++ COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,++ COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ `).get(svc, rangeCutoff);++ } else {++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);++ summary = this.db.prepare(`++ SELECT++ SUM(pageviews) as total_pageviews,++ SUM(api_calls) as total_api_calls,++ SUM(unique_visitors) as total_unique_visitors,++ SUM(unique_users) as total_unique_users,++ SUM(bot_hits) as total_bot_hits,++ SUM(error_count) as total_errors,++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms++ FROM analytics_daily++ WHERE service = ? AND date >= ?++ `).get(svc, dateCutoff);++ }++++ // Real-time (last 5 min)++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');++ const realtime = this.db.prepare(`++ SELECT++ COUNT(*) as requests,++ COUNT(DISTINCT ip) as visitors,++ COUNT(*) FILTER (WHERE is_bot = 1) as bots++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ `).get(svc, fiveMinAgo);++++ // Top pages (human only)++ const topPages = this.db.prepare(`++ SELECT path, COUNT(*) as hits, COUNT(DISTINCT ip) as visitors++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY path ORDER BY hits DESC LIMIT 20++ `).all(svc, rangeCutoff);++++ // Top referers++ const topReferers = this.db.prepare(`++ SELECT referer, COUNT(*) as hits++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ AND referer IS NOT NULL AND referer != '' AND is_bot = 0++ GROUP BY referer ORDER BY hits DESC LIMIT 15++ `).all(svc, rangeCutoff);++++ // Bot breakdown++ const botBreakdown = this.db.prepare(`++ SELECT bot_type, COUNT(*) as hits++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 1++ GROUP BY bot_type ORDER BY hits DESC LIMIT 15++ `).all(svc, rangeCutoff);++++ // Status code distribution++ const statusCodes = this.db.prepare(`++ SELECT++ CASE++ WHEN status_code >= 200 AND status_code < 300 THEN '2xx'++ WHEN status_code >= 300 AND status_code < 400 THEN '3xx'++ WHEN status_code >= 400 AND status_code < 500 THEN '4xx'++ WHEN status_code >= 500 THEN '5xx'++ ELSE 'other'++ END as group_code,++ COUNT(*) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ GROUP BY group_code ORDER BY cnt DESC++ `).all(svc, rangeCutoff);++++ // Device/browser/OS breakdown++ const deviceBreakdown = this.db.prepare(`++ SELECT device_type, COUNT(*) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY device_type ORDER BY cnt DESC++ `).all(svc, rangeCutoff);++++ const browserBreakdown = this.db.prepare(`++ SELECT browser, COUNT(*) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY browser ORDER BY cnt DESC++ `).all(svc, rangeCutoff);++++ const osBreakdown = this.db.prepare(`++ SELECT os, COUNT(*) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY os ORDER BY cnt DESC++ `).all(svc, rangeCutoff);++++ // Country breakdown++ const countryBreakdown = this.db.prepare(`++ SELECT country, COUNT(*) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ?++ AND country IS NOT NULL AND is_bot = 0++ GROUP BY country ORDER BY cnt DESC LIMIT 20++ `).all(svc, rangeCutoff);++++ // ── Enhanced Metrics ─────────────────────────────────++++ // Response time percentiles (p50, p90, p95, p99)++ let responsePercentiles = {};++ try {++ const rtRows = this.db.prepare(`++ SELECT response_time_ms FROM analytics_events++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0++ ORDER BY response_time_ms ASC++ `).all(svc, rangeCutoff);++ if (rtRows.length > 0) {++ const vals = rtRows.map(r => r.response_time_ms);++ const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];++ responsePercentiles = { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };++ }++ } catch {}++++ // Bandwidth estimation (response_time_ms * rough factor isn't great,++ // but we can count requests and estimate from content-length if tracked;++ // for now, count total requests * avg page weight heuristic)++ let bandwidth = {};++ try {++ const bwData = this.db.prepare(`++ SELECT++ COUNT(*) as total_requests,++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,++ SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ `).get(svc, rangeCutoff);++ if (bwData) {++ bandwidth = {++ total_requests: bwData.total_requests || 0,++ page_requests: bwData.page_requests || 0,++ api_requests: bwData.api_requests || 0,++ estimated_bytes: bwData.estimated_bytes || 0,++ };++ }++ } catch {}++++ // Authenticated vs anonymous sessions++ let authBreakdown = {};++ try {++ authBreakdown = this.db.prepare(`++ SELECT++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS authenticated,++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anonymous,++ COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) AS unique_authenticated_users,++ COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ `).get(svc, rangeCutoff) || {};++ } catch {}++++ // Error rate over time (hourly for multi-day, bucketed for sub-day)++ let errorTrend = [];++ try {++ if (isSubDay) {++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;++ errorTrend = this.db.prepare(`++ SELECT++ strftime('%Y-%m-%d %H:', created_at) ||++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,++ COUNT(*) AS total,++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,++ COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY bucket ORDER BY bucket ASC++ `).all(svc, rangeCutoff);++ } else {++ errorTrend = this.db.prepare(`++ SELECT hour as bucket,++ (pageviews + api_calls) as total,++ error_count as errors++ FROM analytics_hourly++ WHERE service = ? AND hour >= ?++ ORDER BY hour ASC++ `).all(svc, hourlyCutoff);++ }++ } catch {}++++ // Peak hours (which hours of day get most traffic)++ let peakHours = [];++ try {++ peakHours = this.db.prepare(`++ SELECT++ CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,++ COUNT(*) AS hits,++ COUNT(DISTINCT ip) AS visitors,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY hour_of_day++ ORDER BY hour_of_day ASC++ `).all(svc, rangeCutoff);++ } catch {}++++ // Top API endpoints (separate from top pages)++ let topApiEndpoints = [];++ try {++ topApiEndpoints = this.db.prepare(`++ SELECT path, method, COUNT(*) as hits,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0++ GROUP BY path, method ORDER BY hits DESC LIMIT 20++ `).all(svc, rangeCutoff);++ } catch {}++++ // Unique sessions count++ let sessionCount = 0;++ try {++ const sc = this.db.prepare(`++ SELECT COUNT(DISTINCT session_id) as cnt++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0++ `).get(svc, rangeCutoff);++ sessionCount = sc?.cnt || 0;++ } catch {}++++ // New vs returning visitors (first-time IPs in this period vs seen before)++ let visitorTypes = {};++ try {++ visitorTypes = this.db.prepare(`++ SELECT++ COUNT(DISTINCT ip) FILTER (++ WHERE ip NOT IN (++ SELECT DISTINCT ip FROM analytics_events++ WHERE service = ? AND created_at < ? AND is_bot = 0++ )++ ) AS new_visitors,++ COUNT(DISTINCT ip) FILTER (++ WHERE ip IN (++ SELECT DISTINCT ip FROM analytics_events++ WHERE service = ? AND created_at < ? AND is_bot = 0++ )++ ) AS returning_visitors++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ `).get(svc, rangeCutoff, svc, rangeCutoff, svc, rangeCutoff) || {};++ } catch {}++++ // Slowest endpoints++ let slowestEndpoints = [];++ try {++ slowestEndpoints = this.db.prepare(`++ SELECT path, method,++ COUNT(*) as hits,++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,++ MAX(response_time_ms) AS max_ms++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0++ GROUP BY path, method++ HAVING hits >= 3++ ORDER BY avg_ms DESC LIMIT 10++ `).all(svc, rangeCutoff);++ } catch {}++++ // Authenticated vs anonymous time trend++ let authTrend = [];++ try {++ if (isSubDay) {++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;++ authTrend = this.db.prepare(`++ SELECT++ strftime('%Y-%m-%d %H:', created_at) ||++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY bucket ORDER BY bucket ASC++ `).all(svc, rangeCutoff);++ } else {++ authTrend = this.db.prepare(`++ SELECT++ strftime('%Y-%m-%d', created_at) AS bucket,++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits++ FROM analytics_events++ WHERE service = ? AND created_at >= ? AND is_bot = 0++ GROUP BY bucket ORDER BY bucket ASC++ `).all(svc, rangeCutoff);++ }++ } catch {}++++ return {++ service: svc,++ period_days: effectiveDays,++ period_hours: hours || null,++ summary: summary || {},++ realtime: realtime || {},++ daily,++ hourly,++ timeBuckets,++ topPages,++ topReferers,++ botBreakdown,++ statusCodes,++ deviceBreakdown,++ browserBreakdown,++ osBreakdown,++ countryBreakdown,++ // Enhanced metrics++ responsePercentiles,++ bandwidth,++ authBreakdown,++ errorTrend,++ peakHours,++ topApiEndpoints,++ sessionCount,++ visitorTypes,++ slowestEndpoints,++ authTrend,++ };++ }++++ /**++ * Get a cross-service overview (for the admin panel).++ */++ getOverview(days = 30) {++ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);++++ const services = this.db.prepare(`++ SELECT++ service,++ SUM(pageviews) as pageviews,++ SUM(api_calls) as api_calls,++ SUM(unique_visitors) as unique_visitors,++ SUM(unique_users) as unique_users,++ SUM(bot_hits) as bot_hits,++ SUM(error_count) as errors,++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms++ FROM analytics_daily++ WHERE date >= ?++ GROUP BY service++ ORDER BY pageviews DESC++ `).all(cutoff);++++ // Totals across all services++ const totals = this.db.prepare(`++ SELECT++ SUM(pageviews) as pageviews,++ SUM(api_calls) as api_calls,++ SUM(unique_visitors) as unique_visitors,++ SUM(bot_hits) as bot_hits,++ SUM(error_count) as errors++ FROM analytics_daily++ WHERE date >= ?++ `).get(cutoff);++++ // Daily trend (all services combined)++ const dailyTrend = this.db.prepare(`++ SELECT date,++ SUM(pageviews) as pageviews,++ SUM(api_calls) as api_calls,++ SUM(unique_visitors) as unique_visitors,++ SUM(bot_hits) as bot_hits++ FROM analytics_daily++ WHERE date >= ?++ GROUP BY date++ ORDER BY date ASC++ `).all(cutoff);++++ // Real-time all services++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');++ const realtime = this.db.prepare(`++ SELECT++ service,++ COUNT(*) as requests,++ COUNT(DISTINCT ip) as visitors,++ COUNT(*) FILTER (WHERE is_bot = 1) as bots++ FROM analytics_events++ WHERE created_at >= ?++ GROUP BY service++ `).all(fiveMinAgo);++++ return {++ period_days: days,++ totals: totals || {},++ services,++ dailyTrend,++ realtime,++ };++ }++++ /**++ * Get bot analysis data.++ */++ getBotAnalysis(days = 30) {++ const daysCast = Math.min(Math.max(1, days), 365);++++ // Top bot IPs++ const topBotIPs = this.db.prepare(`++ SELECT ip, COUNT(*) as hits, bot_type, user_agent,++ MIN(created_at) as first_seen, MAX(created_at) as last_seen++ FROM analytics_events++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')++ GROUP BY ip ORDER BY hits DESC LIMIT 25++ `).all(daysCast);++++ // Bot vs human ratio over time++ const botTrend = this.db.prepare(`++ SELECT date,++ SUM(pageviews) + SUM(api_calls) as human_hits,++ SUM(bot_hits) as bot_hits++ FROM analytics_daily++ WHERE date >= date('now', '-' || ? || ' days')++ GROUP BY date ORDER BY date ASC++ `).all(daysCast);++++ // Bot type breakdown++ const botTypes = this.db.prepare(`++ SELECT bot_type, COUNT(*) as hits, COUNT(DISTINCT ip) as unique_ips++ FROM analytics_events++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')++ GROUP BY bot_type ORDER BY hits DESC++ `).all(daysCast);++++ // Suspicious behavior patterns++ const suspiciousIPs = this.db.prepare(`++ SELECT ip, COUNT(*) as total_hits,++ COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,++ COUNT(DISTINCT path) as unique_paths,++ MIN(created_at) as first_seen,++ MAX(created_at) as last_seen++ FROM analytics_events++ WHERE created_at >= datetime('now', '-7 days') AND is_bot = 0++ GROUP BY ip++ HAVING total_hits > 500++ ORDER BY total_hits DESC LIMIT 20++ `).all();++++ return {++ topBotIPs,++ botTrend,++ botTypes,++ suspiciousIPs,++ };++ }++++ /**++ * Destroy tracker (clean up timers).++ */++ destroy() {++ this.flush();++ if (this._flushInterval) clearInterval(this._flushInterval);++ if (this._aggregateInterval) clearInterval(this._aggregateInterval);++ }++}++++++module.exports = {++ AnalyticsTracker,++ classifyRequest,++ parseUserAgent,++ ANALYTICS_SCHEMA,++ BOT_USER_AGENTS,++ SUSPICIOUS_PATTERNS,++};nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — OAuth2 Auth Client++// Used by OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media++// to authenticate users via the central OpenVibe.Network SSO.++// ═══════════════════════════════════════════════════════════════++++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { BRAND } = require('./brand');++++const AUTH_BASE = BRAND.urls.login;++++class OpenVibeAuthClient {++ /**++ * @param {Object} opts++ * @param {string} opts.clientId - OAuth2 client_id (e.g. 'live')++ * @param {string} opts.clientSecret - OAuth2 client_secret++ * @param {string} opts.redirectUri - Callback URL (e.g. 'https://openvibe.live/auth/callback')++ * @param {string} opts.publicKey - RS256 public key for JWT verification (PEM)++ * @param {string} [opts.authBase] - Override auth server URL (default: openvibe.network)++ * @param {string} [opts.internalBase] - Internal auth API (default: http://127.0.0.1:4000)++ */++ constructor(opts) {++ this.clientId = opts.clientId;++ this.clientSecret = opts.clientSecret;++ this.redirectUri = opts.redirectUri;++ this.publicKey = opts.publicKey;++ this.authBase = opts.authBase || AUTH_BASE;++ this.internalBase = opts.internalBase || 'http://127.0.0.1:4000';++ }++++ /**++ * Generate an authorization URL for the OAuth2 login redirect.++ * @param {string} [scope='profile theme'] - Requested scopes++ * @returns {{ url: string, state: string }}++ */++ getAuthorizationUrl(scope = 'profile theme') {++ const state = crypto.randomBytes(16).toString('hex');++ const params = new URLSearchParams({++ client_id: this.clientId,++ redirect_uri: this.redirectUri,++ response_type: 'code',++ scope,++ state,++ });++ return {++ url: `${this.authBase}/oauth/authorize?${params.toString()}`,++ state,++ };++ }++++ /**++ * Exchange an authorization code for tokens.++ * @param {string} code - The authorization code from the callback++ * @returns {Promise<{ access_token: string, refresh_token: string, user: Object }>}++ */++ async exchangeCode(code) {++ const res = await fetch(`${this.authBase}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ grant_type: 'authorization_code',++ client_id: this.clientId,++ client_secret: this.clientSecret,++ redirect_uri: this.redirectUri,++ code,++ }),++ });++ if (!res.ok) {++ const err = await res.json().catch(() => ({}));++ throw new Error(err.error || `Token exchange failed: ${res.status}`);++ }++ return res.json();++ }++++ /**++ * Refresh an access token using a refresh token.++ * @param {string} refreshToken++ * @returns {Promise<{ access_token: string, refresh_token: string }>}++ */++ async refreshAccessToken(refreshToken) {++ const res = await fetch(`${this.authBase}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ grant_type: 'refresh_token',++ client_id: this.clientId,++ client_secret: this.clientSecret,++ refresh_token: refreshToken,++ }),++ });++ if (!res.ok) {++ const err = await res.json().catch(() => ({}));++ throw new Error(err.error || `Token refresh failed: ${res.status}`);++ }++ return res.json();++ }++++ /**++ * Verify and decode a JWT access token.++ * Uses the RS256 public key from the auth server.++ * @param {string} token++ * @returns {Object|null} Decoded payload or null if invalid++ */++ verifyToken(token) {++ try {++ // issuer is the tools/SSO public URL — derive from authBase to support white-label++ const issuer = this.authBase || this.issuer;++ return jwt.verify(token, this.publicKey, {++ algorithms: ['RS256'],++ issuer,++ });++ } catch {++ return null;++ }++ }++++ /**++ * Verify a token via the internal API (server-to-server).++ * Useful as a fallback or for getting fresh user data.++ * @param {string} token++ * @param {string} internalKey - X-Internal-Key header value++ * @returns {Promise<Object|null>}++ */++ async verifyTokenInternal(token, internalKey) {++ try {++ const res = await fetch(`${this.internalBase}/internal/verify-token`, {++ method: 'POST',++ headers: {++ 'Content-Type': 'application/json',++ 'X-Internal-Key': internalKey,++ },++ body: JSON.stringify({ token }),++ });++ if (!res.ok) return null;++ return res.json();++ } catch {++ return null;++ }++ }++++ /**++ * Get fresh user profile data via internal API.++ * @param {number} userId++ * @param {string} internalKey++ * @returns {Promise<Object|null>}++ */++ async getUserProfile(userId, internalKey) {++ try {++ const res = await fetch(`${this.internalBase}/internal/user/${userId}/sync`, {++ method: 'POST',++ headers: { 'X-Internal-Key': internalKey },++ });++ if (!res.ok) return null;++ return res.json();++ } catch {++ return null;++ }++ }++}++++module.exports = { OpenVibeAuthClient };nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Brand Constants++// Shared across all OpenVibe services for consistent branding.++// Values are seeded with OpenVibe defaults and can be++// overridden at runtime via the URL registry / admin panel.++// ═══════════════════════════════════════════════════════════════++++// ── Default (OpenVibe) values ────────────────────────────++// These are the canonical defaults for the OpenVibe deployment.++// White-label installs override via the URL registry.++++const BRAND_DEFAULTS = Object.freeze({++ networkName: 'OpenVibe',++ networkServiceName: 'OpenVibe.Network',++ toolsServiceName: 'OpenVibe.Tools',++ streamerServiceName:'OpenVibe.Live',++ gamesServiceName: 'OpenVibe.Games',++ mediaServiceName: 'OpenVibe.Media',++ tagline: 'One Account. All of OpenVibe.',++ campTagline: 'Free & Open Live Streaming',++ toolsSubdomainBase: 'openvibe.tools',++ discord: 'https://discord.gg/M6MuRUaeJj',++ github: 'https://github.com/OpenVibe.Live',++});++++function getDefaultBrandUrls(env = process.env) {++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ login: env.LOGIN_URL || env.OV_NETWORK_LOGIN_URL || env.OV_NETWORK_URL || 'https://openvibe.network',++ tools: env.OV_TOOLS_URL || 'https://openvibe.tools',++ live: env.OV_LIVE_URL || 'https://openvibe.live',++ games: env.OV_GAMES_URL || 'https://openvibe.games',++ media: env.OV_MEDIA_URL || 'https://openvibe.media',++ community:env.OV_COMMUNITY_URL || 'https://openvibe.community',++ // Tool satellites (Host-routed under openvibe.tools)++ maps: env.OV_MAPS_URL || 'https://maps.openvibe.tools',++ food: env.OV_FOOD_URL || 'https://food.openvibe.tools',++ img: env.OV_IMG_URL || 'https://img.openvibe.tools',++ yt: env.OV_YT_URL || 'https://yt.openvibe.tools',++ audio: env.OV_AUDIO_URL || 'https://audio.openvibe.tools',++ text: env.OV_TEXT_URL || 'https://text.openvibe.tools',++ logo: env.OV_LOGO_URL || 'https://logo.openvibe.tools',++ docs: env.OV_DOCS_URL || 'https://docs.openvibe.tools',++ net: env.OV_NET_URL || 'https://net.openvibe.tools',++ dev: env.OV_DEV_URL || 'https://dev.openvibe.tools',++ pastes: env.OV_PASTES_URL || 'https://pastes.openvibe.tools',++ // Legacy aliases (older callers) — same targets as above++ streamer: env.OV_LIVE_URL || 'https://openvibe.live',++ quest: env.OV_GAMES_URL || 'https://openvibe.games',++ discord: BRAND_DEFAULTS.discord,++ github: BRAND_DEFAULTS.github,++ };++}++++const DEFAULT_URLS = Object.freeze(getDefaultBrandUrls());++++function resolveBrandUrls(overrides = {}, env = process.env) {++ return Object.freeze({++ ...getDefaultBrandUrls(env),++ ...overrides,++ });++}++++/**++ * Build a brand object from a resolved URL registry map.++ * Call this server-side when the canonical registry has been loaded.++ *++ * @param {Object} registry - resolved registry map from resolveRegistryValues()++ * @param {Object} [env] - process.env fallback++ * @returns {Object} brand object with names, urls, colors, services, oauth++ */++function buildBrandFromRegistry(registry = {}, env = process.env) {++ const get = (key) => (registry[key]?.value) || null;++++ const networkName = get('NETWORK_NAME') || BRAND_DEFAULTS.networkName;++ const toolsServiceName = get('TOOLS_SERVICE_NAME') || BRAND_DEFAULTS.toolsServiceName;++ const streamerServiceName= get('STREAMER_SERVICE_NAME')|| BRAND_DEFAULTS.streamerServiceName;++ const gamesServiceName = get('GAMES_SERVICE_NAME') || BRAND_DEFAULTS.gamesServiceName;++ const mediaServiceName = get('MEDIA_SERVICE_NAME') || BRAND_DEFAULTS.mediaServiceName;++ const defaults = getDefaultBrandUrls(env);++ const networkUrl = get('OV_NETWORK_URL') || defaults.network;++ const liveUrl = get('OV_LIVE_URL') || defaults.live;++ const toolsUrl = get('OV_TOOLS_URL') || defaults.tools;++ const gamesUrl = get('OV_GAMES_URL') || defaults.games;++ const mediaUrl = get('OV_MEDIA_URL') || defaults.media;++++ const urls = resolveBrandUrls({++ network: networkUrl,++ login: get('OV_NETWORK_LOGIN_URL') || networkUrl,++ live: liveUrl,++ streamer: liveUrl,++ tools: toolsUrl,++ games: gamesUrl,++ quest: gamesUrl,++ media: mediaUrl,++ }, env);++++ // Build service list using registry URLs where available++ const services = [++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },++ { id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },++ { id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },++ { id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },++ ];++++ return Object.freeze({++ name: networkName,++ toolsName: toolsServiceName,++ streamerName:streamerServiceName,++ gamesName: gamesServiceName,++ mediaName: mediaServiceName,++ tagline: BRAND_DEFAULTS.tagline,++ campTagline: BRAND_DEFAULTS.campTagline,++ toolsSubdomainBase: get('TOOLS_SUBDOMAIN_BASE') || BRAND_DEFAULTS.toolsSubdomainBase,++ urls: Object.freeze(urls),++ colors: BRAND.colors,++ oauth: BRAND.oauth,++ services: Object.freeze(services),++ });++}++++const BRAND = Object.freeze({++ name: BRAND_DEFAULTS.networkName,++ tagline: BRAND_DEFAULTS.tagline,++ campTagline: BRAND_DEFAULTS.campTagline,++++ urls: resolveBrandUrls(),++++ // Palette per CONTRACTS.md++ colors: Object.freeze({++ accent: '#8b5cf6',++ accentLight: '#a78bfa',++ accentDark: '#6d28d9',++ secondary: '#22d3ee',++ bgDark: '#131318',++ flame: '#a78bfa', // legacy alias for accentLight++ signalRed: '#e74c3c',++ success: '#2ecc71',++ warning: '#f39c12',++ danger: '#e74c3c',++ info: '#3498db',++ }),++++ // OAuth2 client IDs for each service (seeded in the Network DB)++ oauth: Object.freeze({++ live: 'live',++ tools: 'tools',++ games: 'games',++ media: 'media',++ }),++++ // Primary services in the network (for dashboard display, health checks, etc.)++ // Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.++ services: Object.freeze([++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },++ { id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },++ { id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },++ { id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },++ ]),++++ // Tool sub-brands hosted by OpenVibe.Tools (<Name>.OpenVibe naming)++ toolBrands: Object.freeze([++ { id: 'net', name: 'Net.OpenVibe', url: 'https://net.openvibe.tools', description: 'Network & Internet Diagnostics' },++ { id: 'dev', name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools', description: 'Developer & SEO Tools' },++ { id: 'paste', name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools',description: 'Code & Text Sharing' },++ { id: 'maps', name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools', description: 'Camp & Shelter Locator' },++ { id: 'food', name: 'Food.OpenVibe', url: 'https://food.openvibe.tools', description: 'Food Resource Finder' },++ { id: 'img', name: 'Img.OpenVibe', url: 'https://img.openvibe.tools', description: 'Image Converter & Tools' },++ { id: 'yt', name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools', description: 'YouTube Downloader' },++ { id: 'audio', name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools', description: 'Audio Converter & Effects' },++ { id: 'text', name: 'Text.OpenVibe', url: 'https://text.openvibe.tools', description: 'Text Generation & Unicode' },++ { id: 'logo', name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools', description: 'Logo & Title Card Maker' },++ { id: 'docs', name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools', description: 'PDF & Document Tools' },++ ]),++});++++module.exports = { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry };nnew file mode 100644@@@ -0,0 +1,878 @@++'use strict';++++const RAW_BUILTIN_THEMES = [++ {++ name: 'Vibe',++ slug: 'vibe',++ mode: 'dark',++ description: 'The original OpenVibe.Live look — warm amber tones by firelight.',++ tags: ['default', 'warm', 'cozy'],++ variables: {++ '--bg-primary': '#0d0d0f',++ '--bg-secondary': '#16161a',++ '--bg-tertiary': '#1e1e24',++ '--bg-card': '#1a1a20',++ '--bg-hover': '#242430',++ '--bg-input': '#12121a',++ '--text-primary': '#e8e6e3',++ '--text-secondary': '#9a9a9a',++ '--text-muted': '#666',++ '--accent': '#8b5cf6',++ '--accent-light': '#a78bfa',++ '--accent-dark': '#6d28d9',++ '--border': '#2a2a32',++ '--border-light': '#3a3a44',++ '--live-red': '#e74c3c',++ '--success': '#2ecc71',++ '--warning': '#f39c12',++ '--danger': '#e74c3c',++ '--info': '#3498db',++ '--shadow': '0 2px 12px rgba(0,0,0,0.4)',++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.6)',++ },++ },++ {++ name: 'Midnight',++ slug: 'midnight',++ mode: 'dark',++ description: 'Deep blue-black with cool steel accents.',++ tags: ['cool', 'minimal', 'blue'],++ variables: {++ '--bg-primary': '#0a0e17',++ '--bg-secondary': '#111827',++ '--bg-tertiary': '#1a2235',++ '--bg-card': '#151d2e',++ '--bg-hover': '#1e2a42',++ '--bg-input': '#0d1220',++ '--text-primary': '#e2e8f0',++ '--text-secondary': '#94a3b8',++ '--text-muted': '#64748b',++ '--accent': '#6366f1',++ '--accent-light': '#818cf8',++ '--accent-dark': '#4f46e5',++ '--border': '#1e293b',++ '--border-light': '#334155',++ },++ },++ {++ name: 'Forest',++ slug: 'forest',++ mode: 'dark',++ description: 'Deep greens and earthy tones — nature at night.',++ tags: ['nature', 'green', 'earthy'],++ variables: {++ '--bg-primary': '#0a120a',++ '--bg-secondary': '#121e12',++ '--bg-tertiary': '#1a2a1a',++ '--bg-card': '#152015',++ '--bg-hover': '#1e3420',++ '--bg-input': '#0d160d',++ '--text-primary': '#d4e8d4',++ '--text-secondary': '#8aaa8a',++ '--text-muted': '#5a7a5a',++ '--accent': '#4ade80',++ '--accent-light': '#86efac',++ '--accent-dark': '#22c55e',++ '--border': '#1a3a1a',++ '--border-light': '#2a4a2a',++ },++ },++ {++ name: 'Neon Tokyo',++ slug: 'neon-tokyo',++ mode: 'dark',++ description: 'Cyberpunk neon pink/purple over dark chrome.',++ tags: ['cyberpunk', 'neon', 'vibrant'],++ variables: {++ '--bg-primary': '#0a0a0f',++ '--bg-secondary': '#12101a',++ '--bg-tertiary': '#1a1625',++ '--bg-card': '#15111f',++ '--bg-hover': '#221a32',++ '--bg-input': '#0d0b14',++ '--text-primary': '#f0e6ff',++ '--text-secondary': '#b4a0d0',++ '--text-muted': '#6a5a80',++ '--accent': '#ff006e',++ '--accent-light': '#ff4d94',++ '--accent-dark': '#c9005a',++ '--border': '#2a1a3a',++ '--border-light': '#3a2a4a',++ },++ },++ {++ name: 'Dracula',++ slug: 'dracula',++ mode: 'dark',++ description: 'The beloved Dracula color scheme.',++ tags: ['popular', 'purple', 'classic'],++ variables: {++ '--bg-primary': '#282a36',++ '--bg-secondary': '#1e1f29',++ '--bg-tertiary': '#343746',++ '--bg-card': '#2c2e3c',++ '--bg-hover': '#3a3d50',++ '--bg-input': '#21222c',++ '--text-primary': '#f8f8f2',++ '--text-secondary': '#bfbfb2',++ '--text-muted': '#6272a4',++ '--accent': '#bd93f9',++ '--accent-light': '#d4b8ff',++ '--accent-dark': '#9b6ddb',++ '--border': '#44475a',++ '--border-light': '#565970',++ '--success': '#50fa7b',++ '--danger': '#ff5555',++ '--warning': '#f1fa8c',++ '--info': '#8be9fd',++ '--live-red': '#ff5555',++ },++ },++ {++ name: 'Monokai',++ slug: 'monokai',++ mode: 'dark',++ description: 'Classic Monokai developer palette.',++ tags: ['developer', 'classic', 'warm'],++ variables: {++ '--bg-primary': '#272822',++ '--bg-secondary': '#1e1f1c',++ '--bg-tertiary': '#3e3d32',++ '--bg-card': '#2d2e27',++ '--bg-hover': '#49483e',++ '--bg-input': '#22231e',++ '--text-primary': '#f8f8f2',++ '--text-secondary': '#cfcfc2',++ '--text-muted': '#75715e',++ '--accent': '#a6e22e',++ '--accent-light': '#c4ff50',++ '--accent-dark': '#7dac1c',++ '--border': '#49483e',++ '--border-light': '#5b5a50',++ '--danger': '#f92672',++ '--warning': '#e6db74',++ '--info': '#66d9ef',++ },++ },++ {++ name: 'Ocean Deep',++ slug: 'ocean-deep',++ mode: 'dark',++ description: 'Dark ocean depths with teal highlights.',++ tags: ['ocean', 'teal', 'calm'],++ variables: {++ '--bg-primary': '#0a1215',++ '--bg-secondary': '#0f1a1f',++ '--bg-tertiary': '#162228',++ '--bg-card': '#121c22',++ '--bg-hover': '#1a2a32',++ '--bg-input': '#0c1418',++ '--text-primary': '#d0e8ef',++ '--text-secondary': '#7aa8ba',++ '--text-muted': '#4a7080',++ '--accent': '#00bcd4',++ '--accent-light': '#4dd0e1',++ '--accent-dark': '#0097a7',++ '--border': '#1a3040',++ '--border-light': '#2a4050',++ },++ },++ {++ name: 'Sunset',++ slug: 'sunset',++ mode: 'dark',++ description: 'Warm orange-to-pink gradient vibes at dusk.',++ tags: ['warm', 'gradient', 'vibrant'],++ variables: {++ '--bg-primary': '#120a0a',++ '--bg-secondary': '#1a1010',++ '--bg-tertiary': '#241616',++ '--bg-card': '#1e1212',++ '--bg-hover': '#2e1a1a',++ '--bg-input': '#140c0c',++ '--text-primary': '#ffe8e0',++ '--text-secondary': '#c49080',++ '--text-muted': '#7a5a50',++ '--accent': '#ff6b35',++ '--accent-light': '#ff8f5e',++ '--accent-dark': '#d04a1a',++ '--border': '#3a2020',++ '--border-light': '#4a3030',++ },++ },++ {++ name: 'Arctic',++ slug: 'arctic',++ mode: 'dark',++ description: 'Frozen blues with crisp white accents.',++ tags: ['cool', 'blue', 'clean'],++ variables: {++ '--bg-primary': '#0c1220',++ '--bg-secondary': '#101828',++ '--bg-tertiary': '#182030',++ '--bg-card': '#141c2a',++ '--bg-hover': '#1e2840',++ '--bg-input': '#0e1422',++ '--text-primary': '#e0eaf5',++ '--text-secondary': '#8aa0c0',++ '--text-muted': '#5a708a',++ '--accent': '#38bdf8',++ '--accent-light': '#7dd3fc',++ '--accent-dark': '#0284c7',++ '--border': '#1e3050',++ '--border-light': '#2a4060',++ },++ },++ {++ name: 'Ember',++ slug: 'ember',++ mode: 'dark',++ description: 'Smoldering reds and warm charcoal.',++ tags: ['red', 'warm', 'intense'],++ variables: {++ '--bg-primary': '#120808',++ '--bg-secondary': '#1a0e0e',++ '--bg-tertiary': '#241414',++ '--bg-card': '#1e1010',++ '--bg-hover': '#2e1818',++ '--bg-input': '#140a0a',++ '--text-primary': '#f0dada',++ '--text-secondary': '#b0808a',++ '--text-muted': '#6a4a50',++ '--accent': '#ef4444',++ '--accent-light': '#f87171',++ '--accent-dark': '#b91c1c',++ '--border': '#3a1a20',++ '--border-light': '#4a2a30',++ },++ },++ {++ name: 'Vapor',++ slug: 'vapor',++ mode: 'dark',++ description: 'Vaporwave aesthetics — pink, purple, and teal.',++ tags: ['retro', 'aesthetic', 'neon'],++ variables: {++ '--bg-primary': '#0e0a14',++ '--bg-secondary': '#14101c',++ '--bg-tertiary': '#1c1628',++ '--bg-card': '#181220',++ '--bg-hover': '#241a34',++ '--bg-input': '#100c16',++ '--text-primary': '#f0e0ff',++ '--text-secondary': '#c090e0',++ '--text-muted': '#7050a0',++ '--accent': '#e040fb',++ '--accent-light': '#ea80fc',++ '--accent-dark': '#aa00d4',++ '--border': '#2a1a40',++ '--border-light': '#3a2a50',++ '--info': '#00e5ff',++ '--success': '#69f0ae',++ },++ },++ {++ name: 'Slate',++ slug: 'slate',++ mode: 'dark',++ description: 'Neutral gray with subtle blue undertones.',++ tags: ['neutral', 'minimal', 'professional'],++ variables: {++ '--bg-primary': '#0f1118',++ '--bg-secondary': '#161820',++ '--bg-tertiary': '#1e2028',++ '--bg-card': '#1a1c24',++ '--bg-hover': '#24262e',++ '--bg-input': '#12141c',++ '--text-primary': '#e4e6ea',++ '--text-secondary': '#a0a4b0',++ '--text-muted': '#606470',++ '--accent': '#a78bfa',++ '--accent-light': '#c4b5fd',++ '--accent-dark': '#7c3aed',++ '--border': '#2a2c38',++ '--border-light': '#3a3c48',++ },++ },++ {++ name: 'Matrix',++ slug: 'matrix',++ mode: 'dark',++ description: 'Green terminal text on pitch black.',++ tags: ['hacker', 'retro', 'terminal'],++ variables: {++ '--bg-primary': '#000000',++ '--bg-secondary': '#050a05',++ '--bg-tertiary': '#0a140a',++ '--bg-card': '#071007',++ '--bg-hover': '#0e1e0e',++ '--bg-input': '#030803',++ '--text-primary': '#00ff41',++ '--text-secondary': '#00b030',++ '--text-muted': '#005a18',++ '--accent': '#00ff41',++ '--accent-light': '#66ff8a',++ '--accent-dark': '#00aa2a',++ '--border': '#0a2a0a',++ '--border-light': '#144014',++ },++ },++ {++ name: 'Nord',++ slug: 'nord',++ mode: 'dark',++ description: 'Arctic, north-bluish color palette inspired by the Nord theme.',++ tags: ['popular', 'blue', 'soft'],++ variables: {++ '--bg-primary': '#2e3440',++ '--bg-secondary': '#272c36',++ '--bg-tertiary': '#3b4252',++ '--bg-card': '#333a48',++ '--bg-hover': '#434c5e',++ '--bg-input': '#2a303c',++ '--text-primary': '#eceff4',++ '--text-secondary': '#d8dee9',++ '--text-muted': '#7b88a1',++ '--accent': '#88c0d0',++ '--accent-light': '#8fbcbb',++ '--accent-dark': '#5e81ac',++ '--border': '#3b4252',++ '--border-light': '#4c566a',++ '--success': '#a3be8c',++ '--danger': '#bf616a',++ '--warning': '#ebcb8b',++ '--info': '#81a1c1',++ },++ },++ {++ name: 'Gruvbox Dark',++ slug: 'gruvbox-dark',++ mode: 'dark',++ description: 'Retro groove colors with warm, high-contrast tones.',++ tags: ['retro', 'warm', 'developer'],++ variables: {++ '--bg-primary': '#1d2021',++ '--bg-secondary': '#282828',++ '--bg-tertiary': '#3c3836',++ '--bg-card': '#32302f',++ '--bg-hover': '#504945',++ '--bg-input': '#242424',++ '--text-primary': '#ebdbb2',++ '--text-secondary': '#d5c4a1',++ '--text-muted': '#928374',++ '--accent': '#fabd2f',++ '--accent-light': '#fce566',++ '--accent-dark': '#d79921',++ '--border': '#3c3836',++ '--border-light': '#504945',++ '--success': '#b8bb26',++ '--danger': '#fb4934',++ '--warning': '#fe8019',++ '--info': '#83a598',++ },++ },++ {++ name: 'Abyss',++ slug: 'abyss',++ mode: 'dark',++ description: 'The deepest dark — nearly pure black with dim accents.',++ tags: ['minimal', 'ultra-dark', 'oled'],++ variables: {++ '--bg-primary': '#000000',++ '--bg-secondary': '#080808',++ '--bg-tertiary': '#111111',++ '--bg-card': '#0a0a0a',++ '--bg-hover': '#181818',++ '--bg-input': '#050505',++ '--text-primary': '#cccccc',++ '--text-secondary': '#888888',++ '--text-muted': '#444444',++ '--accent': '#ffffff',++ '--accent-light': '#ffffff',++ '--accent-dark': '#aaaaaa',++ '--border': '#1a1a1a',++ '--border-light': '#252525',++ },++ },++ {++ name: 'Copper',++ slug: 'copper',++ mode: 'dark',++ description: 'Rich copper and bronze on dark walnut.',++ tags: ['metallic', 'warm', 'premium'],++ variables: {++ '--bg-primary': '#110d09',++ '--bg-secondary': '#1a140e',++ '--bg-tertiary': '#231c14',++ '--bg-card': '#1e1710',++ '--bg-hover': '#2c2218',++ '--bg-input': '#13100b',++ '--text-primary': '#e8ddd0',++ '--text-secondary': '#b49a80',++ '--text-muted': '#7a6a54',++ '--accent': '#cd7f32',++ '--accent-light': '#daa060',++ '--accent-dark': '#a06020',++ '--border': '#2a2016',++ '--border-light': '#3a3020',++ },++ },++ {++ name: 'Sakura Night',++ slug: 'sakura-night',++ mode: 'dark',++ description: 'Soft pink cherry blossom on dark indigo.',++ tags: ['japanese', 'pink', 'soft'],++ variables: {++ '--bg-primary': '#0e0a14',++ '--bg-secondary': '#150f1c',++ '--bg-tertiary': '#1c1526',++ '--bg-card': '#181020',++ '--bg-hover': '#221830',++ '--bg-input': '#100c16',++ '--text-primary': '#f0e0f0',++ '--text-secondary': '#c0a0c0',++ '--text-muted': '#7a5a7a',++ '--accent': '#f472b6',++ '--accent-light': '#f9a8d4',++ '--accent-dark': '#db2777',++ '--border': '#2a1a30',++ '--border-light': '#3a2a40',++ },++ },++ {++ name: 'Hacker',++ slug: 'hacker',++ mode: 'dark',++ description: 'Amber-on-black retro terminal look.',++ tags: ['retro', 'terminal', 'nostalgia'],++ variables: {++ '--bg-primary': '#000000',++ '--bg-secondary': '#0a0800',++ '--bg-tertiary': '#141000',++ '--bg-card': '#0e0c00',++ '--bg-hover': '#1c1800',++ '--bg-input': '#060400',++ '--text-primary': '#ffb000',++ '--text-secondary': '#cc8d00',++ '--text-muted': '#664700',++ '--accent': '#ffb000',++ '--accent-light': '#ffd050',++ '--accent-dark': '#cc8d00',++ '--border': '#1a1400',++ '--border-light': '#2a2200',++ },++ },++ {++ name: 'Daylight',++ slug: 'daylight',++ mode: 'light',++ description: 'Clean, bright white with warm accents for daytime use.',++ tags: ['clean', 'bright', 'default-light'],++ variables: {++ '--bg-primary': '#ffffff',++ '--bg-secondary': '#f7f7f8',++ '--bg-tertiary': '#eeeef0',++ '--bg-card': '#ffffff',++ '--bg-hover': '#f0f0f2',++ '--bg-input': '#f5f5f7',++ '--text-primary': '#1a1a2e',++ '--text-secondary': '#4a4a6a',++ '--text-muted': '#9a9ab0',++ '--accent': '#8b5cf6',++ '--accent-light': '#a78bfa',++ '--accent-dark': '#6d28d9',++ '--border': '#e0e0e6',++ '--border-light': '#d0d0d8',++ '--shadow': '0 2px 12px rgba(0,0,0,0.08)',++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.12)',++ },++ },++ {++ name: 'Paper',++ slug: 'paper',++ mode: 'light',++ description: 'Warm off-white like aged paper with ink accents.',++ tags: ['warm', 'soft', 'minimal'],++ variables: {++ '--bg-primary': '#faf8f5',++ '--bg-secondary': '#f0ece6',++ '--bg-tertiary': '#e8e2d8',++ '--bg-card': '#faf8f5',++ '--bg-hover': '#f0ece6',++ '--bg-input': '#f5f2ed',++ '--text-primary': '#2c2416',++ '--text-secondary': '#5a4e3e',++ '--text-muted': '#9a8e7e',++ '--accent': '#8b6914',++ '--accent-light': '#b08a30',++ '--accent-dark': '#6a5010',++ '--border': '#ddd6c8',++ '--border-light': '#ccc4b0',++ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',++ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',++ },++ },++ {++ name: 'Cloud',++ slug: 'cloud',++ mode: 'light',++ description: 'Airy light blue with sky accents.',++ tags: ['blue', 'airy', 'fresh'],++ variables: {++ '--bg-primary': '#f0f5ff',++ '--bg-secondary': '#e8eef8',++ '--bg-tertiary': '#dde5f0',++ '--bg-card': '#f5f8ff',++ '--bg-hover': '#e4ecf8',++ '--bg-input': '#edf2fa',++ '--text-primary': '#1a2040',++ '--text-secondary': '#4a5070',++ '--text-muted': '#8a90aa',++ '--accent': '#3b82f6',++ '--accent-light': '#60a5fa',++ '--accent-dark': '#2563eb',++ '--border': '#d0d8ea',++ '--border-light': '#bcc8e0',++ '--shadow': '0 2px 12px rgba(0,20,60,0.06)',++ '--shadow-lg': '0 8px 32px rgba(0,20,60,0.10)',++ },++ },++ {++ name: 'Meadow',++ slug: 'meadow',++ mode: 'light',++ description: 'Fresh spring greens with natural tones.',++ tags: ['nature', 'green', 'fresh'],++ variables: {++ '--bg-primary': '#f2f8f0',++ '--bg-secondary': '#e8f0e4',++ '--bg-tertiary': '#dce8d6',++ '--bg-card': '#f5faf2',++ '--bg-hover': '#e4f0de',++ '--bg-input': '#edf5ea',++ '--text-primary': '#1a2e16',++ '--text-secondary': '#3a5a32',++ '--text-muted': '#7a9a6e',++ '--accent': '#16a34a',++ '--accent-light': '#22c55e',++ '--accent-dark': '#15803d',++ '--border': '#c8dcc0',++ '--border-light': '#b0ccaa',++ '--shadow': '0 2px 12px rgba(0,40,0,0.06)',++ '--shadow-lg': '0 8px 32px rgba(0,40,0,0.10)',++ },++ },++ {++ name: 'Peach',++ slug: 'peach',++ mode: 'light',++ description: 'Soft peachy pinks with warm coral accents.',++ tags: ['warm', 'pink', 'soft'],++ variables: {++ '--bg-primary': '#fff5f0',++ '--bg-secondary': '#fbeae2',++ '--bg-tertiary': '#f5ddd2',++ '--bg-card': '#fff7f2',++ '--bg-hover': '#fce8dc',++ '--bg-input': '#fdf0ea',++ '--text-primary': '#2e1a14',++ '--text-secondary': '#6a4438',++ '--text-muted': '#aa8070',++ '--accent': '#f97316',++ '--accent-light': '#fb923c',++ '--accent-dark': '#ea580c',++ '--border': '#f0d0c0',++ '--border-light': '#e4bfab',++ '--shadow': '0 2px 12px rgba(60,20,0,0.06)',++ '--shadow-lg': '0 8px 32px rgba(60,20,0,0.10)',++ },++ },++ {++ name: 'Lavender',++ slug: 'lavender',++ mode: 'light',++ description: 'Gentle purple hues — calming and elegant.',++ tags: ['purple', 'calm', 'elegant'],++ variables: {++ '--bg-primary': '#f8f5ff',++ '--bg-secondary': '#f0eaf8',++ '--bg-tertiary': '#e6ddf0',++ '--bg-card': '#faf7ff',++ '--bg-hover': '#eee6f8',++ '--bg-input': '#f4f0fa',++ '--text-primary': '#1e1430',++ '--text-secondary': '#4a3a60',++ '--text-muted': '#9080aa',++ '--accent': '#8b5cf6',++ '--accent-light': '#a78bfa',++ '--accent-dark': '#7c3aed',++ '--border': '#ddd0ee',++ '--border-light': '#ccc0dd',++ '--shadow': '0 2px 12px rgba(40,0,60,0.06)',++ '--shadow-lg': '0 8px 32px rgba(40,0,60,0.10)',++ },++ },++ {++ name: 'Gruvbox Light',++ slug: 'gruvbox-light',++ mode: 'light',++ description: 'Light retro groove — warm cream with bold accents.',++ tags: ['retro', 'warm', 'developer'],++ variables: {++ '--bg-primary': '#fbf1c7',++ '--bg-secondary': '#f2e5bc',++ '--bg-tertiary': '#ebdbb2',++ '--bg-card': '#fbf1c7',++ '--bg-hover': '#f2e5bc',++ '--bg-input': '#f5ecc4',++ '--text-primary': '#3c3836',++ '--text-secondary': '#504945',++ '--text-muted': '#928374',++ '--accent': '#d65d0e',++ '--accent-light': '#fe8019',++ '--accent-dark': '#af3a03',++ '--border': '#d5c4a1',++ '--border-light': '#bdae93',++ '--success': '#98971a',++ '--danger': '#cc241d',++ '--warning': '#d79921',++ '--info': '#458588',++ '--shadow': '0 2px 12px rgba(40,30,0,0.08)',++ '--shadow-lg': '0 8px 32px rgba(40,30,0,0.12)',++ },++ },++ {++ name: 'Snow',++ slug: 'snow',++ mode: 'light',++ description: 'Pure white with icy blue accents.',++ tags: ['minimal', 'clean', 'blue'],++ variables: {++ '--bg-primary': '#ffffff',++ '--bg-secondary': '#f8fafc',++ '--bg-tertiary': '#f1f5f9',++ '--bg-card': '#ffffff',++ '--bg-hover': '#f1f5f9',++ '--bg-input': '#f8fafc',++ '--text-primary': '#0f172a',++ '--text-secondary': '#334155',++ '--text-muted': '#94a3b8',++ '--accent': '#0ea5e9',++ '--accent-light': '#38bdf8',++ '--accent-dark': '#0284c7',++ '--border': '#e2e8f0',++ '--border-light': '#cbd5e1',++ '--shadow': '0 2px 12px rgba(0,0,0,0.05)',++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.08)',++ },++ },++ {++ name: 'Sand',++ slug: 'sand',++ mode: 'light',++ description: 'Desert sand and warm tan tones.',++ tags: ['warm', 'earthy', 'desert'],++ variables: {++ '--bg-primary': '#f8f4ef',++ '--bg-secondary': '#f0e8de',++ '--bg-tertiary': '#e8ddd0',++ '--bg-card': '#faf6f0',++ '--bg-hover': '#f0e8de',++ '--bg-input': '#f5f0e8',++ '--text-primary': '#2e2418',++ '--text-secondary': '#5a4e3e',++ '--text-muted': '#9a8e7e',++ '--accent': '#b8860b',++ '--accent-light': '#daa520',++ '--accent-dark': '#8b6508',++ '--border': '#ddd0be',++ '--border-light': '#ccc0aa',++ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',++ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',++ },++ },++ {++ name: 'Rose',++ slug: 'rose',++ mode: 'light',++ description: 'Delicate rose pink with warm blush tones.',++ tags: ['pink', 'romantic', 'soft'],++ variables: {++ '--bg-primary': '#fff5f7',++ '--bg-secondary': '#fce7ec',++ '--bg-tertiary': '#f5d5de',++ '--bg-card': '#fff7f9',++ '--bg-hover': '#fce0e8',++ '--bg-input': '#fdeef2',++ '--text-primary': '#2e1420',++ '--text-secondary': '#6a3850',++ '--text-muted': '#aa7090',++ '--accent': '#e11d48',++ '--accent-light': '#f43f5e',++ '--accent-dark': '#be123c',++ '--border': '#f0c8d4',++ '--border-light': '#e4b0c0',++ '--shadow': '0 2px 12px rgba(60,0,20,0.06)',++ '--shadow-lg': '0 8px 32px rgba(60,0,20,0.10)',++ },++ },++ {++ name: 'Solarized Dark',++ slug: 'solarized-dark',++ mode: 'dark',++ description: 'Ethan Schoonover\'s precision color scheme — dark variant.',++ tags: ['popular', 'developer', 'balanced'],++ variables: {++ '--bg-primary': '#002b36',++ '--bg-secondary': '#073642',++ '--bg-tertiary': '#0a3f4e',++ '--bg-card': '#053542',++ '--bg-hover': '#0d4a58',++ '--bg-input': '#01303d',++ '--text-primary': '#fdf6e3',++ '--text-secondary': '#93a1a1',++ '--text-muted': '#586e75',++ '--accent': '#b58900',++ '--accent-light': '#d4a017',++ '--accent-dark': '#8a6a00',++ '--border': '#0a4050',++ '--border-light': '#105060',++ '--success': '#859900',++ '--danger': '#dc322f',++ '--warning': '#cb4b16',++ '--info': '#268bd2',++ },++ },++ {++ name: 'Solarized Light',++ slug: 'solarized-light',++ mode: 'light',++ description: 'Ethan Schoonover\'s precision color scheme — light variant.',++ tags: ['popular', 'developer', 'balanced'],++ variables: {++ '--bg-primary': '#fdf6e3',++ '--bg-secondary': '#eee8d5',++ '--bg-tertiary': '#e4ddc6',++ '--bg-card': '#fdf6e3',++ '--bg-hover': '#eee8d5',++ '--bg-input': '#f5eedb',++ '--text-primary': '#002b36',++ '--text-secondary': '#586e75',++ '--text-muted': '#93a1a1',++ '--accent': '#b58900',++ '--accent-light': '#d4a017',++ '--accent-dark': '#8a6a00',++ '--border': '#d6ceb5',++ '--border-light': '#c8c0a5',++ '--success': '#859900',++ '--danger': '#dc322f',++ '--warning': '#cb4b16',++ '--info': '#268bd2',++ '--shadow': '0 2px 12px rgba(0,30,40,0.08)',++ '--shadow-lg': '0 8px 32px rgba(0,30,40,0.12)',++ },++ },++ {++ name: 'Catppuccin Mocha',++ slug: 'catppuccin-mocha',++ mode: 'dark',++ description: 'Soothing pastel theme for the high-spirited.',++ tags: ['popular', 'pastel', 'soft'],++ variables: {++ '--bg-primary': '#1e1e2e',++ '--bg-secondary': '#181825',++ '--bg-tertiary': '#313244',++ '--bg-card': '#1e1e2e',++ '--bg-hover': '#313244',++ '--bg-input': '#1a1a2c',++ '--text-primary': '#cdd6f4',++ '--text-secondary': '#bac2de',++ '--text-muted': '#6c7086',++ '--accent': '#cba6f7',++ '--accent-light': '#dcc0ff',++ '--accent-dark': '#b07de0',++ '--border': '#313244',++ '--border-light': '#45475a',++ '--success': '#a6e3a1',++ '--danger': '#f38ba8',++ '--warning': '#fab387',++ '--info': '#89b4fa',++ '--live-red': '#f38ba8',++ },++ },++ {++ name: 'Catppuccin Latte',++ slug: 'catppuccin-latte',++ mode: 'light',++ description: 'Soothing pastel theme — light variant.',++ tags: ['popular', 'pastel', 'soft'],++ variables: {++ '--bg-primary': '#eff1f5',++ '--bg-secondary': '#e6e9ef',++ '--bg-tertiary': '#dce0e8',++ '--bg-card': '#eff1f5',++ '--bg-hover': '#dce0e8',++ '--bg-input': '#e8ebf0',++ '--text-primary': '#4c4f69',++ '--text-secondary': '#5c5f77',++ '--text-muted': '#9ca0b0',++ '--accent': '#8839ef',++ '--accent-light': '#a05cff',++ '--accent-dark': '#7028d4',++ '--border': '#ccd0da',++ '--border-light': '#bcc0cc',++ '--success': '#40a02b',++ '--danger': '#d20f39',++ '--warning': '#fe640b',++ '--info': '#1e66f5',++ '--live-red': '#d20f39',++ '--shadow': '0 2px 12px rgba(0,0,0,0.06)',++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.10)',++ },++ },++ {++ name: 'High Contrast',++ slug: 'high-contrast',++ mode: 'dark',++ description: 'Maximum contrast for accessibility — bold yellow on black.',++ tags: ['accessibility', 'high-contrast', 'bold'],++ variables: {++ '--bg-primary': '#000000',++ '--bg-secondary': '#0a0a0a',++ '--bg-tertiary': '#1a1a1a',++ '--bg-card': '#0f0f0f',++ '--bg-hover': '#222222',++ '--bg-input': '#0a0a0a',++ '--text-primary': '#ffffff',++ '--text-secondary': '#f0f0f0',++ '--text-muted': '#aaaaaa',++ '--accent': '#ffd700',++ '--accent-light': '#ffed4a',++ '--accent-dark': '#ccac00',++ '--border': '#444444',++ '--border-light': '#666666',++ '--live-red': '#ff0000',++ '--success': '#00ff00',++ '--warning': '#ffff00',++ '--danger': '#ff0000',++ '--info': '#00ffff',++ },++ },++];++++const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({++ id: theme.slug,++ ...theme,++ tags: Array.isArray(theme.tags) ? [...theme.tags] : [],++ variables: { ...(theme.variables || {}) },++}));++++const DEFAULT_THEME = BUILTIN_THEMES.find((theme) => theme.id === 'vibe') || BUILTIN_THEMES[0];++const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });++++module.exports = {++ BUILTIN_THEMES,++ DEFAULT_THEME,++ DEFAULT_VARS,++};nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++const { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry } = require('./brand');++const { OpenVibeAuthClient } = require('./auth-client');++const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');++const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth, internalApiAuth } = require('./middleware');++const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');++const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');++const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');++++module.exports = {++ // Brand++ BRAND,++ BRAND_DEFAULTS,++ buildBrandFromRegistry,++ // Auth++ OpenVibeAuthClient,++ extractToken,++ requireOpenVibeAuth,++ optionalOpenVibeAuth,++ internalApiAuth,++ // Themes++ CSS_VARIABLES,++ DEFAULT_VARS,++ BUILTIN_THEMES,++ applyTheme,++ resolveBuiltinTheme,++ sanitizeCssValue,++ loadFromStorage,++ saveToStorage,++ syncThemeToServer,++ // Notifications++ PRIORITY,++ CATEGORY,++ NOTIFICATION_TYPES: TYPES,++ SOUNDS,++ EMAIL_ELIGIBLE_CATEGORIES,++ createNotification,++ DEFAULT_NOTIFICATION_PREFS,++ // Analytics++ AnalyticsTracker,++ classifyRequest,++ parseUserAgent,++ ANALYTICS_SCHEMA,++ BOT_USER_AGENTS,++ SUSPICIOUS_PATTERNS,++ // URL Registry Resolver++ URL_DEFINITIONS,++ normalizeValue,++ validateValue,++ resolveRegistryValues,++ formatRegistryEntry,++ resolveBrandUrls,++ // Client-side modules (browser only — require() for bundlers, <script> tag for direct use)++ // OpenVibeNotifications: require('./notification-ui'),++ // OpenVibeUserCard: require('./user-card'),++ // OpenVibeNavbar: require('./navbar'),++ // OpenVibeAccountSwitcher: require('./account-switcher'),++};nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Express Middleware++// Auth middleware for services consuming OpenVibe.Network SSO tokens.++// ═══════════════════════════════════════════════════════════════++++const { OpenVibeAuthClient } = require('./auth-client');++++/**++ * Extract token from request.++ * Priority: Authorization header → cookie → query param (WebSocket)++ */++function extractToken(req) {++ // 1. Authorization: Bearer <token>++ const authHeader = req.headers.authorization;++ if (authHeader && authHeader.startsWith('Bearer ')) {++ return authHeader.slice(7);++ }++ // 2. Cookie++ if (req.cookies?.ov_token) return req.cookies.ov_token;++ if (req.cookies?.token) return req.cookies.token; // legacy compat++ // 3. Query param (WebSocket upgrade)++ if (req.query?.token) return req.query.token;++ return null;++}++++/**++ * Create requireOpenVibeAuth middleware.++ * Verifies JWT signed by OpenVibe.Network and attaches req.user.++ *++ * @param {OpenVibeAuthClient} authClient++ * @returns {Function} Express middleware++ */++function requireOpenVibeAuth(authClient) {++ return (req, res, next) => {++ const token = extractToken(req);++ if (!token) {++ return res.status(401).json({ error: 'Authentication required' });++ }++ const user = authClient.verifyToken(token);++ if (!user) {++ return res.status(401).json({ error: 'Invalid or expired token' });++ }++ req.user = user;++ req.token = token;++ next();++ };++}++++/**++ * Create optionalOpenVibeAuth middleware.++ * Attaches req.user if a valid token is present, otherwise continues.++ *++ * @param {OpenVibeAuthClient} authClient++ * @returns {Function} Express middleware++ */++function optionalOpenVibeAuth(authClient) {++ return (req, _res, next) => {++ const token = extractToken(req);++ if (token) {++ const user = authClient.verifyToken(token);++ if (user) {++ req.user = user;++ req.token = token;++ }++ }++ next();++ };++}++++/**++ * Internal API authentication middleware.++ * Verifies X-Internal-Key header for server-to-server calls.++ *++ * @param {string} internalKey - Expected key value++ * @returns {Function} Express middleware++ */++function internalApiAuth(internalKey) {++ return (req, res, next) => {++ const provided = req.headers['x-internal-key'];++ if (!provided || provided !== internalKey) {++ return res.status(403).json({ error: 'Forbidden' });++ }++ next();++ };++}++++module.exports = {++ extractToken,++ requireOpenVibeAuth,++ optionalOpenVibeAuth,++ internalApiAuth,++};nnew file mode 100644@@@ -0,0 +1,651 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Universal Navbar++// Consistent top bar across all services with logo, navigation,++// notification bell, account switcher, and theme-aware styling.++// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };++ let _navEl = null;++++ function injectStyles() {++ if (document.getElementById('openvibe-navbar-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-navbar-styles';++ s.textContent = `++ .openvibe-navbar {++ position: sticky; top: 0; z-index: 10000;++ height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;++ background: var(--bg-secondary, #252530);++ border-bottom: 1px solid var(--border, #333340);++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ }++ .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }++ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }++ .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }++ .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }++++ .openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }++ .openvibe-navbar-links a {++ padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;++ color: var(--text-secondary, #b0b0b8); text-decoration: none;++ transition: all .15s;++ }++ .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #a78bfa); }++++ .openvibe-navbar-spacer { flex: 1; }++++ .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }++++ .openvibe-navbar-avatar {++ width: 32px; height: 32px; border-radius: 50%; cursor: pointer;++ border: 2px solid var(--border, #333340); transition: border-color .2s;++ object-fit: cover;++ }++ .openvibe-navbar-avatar:hover { border-color: var(--accent, #8b5cf6); }++++ .openvibe-navbar-login {++ padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;++ background: var(--accent, #8b5cf6); color: #fff; border: none; cursor: pointer;++ transition: background .15s; text-decoration: none; display: inline-flex; align-items: center;++ }++ .openvibe-navbar-login:hover { background: var(--accent-dark, #6d28d9); }++++ .openvibe-navbar-dropdown {++ position: absolute; top: 48px; right: 8px;++ width: 260px; background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 10px;++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));++ display: none; flex-direction: column; overflow: hidden;++ animation: openvibe-slide-down .2s ease;++ }++ .openvibe-navbar-dropdown.open { display: flex; }++ @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }++++ .openvibe-navbar-dropdown-header {++ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);++ display: flex; align-items: center; gap: 10px;++ }++ .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }++ .openvibe-navbar-dropdown-header .info { line-height: 1.3; }++ .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }++ .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }++ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #a78bfa); }++++ .openvibe-navbar-dropdown-accounts {++ padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);++ max-height: 140px; overflow-y: auto;++ }++ .openvibe-navbar-dropdown-accounts .account-item {++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;++ border-radius: 6px; cursor: pointer; font-size: 12px;++ color: var(--text-secondary, #b0b0b8); transition: background .12s;++ }++ .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }++ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #a78bfa); font-weight: 600; }++ .openvibe-navbar-dropdown-accounts .add-account {++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;++ border-radius: 6px; cursor: pointer; font-size: 12px;++ color: var(--text-muted, #707080); transition: background .12s;++ text-decoration: none;++ }++ .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++++ .openvibe-navbar-dropdown-menu { padding: 6px 8px; }++ .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {++ display: flex; align-items: center; gap: 8px; width: 100%;++ padding: 8px; border-radius: 6px; font-size: 12px; font-weight: 500;++ background: none; border: none; color: var(--text-primary, #e0e0e0);++ cursor: pointer; text-align: left; text-decoration: none;++ transition: background .12s;++ }++ .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-menu .danger { color: var(--live-red, #e74c3c); }++ .openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }++++ .openvibe-navbar .openvibe-network-badge {++ font-size: 10px; padding: 2px 8px; border-radius: 4px;++ background: rgba(139,92,246,0.1); color: var(--accent-light, #a78bfa);++ font-weight: 500; cursor: pointer; border: 1px solid transparent;++ transition: all .15s;++ }++ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #6d28d9); }++++ @media (max-width: 600px) {++ .openvibe-navbar-links { display: none; }++ .openvibe-navbar .openvibe-network-badge { display: none; }++ }++ `;++ document.head.appendChild(s);++ }++++ // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)++ const SERVICE_NAMES = {++ live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',++ media: 'OpenVibe.Media', network: 'OpenVibe.Network',++ net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',++ maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',++ yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',++ logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',++ };++++ const SERVICE_ICONS = {++ live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',++ media: 'fa-photo-film', network: 'fa-circle-nodes',++ net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',++ maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',++ yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',++ logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',++ };++++ // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)++ const SUBDOMAIN_BRANDS = {++ 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },++ 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },++ 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },++ 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },++ 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },++ 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },++ 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },++ 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },++ 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },++ 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },++ 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },++ 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },++ 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },++ 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },++ 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },++ 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },++ 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },++ 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },++ 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },++ 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },++ // Audio tool subdomains++ 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },++ 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },++ 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },++ 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },++ 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },++ 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },++ 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },++ 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },++ 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },++ 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },++ 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },++ 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },++ 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },++ 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },++ 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },++ 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },++ 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },++ 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },++ 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },++ 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },++ 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },++ 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },++ 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },++ // Text tool subdomains++ 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },++ 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },++ 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },++ 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },++ 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },++ 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },++ 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },++ 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },++ 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },++ 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },++ 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },++ 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },++ 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },++ 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },++ 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },++ 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },++ 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },++ 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },++ 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },++ 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },++ 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },++ 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },++ 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },++ 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },++ 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },++ 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },++ 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },++ 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },++ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },++ 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },++ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },++ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },++ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },++ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },++ 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },++ 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },++ 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },++ 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },++ 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },++ 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },++ 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },++ 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },++ 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },++ 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },++ // Logo / design subdomains++ 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },++ 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },++ 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },++ 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },++ 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },++ 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },++ 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },++ 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },++ 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },++ 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },++ 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },++ 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },++ // Document / PDF subdomains++ 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },++ 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },++ 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },++ 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },++ 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },++ 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },++ 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },++ 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },++ 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },++ 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },++ 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },++ 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },++ 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },++ 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },++ 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },++ // Network tool subdomains++ 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },++ 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },++ 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },++ 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },++ 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },++ 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },++ 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },++ 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },++ 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },++ 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },++ 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },++ 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },++ 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },++ 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },++ 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },++ 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },++ 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },++ 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },++ 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },++ 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },++ 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },++ 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },++ 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },++ 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },++ 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },++ 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },++ 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },++ 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },++ 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },++ 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },++ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },++ 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },++ 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },++ // Dev.OpenVibe subdomains++ 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },++ 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },++ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },++ 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },++ 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },++ 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },++ 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },++ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },++ 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },++ 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },++ 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },++ 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },++ 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },++ 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },++ 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },++ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },++ 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },++ 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },++ 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },++ 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },++ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },++ 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },++ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },++ 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },++ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },++ 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },++ 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },++ 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },++ // Dev.OpenVibe aliases++ 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },++ 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },++ 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },++ 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },++ 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },++ 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },++ 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },++ 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },++ 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },++ 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },++ 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },++ 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },++ 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },++ 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },++ 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },++ };++++ const SERVICE_LINKS = {++ live: [++ { label: 'Watch', href: '/' },++ { label: 'Chat', href: '/chat' },++ { label: 'VODs', href: '/vods' },++ { label: 'Game', href: '/game' },++ ],++ tools: [++ { label: 'Home', href: '/' },++ ],++ games: [++ { label: 'Play', href: '/game' },++ { label: 'Canvas', href: '/canvas' },++ { label: 'Leaderboard', href: '/leaderboard' },++ ],++ media: [++ { label: 'Home', href: '/' },++ ],++ network: [++ { label: 'Home', href: '/' },++ { label: 'My Account', href: '/my' },++ { label: 'Themes', href: '/themes' },++ ],++ maps: [++ { label: 'Map', href: '/' },++ { label: 'Camps', href: '/camps' },++ ],++ food: [++ { label: 'Food Banks', href: '/' },++ { label: 'Meal Plan', href: '/#meal-plan' },++ ],++ img: [++ { label: 'Convert', href: 'https://convert.openvibe.tools' },++ { label: 'Compress', href: 'https://compress.openvibe.tools' },++ { label: 'Resize', href: 'https://resize.openvibe.tools' },++ { label: 'Crop', href: 'https://crop.openvibe.tools' },++ ],++ yt: [++ { label: 'Download', href: '/' },++ ],++ audio: [++ { label: 'Convert', href: 'https://audio.openvibe.tools' },++ { label: 'Trim', href: 'https://trim.openvibe.tools' },++ { label: 'Pitch', href: 'https://pitch.openvibe.tools' },++ { label: 'Reverb', href: 'https://reverb.openvibe.tools' },++ ],++ text: [++ { label: 'Fancy', href: 'https://fancy.openvibe.tools' },++ { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },++ { label: 'ASCII', href: 'https://ascii.openvibe.tools' },++ { label: 'Symbols', href: 'https://symbols.openvibe.tools' },++ ],++ logo: [++ { label: 'Title', href: 'https://title.openvibe.tools' },++ { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },++ { label: 'Badge', href: 'https://badge.openvibe.tools' },++ { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },++ ],++ docs: [++ { label: 'Merge', href: 'https://mergepdf.openvibe.tools' },++ { label: 'Split', href: 'https://splitpdf.openvibe.tools' },++ { label: 'Compress', href: 'https://compresspdf.openvibe.tools' },++ { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' },++ ],++ net: [++ { label: 'Lookup', href: 'https://lookup.openvibe.tools' },++ { label: 'My IP', href: 'https://myip.openvibe.tools' },++ { label: 'DNS', href: 'https://dns.openvibe.tools' },++ { label: 'Ping', href: 'https://ping.openvibe.tools' },++ { label: 'SSL', href: 'https://ssl.openvibe.tools' },++ ],++ dev: [++ { label: 'JSON', href: 'https://json.openvibe.tools' },++ { label: 'Base64', href: 'https://base64.openvibe.tools' },++ { label: 'JWT', href: 'https://jwt.openvibe.tools' },++ { label: 'Regex', href: 'https://regex.openvibe.tools' },++ { label: 'Diff', href: 'https://diff.openvibe.tools' },++ ],++ };++++ function getAccounts() {++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }++ }++++ function escapeAttr(value) {++ return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');++ }++++ function getAvatarInitial(user) {++ const source = user?.display_name || user?.username || 'O';++ return String(source).trim().charAt(0).toUpperCase() || 'O';++ }++++ function makeAvatarPlaceholder(user, size = 64) {++ const initial = getAvatarInitial(user);++ const bg = user?.profile_color || '#8b5cf6';++ const svg = `++ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">++ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>++ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>++ </svg>`;++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;++ }++++ function avatarSrc(user, size = 64) {++ return user?.avatar_url || makeAvatarPlaceholder(user, size);++ }++++ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {++ const fallback = makeAvatarPlaceholder(user, size);++ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';++ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;++ }++++ function attachAvatarFallbacks(rootEl) {++ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {++ img.addEventListener('error', () => {++ const fallback = img.dataset.fallbackSrc;++ if (fallback && img.src !== fallback) {++ img.src = fallback;++ }++ }, { once: true });++ });++ }++++ function render() {++ if (_navEl) _navEl.remove();++++ const nav = document.createElement('nav');++ nav.className = 'openvibe-navbar';++ const svc = _config.service;++ const links = SERVICE_LINKS[svc] || [];++++ // Resolve brand name + icon: config override > subdomain lookup > service defaults++ const host = (typeof location !== 'undefined' && location.hostname) || '';++ // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name++ let subBrand = SUBDOMAIN_BRANDS[host];++ if (!subBrand && host && host.includes('.openvibe.tools')) {++ // Try matching against service names++ const parts = host.split('.');++ const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'++ subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];++ }++ const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';++ const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';++++ const brandHref = '/';++++ const u = _config.user;++ const accounts = getAccounts();++ const isAnon = u && u.is_anon;++ const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;++ const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++++ nav.innerHTML = `++ <a class="openvibe-navbar-brand" href="${brandHref}">++ <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>++ <div>++ <div class="name">${svcName}</div>++ </div>++ </a>++ <div class="openvibe-navbar-links">++ ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}++ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}++ </div>++ <div class="openvibe-navbar-spacer"></div>++ <div class="openvibe-navbar-right">++ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>++ <div id="openvibe-bell-mount"></div>++ ${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :++ `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}++ </div>++ `;++++ // Dropdown++ if (u) {++ const dropdown = document.createElement('div');++ dropdown.className = 'openvibe-navbar-dropdown';++ dropdown.id = 'openvibe-user-dropdown';++++ const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++++ dropdown.innerHTML = `++ <div class="openvibe-navbar-dropdown-header">++ ${avatarImg(u, 72, '', '')}++ <div class="info">++ <div class="name">${u.display_name || u.username}</div>++ <div class="email">${u.email || `@${u.username}`}</div>++ ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}++ </div>++ </div>++ <div class="openvibe-navbar-dropdown-accounts">++ ${otherAccounts.map(a => `++ <div class="account-item" data-account-id="${a.id}">++ ${avatarImg(a, 48, '', '')}++ <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>++ </div>++ `).join('')}++ <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">++ <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>++ <span>Switch to Anonymous</span>++ </div>++ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">++ <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>++ <span>Add another account</span>++ </a>++ </div>++ <div class="openvibe-navbar-dropdown-menu">++ <a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>++ <a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>++ <a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}++ <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>++ <button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>++ </div>++ `;++ nav.appendChild(dropdown);++++ // Avatar click toggles dropdown++ nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {++ dropdown.classList.toggle('open');++ });++++ // Close on outside click++ document.addEventListener('click', e => {++ if (!nav.contains(e.target)) dropdown.classList.remove('open');++ });++++ // Account switching++ dropdown.querySelectorAll('[data-account-id]').forEach(el => {++ el.addEventListener('click', () => {++ const id = el.dataset.accountId;++ document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));++ dropdown.classList.remove('open');++ });++ });++++ dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {++ dropdown.classList.remove('open');++ if (_config.onLogout) _config.onLogout();++ else {++ document.cookie = 'ov_token=;path=/;max-age=0';++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';++ localStorage.removeItem('ov_token');++ localStorage.removeItem('openvibe_anon_token');++ localStorage.removeItem('openvibe_active_account');++ window.location.reload();++ }++ });++ } else {++ nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {++ if (!_config.onLogin) return;++ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;++ event.preventDefault();++ _config.onLogin();++ });++ }++++ // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body++ const mount = document.getElementById('navbar-mount');++ if (mount) {++ mount.appendChild(nav);++ } else {++ document.body.prepend(nav);++ }++ _navEl = nav;++ attachAvatarFallbacks(nav);++ return nav;++ }++++ const OpenVibeNavbar = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ return render();++ },++++ /** Update user (after account switch). */++ setUser(user) {++ _config.user = user;++ render();++ },++++ setToken(token) { _config.token = token; },++++ /** Get the bell mount point for OpenVibeNotifications. */++ getBellMount() {++ return _navEl?.querySelector('#openvibe-bell-mount') || null;++ },++++ getElement() { return _navEl; },++++ destroy() {++ _navEl?.remove();++ _navEl = null;++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;++ else root.OpenVibeNavbar = OpenVibeNavbar;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,539 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Notification UI Client++// Drop-in vanilla JS library for toast notifications, bell badge,++// notification panel, sounds, and real-time polling.++// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>++// OpenVibeNotifications.init({ token, apiBase, onAction })++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const POLL_INTERVAL = 15_000; // 15s for unread count++ const TOAST_DURATION = { low: 0, normal: 3800, high: 6000, critical: 0 }; // 0 = sticky++ const MAX_TOASTS = 3;++ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };++++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', onAction: null };++ let _pollTimer = null;++ let _lastCheck = null;++ let _unreadCount = 0;++ let _toastContainer = null;++ let _panelEl = null;++ let _bellEl = null;++ let _audioCache = {};++ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };++++ // ── Styles (injected once) ───────────────────────────────++ function injectStyles() {++ if (document.getElementById('openvibe-notif-styles')) return;++ const style = document.createElement('style');++ style.id = 'openvibe-notif-styles';++ style.textContent = `++ @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }++ @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }++ @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }++ @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }++ @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }++ @keyframes openvibe-fade-in { from { opacity: 0; } to { opacity: 1; } }++++ .openvibe-toast-container {++ position: fixed; top: 82px; right: 14px; z-index: 100000;++ display: flex; flex-direction: column; gap: 6px;++ pointer-events: none; max-width: 320px; width: 100%;++ }++ .openvibe-toast {++ pointer-events: all;++ background: color-mix(in srgb, var(--bg-card, #22222c) 92%, transparent);++ border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent);++ border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start;++ box-shadow: 0 10px 24px rgba(0,0,0,0.28);++ animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1);++ cursor: pointer; position: relative; overflow: hidden;++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ transition: border-color .2s;++ }++ .openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }++ .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }++ .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }++ .openvibe-toast-body { flex: 1; min-width: 0; }++ .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }++ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: rgba(192,150,92,0.15); color: var(--accent-light, #a78bfa); font-weight: 500; }++ .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }++ .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }++ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); border-radius: 0 0 0 10px; }++ .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }++ .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }++ .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }++ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; transition: all .15s; }++ .openvibe-toast-actions button:hover { background: var(--accent-dark, #a07840); color: #fff; border-color: var(--accent-dark, #a07840); }++ .openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }++ .openvibe-toast-user { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }++ .openvibe-toast-user img { width: 24px; height: 24px; border-radius: 50%; }++ .openvibe-toast-user .username { font-weight: 600; font-size: 12px; cursor: pointer; }++++ /* ── Bell ─────────────────────────────────────── */++ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }++ .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }++ .openvibe-bell:hover svg { fill: var(--accent-light, #a78bfa); }++ .openvibe-bell .badge {++ position: absolute; top: 0; right: 0;++ min-width: 16px; height: 16px; padding: 0 4px;++ background: var(--live-red, #e74c3c); color: #fff;++ border-radius: 8px; font-size: 10px; font-weight: 700;++ display: flex; align-items: center; justify-content: center;++ line-height: 1; pointer-events: none;++ animation: openvibe-badge-pulse .4s ease;++ }++ .openvibe-bell .badge.hidden { display: none; }++ .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }++++ /* ── Panel ────────────────────────────────────── */++ .openvibe-notif-panel {++ position: fixed; top: 62px; right: 16px;++ width: 380px; max-height: calc(100vh - 86px);++ background: var(--bg-secondary, #252530);++ border: 1px solid var(--border, #333340);++ border-radius: 12px;++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));++ z-index: 99999; display: none; flex-direction: column;++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ animation: openvibe-slide-down .2s ease;++ }++ .openvibe-notif-panel.open { display: flex; }++ .openvibe-notif-panel-header {++ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);++ display: flex; align-items: center; justify-content: space-between;++ }++ .openvibe-notif-panel-header h3 { font-size: 15px; font-weight: 600; margin: 0; }++ .openvibe-notif-panel-header .actions { display: flex; gap: 8px; }++ .openvibe-notif-panel-header .actions button { background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all .15s; }++ .openvibe-notif-panel-header .actions button:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-notif-panel-tabs { display: flex; border-bottom: 1px solid var(--border, #333340); }++ .openvibe-notif-panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #707080); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }++ .openvibe-notif-panel-tabs button.active { color: var(--accent-light, #a78bfa); border-bottom-color: var(--accent, #8b5cf6); }++ .openvibe-notif-panel-list { flex: 1; overflow-y: auto; padding: 4px 0; }++ .openvibe-notif-item {++ padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start;++ cursor: pointer; transition: background .15s; border-left: 3px solid transparent;++ }++ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-notif-item.unread { background: rgba(192,150,92,0.04); border-left-color: var(--accent, #8b5cf6); }++ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }++ .openvibe-notif-item .content { flex: 1; min-width: 0; }++ .openvibe-notif-item .content .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }++ .openvibe-notif-item .content .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-notif-item .content .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; }++ .openvibe-notif-item .content .meta .service-tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }++ .openvibe-notif-panel-empty { text-align: center; padding: 48px 24px; color: var(--text-muted, #707080); }++ .openvibe-notif-panel-empty .icon { font-size: 36px; margin-bottom: 8px; display: block; opacity: .5; }++++ @media (max-width: 440px) {++ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }++ .openvibe-notif-panel { right: 8px; left: 8px; width: auto; }++ }++ `;++ document.head.appendChild(style);++ }++++ // ── Sound ────────────────────────────────────────────────++ function playSound(priority) {++ if (!_preferences.sound) return;++ const file = SOUNDS[priority];++ if (!file) return;++ const url = `${_config.soundBase}/${file}`;++ try {++ if (!_audioCache[url]) _audioCache[url] = new Audio(url);++ const audio = _audioCache[url];++ audio.currentTime = 0;++ audio.volume = priority === 'critical' ? 0.8 : 0.5;++ audio.play().catch(() => {});++ } catch {}++ }++++ // ── Toast Container ──────────────────────────────────────++ function ensureToastContainer() {++ if (_toastContainer) return;++ _toastContainer = document.createElement('div');++ _toastContainer.className = 'openvibe-toast-container';++ document.body.appendChild(_toastContainer);++ }++++ function showToast(notification) {++ if (!_preferences.toasts) return;++ if (_preferences.muted_categories.includes(notification.category)) return;++ ensureToastContainer();++++ // Limit visible toasts++ while (_toastContainer.children.length >= MAX_TOASTS) {++ const oldest = _toastContainer.firstChild;++ oldest.remove();++ }++++ const toast = document.createElement('div');++ toast.className = `openvibe-toast priority-${notification.priority}`;++ toast.dataset.notifId = notification.id;++++ const rich = notification.richContent || {};++ let userHtml = '';++ if (rich.user) {++ const u = rich.user;++ const nameStyle = u.profile_color ? `color:${u.profile_color}` : '';++ const nameClass = u.name_effect ? `openvibe-name-fx-${u.name_effect}` : '';++ userHtml = `<div class="openvibe-toast-user">++ ${u.avatar_url ? `<img src="${u.avatar_url}" alt="">` : ''}++ <span class="username ${nameClass}" style="${nameStyle}">${u.display_name || u.username}</span>++ </div>`;++ }++++ let actionsHtml = '';++ if (rich.actions && rich.actions.length) {++ actionsHtml = '<div class="openvibe-toast-actions">' +++ rich.actions.filter(a => a.type !== 'input').map(a =>++ `<button data-action="${a.id}" class="${a.style === 'primary' ? 'primary' : ''}">${a.label}</button>`++ ).join('') + '</div>';++ }++++ const serviceLabel = notification.service && notification.service !== 'network'++ ? `<span class="service-badge">${notification.service}</span>` : '';++++ toast.innerHTML = `++ <div class="openvibe-toast-icon">${notification.icon || '🔔'}</div>++ <div class="openvibe-toast-body">++ ${userHtml}++ <div class="openvibe-toast-title">${notification.title}${serviceLabel}</div>++ <div class="openvibe-toast-msg">${notification.message}</div>++ ${actionsHtml}++ </div>++ <button class="openvibe-toast-close">×</button>++ `;++++ // Progress bar for auto-dismiss++ const duration = TOAST_DURATION[notification.priority] || 5000;++ if (duration > 0) {++ const bar = document.createElement('div');++ bar.className = 'openvibe-toast-progress';++ bar.style.width = '100%';++ bar.style.transition = `width ${duration}ms linear`;++ toast.appendChild(bar);++ requestAnimationFrame(() => { bar.style.width = '0%'; });++ }++++ // Events++ toast.querySelector('.openvibe-toast-close').addEventListener('click', e => {++ e.stopPropagation();++ dismissToast(toast);++ });++++ toast.addEventListener('click', () => {++ if (rich.url) window.open(rich.url, '_blank');++ markRead(notification.id);++ dismissToast(toast);++ });++++ // Action buttons++ toast.querySelectorAll('[data-action]').forEach(btn => {++ btn.addEventListener('click', e => {++ e.stopPropagation();++ const actionId = btn.dataset.action;++ const action = rich.actions.find(a => a.id === actionId);++ if (action?.url) { window.open(action.url, '_blank'); return; }++ if (_config.onAction) _config.onAction(notification.id, actionId, notification);++ dismissToast(toast);++ });++ });++++ _toastContainer.appendChild(toast);++++ // Auto dismiss++ if (duration > 0) {++ setTimeout(() => dismissToast(toast), duration);++ }++ }++++ function dismissToast(el) {++ if (!el || !el.parentNode) return;++ el.classList.add('removing');++ setTimeout(() => el.remove(), 300);++ }++++ // ── Bell / Badge ─────────────────────────────────────────++ function createBell() {++ const bell = document.createElement('div');++ bell.className = 'openvibe-bell';++ bell.innerHTML = `++ <svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>++ <span class="badge hidden">0</span>++ `;++ bell.addEventListener('click', () => togglePanel());++ _bellEl = bell;++ return bell;++ }++++ function updateBadge(count) {++ _unreadCount = count;++ if (!_bellEl) return;++ const badge = _bellEl.querySelector('.badge');++ if (count > 0) {++ badge.textContent = count > 99 ? '99+' : count;++ badge.classList.remove('hidden');++ _bellEl.classList.add('ringing');++ setTimeout(() => _bellEl.classList.remove('ringing'), 600);++ } else {++ badge.classList.add('hidden');++ }++ }++++ // ── Panel ────────────────────────────────────────────────++ function createPanel() {++ const panel = document.createElement('div');++ panel.className = 'openvibe-notif-panel';++ panel.innerHTML = `++ <div class="openvibe-notif-panel-header">++ <h3>Notifications</h3>++ <div class="actions">++ <button data-action="mark-all-read">Mark all read</button>++ <button data-action="settings">⚙️</button>++ </div>++ </div>++ <div class="openvibe-notif-panel-tabs">++ <button class="active" data-tab="all">All</button>++ <button data-tab="unread">Unread</button>++ <button data-tab="social">Social</button>++ <button data-tab="game">Game</button>++ <button data-tab="system">System</button>++ </div>++ <div class="openvibe-notif-panel-list"></div>++ `;++++ panel.querySelector('[data-action="mark-all-read"]').addEventListener('click', markAllRead);++ panel.querySelectorAll('[data-tab]').forEach(btn => {++ btn.addEventListener('click', () => {++ panel.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));++ btn.classList.add('active');++ loadNotifications(btn.dataset.tab);++ });++ });++++ document.addEventListener('click', e => {++ if (_panelEl?.classList.contains('open') && !_panelEl.contains(e.target) && !_bellEl?.contains(e.target)) {++ _panelEl.classList.remove('open');++ }++ });++++ _panelEl = panel;++ document.body.appendChild(panel);++ return panel;++ }++++ function togglePanel() {++ if (!_panelEl) createPanel();++ const opening = !_panelEl.classList.contains('open');++ _panelEl.classList.toggle('open');++ if (opening) loadNotifications('all');++ }++++ async function loadNotifications(tab = 'all') {++ if (!_panelEl) return;++ const list = _panelEl.querySelector('.openvibe-notif-panel-list');++ list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-muted)">Loading...</div>';++++ try {++ const params = new URLSearchParams({ limit: '50' });++ if (tab === 'unread') params.set('unread', '1');++ else if (tab !== 'all') params.set('category', tab);++++ const res = await apiFetch(`/api/notifications?${params}`);++ const data = await res.json();++++ if (!data.notifications || data.notifications.length === 0) {++ list.innerHTML = `<div class="openvibe-notif-panel-empty"><span class="icon">🔔</span>No notifications yet</div>`;++ return;++ }++++ list.innerHTML = data.notifications.map(n => {++ // Render each item in isolation — one malformed notification must++ // never blank the whole dropdown ("Failed to load").++ try {++ const ago = timeAgo(n.created_at);++ const serviceTag = n.service && n.service !== 'network'++ ? `<span class="service-tag">${esc(n.service)}</span>` : '';++ return `<div class="openvibe-notif-item ${n.read ? '' : 'unread'}" data-id="${esc(n.id)}">++ <span class="icon">${esc(n.icon || '🔔')}</span>++ <div class="content">++ <div class="title">${esc(n.title)}</div>++ <div class="msg">${esc(n.message)}</div>++ <div class="meta"><span>${esc(ago)}</span>${serviceTag}</div>++ </div>++ </div>`;++ } catch { return ''; }++ }).join('');++++ list.querySelectorAll('.openvibe-notif-item').forEach(item => {++ item.addEventListener('click', () => {++ const id = item.dataset.id;++ markRead(id);++ item.classList.remove('unread');++ const notif = data.notifications.find(n => String(n.id) === id);++ const rich = richOf(notif);++ if (rich.url) window.open(rich.url, '_blank');++ });++ });++ } catch {++ list.innerHTML = '<div class="openvibe-notif-panel-empty"><span class="icon">⚠️</span>Failed to load</div>';++ }++ }++++ // ── API Helpers ──────────────────────────────────────────++ function isCrossOrigin() {++ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }++ }++++ function apiFetch(path, opts = {}) {++ const headers = { ...opts.headers };++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ const crossOrigin = isCrossOrigin();++ return fetch(`${_config.apiBase}${path}`, {++ ...opts,++ headers,++ // Only send credentials for same-origin requests; cross-origin uses Authorization header++ credentials: crossOrigin ? 'omit' : 'include',++ });++ }++++ async function markRead(id) {++ try { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch {}++ _unreadCount = Math.max(0, _unreadCount - 1);++ updateBadge(_unreadCount);++ }++++ async function markAllRead() {++ try { await apiFetch('/api/notifications/read-all', { method: 'POST' }); } catch {}++ updateBadge(0);++ if (_panelEl?.classList.contains('open')) {++ _panelEl.querySelectorAll('.openvibe-notif-item.unread').forEach(el => el.classList.remove('unread'));++ }++ }++++ async function pollUnread() {++ try {++ const res = await apiFetch('/api/notifications/unread-count');++ const data = await res.json();++ const prev = _unreadCount;++ updateBadge(data.count || 0);++++ // If new notifications arrived, fetch and toast the latest++ if (data.count > prev && _preferences.toasts) {++ const newest = await apiFetch(`/api/notifications?unread=1&limit=${data.count - prev}&since=${_lastCheck || ''}`);++ const nd = await newest.json();++ if (nd.notifications) {++ for (const n of nd.notifications.slice(0, 3)) {++ const rich = n.rich_content ? JSON.parse(n.rich_content) : {};++ showToast({ ...n, richContent: rich });++ playSound(n.priority);++ }++ }++ }++ _lastCheck = new Date().toISOString();++ } catch {}++ }++++ // ── Helpers ──────────────────────────────────────────────++ // Escape untrusted text before inserting into innerHTML. Notification++ // titles/messages can be attacker-controlled (e.g. abusive broadcasts), so++ // they must never be treated as HTML.++ function esc(v) {++ if (v == null) return '';++ return String(v)++ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')++ .replace(/"/g, '"').replace(/'/g, ''');++ }++ // rich_content arrives already parsed from the API, but tolerate a raw JSON++ // string too. Never throw — a bad value just yields {}.++ function richOf(n) {++ const rc = n && n.rich_content;++ if (!rc) return {};++ if (typeof rc === 'object') return rc;++ try { return JSON.parse(rc) || {}; } catch { return {}; }++ }++++ // ── Time Formatting ──────────────────────────────────────++ function timeAgo(dateStr) {++ const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;++ if (diff < 60) return 'just now';++ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;++ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;++ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;++ return new Date(dateStr).toLocaleDateString();++ }++++ // ── Public API ───────────────────────────────────────────++ const OpenVibeNotifications = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ if (opts.preferences) Object.assign(_preferences, opts.preferences);++ _lastCheck = new Date().toISOString();++++ // Start polling++ pollUnread();++ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);++ },++++ destroy() {++ clearInterval(_pollTimer);++ _toastContainer?.remove();++ _panelEl?.remove();++ _toastContainer = null;++ _panelEl = null;++ },++++ /** Create and inject the bell icon. Returns the DOM element. */++ createBell(container) {++ const bell = createBell();++ if (container) container.appendChild(bell);++ pollUnread();++ return bell;++ },++++ /** Programmatically push a toast (for real-time WebSocket events). */++ push(notification) {++ showToast(notification);++ playSound(notification.priority);++ _unreadCount++;++ updateBadge(_unreadCount);++ },++++ /** Update preferences at runtime. */++ setPreferences(prefs) {++ Object.assign(_preferences, prefs);++ },++++ /** Update auth token (e.g., after account switch). */++ setToken(token) {++ _config.token = token;++ if (token) {++ pollUnread();++ if (!_pollTimer) _pollTimer = setInterval(pollUnread, POLL_INTERVAL);++ } else {++ clearInterval(_pollTimer);++ _pollTimer = null;++ updateBadge(0);++ }++ },++++ /** Open/close the notification panel. */++ togglePanel,++++ /** Get current unread count. */++ get unreadCount() { return _unreadCount; },++ };++++ // Export++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;++ else root.OpenVibeNotifications = OpenVibeNotifications;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Universal Notification System++// Types, priorities, categories, and helpers shared across all++// services. Both server-side (Node) and client-side (browser).++// ═══════════════════════════════════════════════════════════════++++// ── Priority Levels ──────────────────────────────────────────++// Determines toast style, sound, persistence, and email eligibility.++const PRIORITY = Object.freeze({++ LOW: 'low', // Silent, no toast, inbox only++ NORMAL: 'normal', // Toast + badge, default sound++ HIGH: 'high', // Sticky toast, alert sound, badge pulse++ CRITICAL: 'critical', // Sticky + email-eligible, alarm sound, cannot dismiss silently++});++++// ── Categories ───────────────────────────────────────────────++// Users can configure per-category mute/sound/email preferences.++const CATEGORY = Object.freeze({++ SOCIAL: 'social', // Follows, friend requests, mentions, kisses++ CHAT: 'chat', // DMs, @mentions, whispers++ GAME: 'game', // Level-ups, loot, dungeon invites, canvas events++ STREAM: 'stream', // Stream went live, raid, host, clip created++ ECONOMY: 'economy', // Coins received, shop sales, trade offers++ ACHIEVEMENT: 'achievement', // Badges, milestones, unlocks++ MODERATION: 'moderation', // Warnings, bans, mutes, reports++ SYSTEM: 'system', // Password changed, new login, security alerts++ SERVICE: 'service', // Cross-service: linked accounts, SSO events++ ADMIN: 'admin', // Admin broadcasts, maintenance notices++});++++// ── Notification Types (expandable per-service) ──────────────++// Each type maps to a category + default priority + template.++const TYPES = Object.freeze({++ // Social++ FOLLOW: { category: 'social', priority: 'normal', icon: '👤', title: 'New Follower' },++ MENTION: { category: 'social', priority: 'normal', icon: '@', title: 'Mentioned You' },++ FRIEND_REQUEST: { category: 'social', priority: 'normal', icon: '🤝', title: 'Friend Request' },++++ // Chat++ DIRECT_MESSAGE: { category: 'chat', priority: 'normal', icon: '💬', title: 'New Message' },++ WHISPER: { category: 'chat', priority: 'normal', icon: '🔇', title: 'Whisper' },++++ // Game++ LEVEL_UP: { category: 'game', priority: 'normal', icon: '⬆️', title: 'Level Up!' },++ LOOT_DROP: { category: 'game', priority: 'normal', icon: '🎁', title: 'Loot Drop' },++ DUNGEON_INVITE: { category: 'game', priority: 'high', icon: '🏰', title: 'Dungeon Invite' },++ BATTLE_CHALLENGE: { category: 'game', priority: 'high', icon: '⚔️', title: 'Battle Challenge' },++ CANVAS_OVERWRITE: { category: 'game', priority: 'low', icon: '🎨', title: 'Pixel Overwritten' },++ ACHIEVEMENT_UNLOCK: { category: 'achievement', priority: 'normal', icon: '🏆', title: 'Achievement Unlocked' },++++ // Stream++ STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },++ RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },++ CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },++++ // Economy++ COINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },++ TRADE_OFFER: { category: 'economy', priority: 'normal', icon: '🤝', title: 'Trade Offer' },++++ // Moderation++ WARNING: { category: 'moderation', priority: 'high', icon: '⚠️', title: 'Warning' },++ TIMEOUT: { category: 'moderation', priority: 'high', icon: '⏰', title: 'Timeout' },++ BAN: { category: 'moderation', priority: 'critical', icon: '🚫', title: 'Account Banned' },++++ // System / Security++ PASSWORD_CHANGED: { category: 'system', priority: 'critical', icon: '🔒', title: 'Password Changed' },++ NEW_LOGIN: { category: 'system', priority: 'high', icon: '🔐', title: 'New Login Detected' },++ EMAIL_VERIFIED: { category: 'system', priority: 'normal', icon: '✅', title: 'Email Verified' },++ ACCOUNT_LINKED: { category: 'system', priority: 'normal', icon: '🔗', title: 'Account Linked' },++++ // Service / Cross-platform++ WELCOME: { category: 'service', priority: 'normal', icon: '🔥', title: 'Welcome to OpenVibe' },++ SERVICE_ANNOUNCEMENT:{ category: 'admin', priority: 'high', icon: '📢', title: 'Announcement' },++ MAINTENANCE: { category: 'admin', priority: 'critical', icon: '🛠️', title: 'Scheduled Maintenance' },++});++++// ── Sound Map ────────────────────────────────────────────────++// Priority → sound file (relative to /assets/sounds/ on each service).++const SOUNDS = Object.freeze({++ low: null,++ normal: 'notification.mp3',++ high: 'notification-high.mp3',++ critical: 'notification-alarm.mp3',++});++++// ── Email Eligibility ────────────────────────────────────────++// Categories that are email-worthy by default when the user has not++// explicitly enabled email for a category-specific preference.++const EMAIL_ELIGIBLE_CATEGORIES = new Set([++ 'system', 'moderation', 'admin',++]);++++function isEmailEligible(notification) {++ return notification.priority === PRIORITY.CRITICAL++ && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);++}++++// ── Rich Content Schema ──────────────────────────────────────++// Notifications can carry optional rich payloads.++//++// richContent: {++// image?: string, // URL to preview image++// thumbnail?: string, // Small avatar/icon URL++// body?: string, // Markdown-safe extended body++// url?: string, // Click-through URL++// service?: string, // Origin service ID++// actions?: [ // Interactive buttons/inputs++// { id: string, label: string, style: 'primary'|'secondary'|'danger', url?: string },++// { id: string, type: 'input', placeholder: string, submitLabel: string },++// ],++// user?: { // Linked user display info++// id: number, username: string, display_name: string,++// avatar_url: string, profile_color: string, role: string,++// name_effect?: string, particle_effect?: string,++// },++// context?: object, // Arbitrary service-specific data++// }++++// ── Notification Object Shape ────────────────────────────────++// {++// id: string, // UUID++// type: string, // Key from TYPES++// category: string, // From CATEGORY++// priority: string, // From PRIORITY++// title: string, // Short headline++// message: string, // Plain-text body++// icon: string, // Emoji or icon class++// user_id: number, // Recipient++// sender_id?: number, // Who triggered it (nullable)++// service: string, // Origin service ('live', 'games', 'network')++// richContent?: object, // See schema above++// read: boolean,++// dismissed: boolean,++// emailed: boolean,++// created_at: string, // ISO 8601++// expires_at?: string, // Optional auto-expire++// }++++function createNotification({ type, message, userId, senderId, service, richContent, expiresAt }) {++ const typeDef = TYPES[type];++ if (!typeDef) throw new Error(`Unknown notification type: ${type}`);++++ return {++ type,++ category: typeDef.category,++ priority: typeDef.priority,++ title: typeDef.title,++ icon: typeDef.icon,++ message: message || '',++ user_id: userId,++ sender_id: senderId || null,++ service: service || 'network',++ richContent: richContent || null,++ read: false,++ dismissed: false,++ emailed: false,++ expires_at: expiresAt || null,++ };++}++++// ── User Preferences Defaults ────────────────────────────────++const DEFAULT_NOTIFICATION_PREFS = Object.freeze({++ enabled: true,++ sound: true,++ toasts: true,++ email_critical: true, // Email for CRITICAL only++ muted_categories: [], // Categories the user has silenced++ do_not_disturb: false,++ dnd_schedule: null, // { start: '22:00', end: '08:00', timezone: 'America/Los_Angeles' }++});++++module.exports = {++ PRIORITY,++ CATEGORY,++ TYPES,++ SOUNDS,++ EMAIL_ELIGIBLE_CATEGORIES,++ isEmailEligible,++ createNotification,++ DEFAULT_NOTIFICATION_PREFS,++};nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "name": "openvibe-shared",++ "version": "1.0.0",++ "description": "Shared auth, theme, and brand utilities for the OpenVibe",++ "main": "index.js",++ "exports": {++ ".": "./index.js",++ "./analytics": "./analytics.js",++ "./auth-client": "./auth-client.js",++ "./theme-sync": "./theme-sync.js",++ "./brand": "./brand.js",++ "./middleware": "./middleware.js",++ "./notifications": "./notifications.js",++ "./url-resolver": "./url-resolver.js"++ },++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++const assert = require('assert');++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');++++// ── Test: Deploy infrastructure keys exist ───────────────────++const deployKeys = [++ 'DEPLOY_ACME_EMAIL',++ 'DEPLOY_CERT_MODE',++ 'DEPLOY_CLOUDFLARE_TOKEN',++ 'DEPLOY_DOMAINS',++ 'DEPLOY_NGINX_MODE',++ 'DEPLOY_NGINX_SITES_PATH',++ 'DEPLOY_NGINX_BACKUP_PATH',++ 'DEPLOY_SERVICE_MAP',++];++++for (const key of deployKeys) {++ assert.ok(URL_DEFINITIONS[key], `Missing deploy key: ${key}`);++ assert.strictEqual(URL_DEFINITIONS[key].key, key);++ assert.ok(URL_DEFINITIONS[key].category.startsWith('deploy_'), `Deploy key ${key} should have deploy_ category`);++}++++// ── Test: Deploy key defaults ────────────────────────────────++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CERT_MODE.default, 'manual');++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_MODE.default, 'preview');++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_SITES_PATH.default, '/etc/nginx/sites-enabled');++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CLOUDFLARE_TOKEN.type, 'secret');++++// ── Test: Secret type normalization ──────────────────────────++assert.strictEqual(normalizeValue(' my-secret-token ', 'secret'), 'my-secret-token');++assert.strictEqual(normalizeValue(null, 'secret'), null);++assert.strictEqual(normalizeValue(undefined, 'secret'), null);++++// ── Test: Deploy config resolves through registry chain ──────++const env = {};++const bootstrap = {++ DEPLOY_ACME_EMAIL: '[email protected]',++ DEPLOY_CERT_MODE: 'cloudflare',++};++const overrides = {++ DEPLOY_CERT_MODE: 'manual',++};++++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);++++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.value, '[email protected]');++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.source, 'bootstrap');++assert.strictEqual(resolved.DEPLOY_CERT_MODE.value, 'manual');++assert.strictEqual(resolved.DEPLOY_CERT_MODE.source, 'admin');++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.value, 'preview');++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.source, 'default');++++// ── Test: DEPLOY_DOMAINS as JSON array ───────────────────────++const domainsResolved = resolveRegistryValues({}, {++ DEPLOY_DOMAINS: JSON.stringify([++ { domain: 'openvibe.tools', wildcard: true, certName: 'openvibe.tools', services: ['network'] },++ { domain: 'openvibe.live', wildcard: true, certName: 'openvibe.live-0001', services: ['live'] },++ ]),++}, {}, URL_DEFINITIONS);++++assert.ok(Array.isArray(domainsResolved.DEPLOY_DOMAINS.value));++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value.length, 2);++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value[0].domain, 'openvibe.tools');++++// ── Test: DEPLOY_SERVICE_MAP as JSON map ─────────────────────++const svcMapResolved = resolveRegistryValues({}, {++ DEPLOY_SERVICE_MAP: JSON.stringify({ custom: { port: 9000, domains: ['custom.example.com'] } }),++}, {}, URL_DEFINITIONS);++++assert.ok(typeof svcMapResolved.DEPLOY_SERVICE_MAP.value === 'object');++assert.strictEqual(svcMapResolved.DEPLOY_SERVICE_MAP.value.custom.port, 9000);++++// ── Test: White-label defaults still work ────────────────────++const whitelabelResolved = resolveRegistryValues({}, {++ NETWORK_NAME: 'CoolNet',++ TOOLS_SERVICE_NAME: 'CoolTools',++ DEPLOY_ACME_EMAIL: '[email protected]',++ DEPLOY_CERT_MODE: 'cloudflare',++}, {}, URL_DEFINITIONS);++++assert.strictEqual(whitelabelResolved.NETWORK_NAME.value, 'CoolNet');++assert.strictEqual(whitelabelResolved.DEPLOY_ACME_EMAIL.value, '[email protected]');++assert.strictEqual(whitelabelResolved.DEPLOY_CERT_MODE.value, 'cloudflare');++++console.log('✅ url-resolver deploy config tests passed');nnew file mode 100644@@@ -0,0 +1,38 @@++const assert = require('assert');++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');++++const env = {++ OV_LIVE_URL: 'https://env.example.com',++ WEBRTC_PUBLIC_URL: 'https://webrtc.env.example.com',++ OV_NETWORK_URL: 'https://network.env.example.com',++};++++const bootstrap = {++ OV_LIVE_URL: 'https://bootstrap.example.com',++ WHIP_PUBLIC_URL: 'https://bootstrap.whip.example.com',++};++++const overrides = {++ OV_LIVE_URL: 'https://admin.example.com',++};++++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);++assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://admin.example.com');++assert.strictEqual(resolved.OV_LIVE_URL.source, 'admin');++assert.strictEqual(resolved.WHIP_PUBLIC_URL.value, 'https://bootstrap.whip.example.com');++assert.strictEqual(resolved.WHIP_PUBLIC_URL.source, 'bootstrap');++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.value, 'https://webrtc.env.example.com');++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.source, 'env');++assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://network.env.example.com');++assert.strictEqual(resolved.OV_NETWORK_URL.source, 'env');++assert.strictEqual(resolved.OV_GAMES_URL.value, 'https://openvibe.games');++assert.strictEqual(resolved.OV_MEDIA_URL.value, 'https://openvibe.media');++assert.strictEqual(resolved.JSMPEG_PUBLIC_URL.source, 'default');++++assert.strictEqual(normalizeValue('turn:turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');++assert.strictEqual(normalizeValue('turn://turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');++assert.strictEqual(normalizeValue('turns://turn.example.com:5349', 'turn_url'), 'turns:turn.example.com:5349');++assert.strictEqual(normalizeValue('turn://user:[email protected]:3478', 'turn_url'), null);++assert.strictEqual(normalizeValue('turn://invalid host', 'turn_url'), null);++++console.log('✅ openvibe-shared url-resolver precedence test passed');nnew file mode 100644@@@ -0,0 +1,222 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Theme Loader (Browser)++// Synchronous theme application from cookie/localStorage.++// Load this BEFORE navbar.js to prevent FOUC.++// Sets CSS custom properties on <html> from the user's theme.++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const COOKIE_NAME = 'ov_theme_id';++ const STORAGE_KEY = 'ov_theme';++ // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,++ // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —++ // that's what makes the theme chosen at openvibe.network/themes sync everywhere.++ // Apps may override via init({apiBase}).++ const DEFAULT_API_BASE = 'https://openvibe.network';++ let _config = { apiBase: DEFAULT_API_BASE };++++ // ── Condensed built-in theme variable maps ───────────────++ // Kept in sync with packages/openvibe-shared/builtin-themes.js++ const THEMES = {++ 'vibe': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#666','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)' },++ 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#64748b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155' },++ 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#5a7a5a','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a' },++ 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#6a5a80','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a' },++ 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#6272a4','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--success':'#50fa7b','--danger':'#ff5555','--warning':'#f1fa8c','--info':'#8be9fd','--live-red':'#ff5555' },++ 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#75715e','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--danger':'#f92672','--warning':'#e6db74','--info':'#66d9ef' },++ 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#4a7080','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050' },++ 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#7a5a50','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030' },++ 'arctic': { '--bg-primary':'#0c1220','--bg-secondary':'#101828','--bg-tertiary':'#182030','--bg-card':'#141c2a','--bg-hover':'#1e2840','--bg-input':'#0e1422','--text-primary':'#e0eaf5','--text-secondary':'#8aa0c0','--text-muted':'#5a708a','--accent':'#38bdf8','--accent-light':'#7dd3fc','--accent-dark':'#0284c7','--border':'#1e3050','--border-light':'#2a4060' },++ 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#6a4a50','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30' },++ 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#7050a0','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--info':'#00e5ff','--success':'#69f0ae' },++ 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#606470','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48' },++ 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#005a18','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014' },++ 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#7b88a1','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--success':'#a3be8c','--danger':'#bf616a','--warning':'#ebcb8b','--info':'#81a1c1' },++ 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#928374','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--success':'#b8bb26','--danger':'#fb4934','--warning':'#fe8019','--info':'#83a598' },++ 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#444444','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525' },++ 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#7a6a54','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020' },++ 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#7a5a7a','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40' },++ 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#664700','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200' },++ 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#9a9ab0','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)' },++ 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#9a8e7e','--accent':'#8b6914','--accent-light':'#b08a30','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)' },++ 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#8a90aa','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)' },++ 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#7a9a6e','--accent':'#16a34a','--accent-light':'#22c55e','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)' },++ 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#aa8070','--accent':'#f97316','--accent-light':'#fb923c','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)' },++ 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#9080aa','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)' },++ 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#928374','--accent':'#d65d0e','--accent-light':'#fe8019','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--success':'#98971a','--danger':'#cc241d','--warning':'#d79921','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)' },++ 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#94a3b8','--accent':'#0ea5e9','--accent-light':'#38bdf8','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)' },++ 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#9a8e7e','--accent':'#b8860b','--accent-light':'#daa520','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)' },++ 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#aa7090','--accent':'#e11d48','--accent-light':'#f43f5e','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)' },++ 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#93a1a1','--text-muted':'#586e75','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--success':'#859900','--danger':'#dc322f','--warning':'#cb4b16','--info':'#268bd2' },++ 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#586e75','--text-muted':'#93a1a1','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--success':'#859900','--danger':'#dc322f','--warning':'#cb4b16','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)' },++ 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#6c7086','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--success':'#a6e3a1','--danger':'#f38ba8','--warning':'#fab387','--info':'#89b4fa','--live-red':'#f38ba8' },++ 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#9ca0b0','--accent':'#8839ef','--accent-light':'#a05cff','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--success':'#40a02b','--danger':'#d20f39','--warning':'#fe640b','--info':'#1e66f5','--live-red':'#d20f39','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)' },++ 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff' },++ };++++ function getCookie(name) {++ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));++ return match ? decodeURIComponent(match[1]) : null;++ }++++ function setCookie(name, value, days) {++ const maxAge = days * 24 * 60 * 60;++ const encoded = encodeURIComponent(value);++ const host = (typeof location !== 'undefined' && location.hostname) || '';++ // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY++ // when actually running on openvibe.tools or one of its subdomains, so the++ // theme follows the user across the tool satellites. Everywhere else++ // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the++ // cookie is host-only — NO Domain attribute.++ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;++ } else {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;++ }++ }++++ /**++ * Apply a theme's CSS variables to document.documentElement.++ * If themeId is not found in the built-in map, does nothing.++ */++ function applyById(themeId) {++ const vars = THEMES[themeId];++ if (!vars) return false;++ const el = document.documentElement;++ for (const [prop, val] of Object.entries(vars)) {++ el.style.setProperty(prop, val);++ }++ return true;++ }++++ /**++ * Apply arbitrary CSS variables object to <html>.++ */++ function applyVars(vars) {++ if (!vars || typeof vars !== 'object') return;++ const el = document.documentElement;++ for (const [prop, val] of Object.entries(vars)) {++ if (prop.startsWith('--')) el.style.setProperty(prop, val);++ }++ }++++ /**++ * Resolve the active theme from (in order):++ * 1. localStorage 'ov_theme' (has id + variables — instant)++ * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)++ * Falls back to 'vibe'.++ */++ function resolveAndApply() {++ // 1. Try localStorage (same-origin cache with full variables)++ try {++ const raw = localStorage.getItem(STORAGE_KEY);++ if (raw) {++ const saved = JSON.parse(raw);++ if (saved && saved.variables && typeof saved.variables === 'object') {++ applyVars(saved.variables);++ return saved.id || saved.slug || 'vibe';++ }++ if (saved && (saved.id || saved.slug)) {++ const id = saved.id || saved.slug;++ if (applyById(id)) return id;++ }++ }++ } catch { /* corrupt localStorage */ }++++ // 2. Try cross-domain cookie++ const cookieId = getCookie(COOKIE_NAME);++ if (cookieId && applyById(cookieId)) {++ // Cache in localStorage for faster next load++ try {++ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));++ } catch { /* quota */ }++ return cookieId;++ }++++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)++ applyById('vibe');++ return 'vibe';++ }++++ /**++ * Save theme choice both locally and cross-domain.++ * Call this when the user picks a theme.++ */++ function save(themeId, variables) {++ const vars = variables || THEMES[themeId];++ if (!vars) return;++ try {++ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));++ } catch { /* quota */ }++ setCookie(COOKIE_NAME, themeId, 365);++ }++++ /**++ * Get the current theme ID (from cookie or localStorage).++ */++ function getCurrent() {++ try {++ const raw = localStorage.getItem(STORAGE_KEY);++ if (raw) {++ const saved = JSON.parse(raw);++ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;++ }++ } catch {}++ return getCookie(COOKIE_NAME) || 'vibe';++ }++++ // ── Auto-apply on load (synchronous, prevents FOUC) ─────++ const activeId = resolveAndApply();++++ // ── If user is logged in, try to sync from server ────────++ // This runs async after page paint to pick up server-side changes++ if (typeof fetch !== 'undefined') {++ setTimeout(function () {++ const token = getCookie('ov_token') ||++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));++ if (!token) return;++ fetch(_config.apiBase + '/api/themes/me/active', {++ headers: { 'Authorization': 'Bearer ' + token },++ credentials: 'include',++ }).then(function (r) { return r.ok ? r.json() : null; })++ .then(function (data) {++ if (!data || !data.theme_id) return;++ const serverId = data.theme_id;++ const custom = data.custom_variables;++ if (custom && typeof custom === 'object' && Object.keys(custom).length) {++ // Custom theme: apply the built-in base (if any) then the user's overrides.++ const base = THEMES[serverId] || {};++ applyVars(base);++ applyVars(custom);++ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }++ setCookie(COOKIE_NAME, serverId, 365);++ } else if (serverId !== getCurrent()) {++ if (applyById(serverId)) save(serverId, THEMES[serverId]);++ }++ }).catch(function () { /* offline / CORS */ });++ }, 500);++ }++++ // ── Public API ───────────────────────────────────────────++ const OpenVibeThemeLoader = {++ THEMES: THEMES,++ apply: applyById,++ applyVars: applyVars,++ save: save,++ getCurrent: getCurrent,++ resolveAndApply: resolveAndApply,++ init: function (opts) {++ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {++ _config.apiBase = opts.apiBase.trim();++ }++ return OpenVibeThemeLoader;++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;++ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++++const { BUILTIN_THEMES, DEFAULT_VARS } = require('./builtin-themes');++++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Theme Sync Engine++// Isomorphic theme system shared across all OpenVibe services.++// Works in both Node.js (SSR) and browser environments.++// ═══════════════════════════════════════════════════════════════++++const CSS_VARIABLES = [++ '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card',++ '--bg-hover', '--bg-input',++ '--text-primary', '--text-secondary', '--text-muted',++ '--accent', '--accent-light', '--accent-dark',++ '--live-red', '--success', '--warning', '--danger', '--info',++ '--border', '--border-light', '--shadow', '--shadow-lg',++];++++const NORMALIZED_DEFAULT_VARS = Object.freeze({ ...DEFAULT_VARS });++++// ── Dangerous CSS patterns (XSS prevention) ─────────────────++const DANGEROUS_PATTERNS = [++ /url\s*\(/i, /expression\s*\(/i, /javascript:/i,++ /@import/i, /behavior\s*:/i, /var\s*\(/i, /-moz-binding/i,++];++++function sanitizeCssValue(value) {++ if (typeof value !== 'string') return null;++ const trimmed = value.trim();++ if (trimmed.length > 200) return null;++ for (const pat of DANGEROUS_PATTERNS) {++ if (pat.test(trimmed)) return null;++ }++ return trimmed;++}++++// ── Theme Application ────────────────────────────────────────++++/**++ * Apply a theme's variables to a target (DOM element or plain object).++ * In browser: pass `document.documentElement` as target.++ * In Node: pass an empty object to collect computed values.++ */++function applyTheme(theme, target) {++ if (!theme || !theme.variables) return;++ const vars = { ...NORMALIZED_DEFAULT_VARS, ...theme.variables };++ for (const [key, value] of Object.entries(vars)) {++ if (!CSS_VARIABLES.includes(key)) continue;++ const safe = sanitizeCssValue(value);++ if (!safe) continue;++ if (target && typeof target.style !== 'undefined' && target.style.setProperty) {++ // DOM element (browser)++ target.style.setProperty(key, safe);++ } else if (typeof target === 'object') {++ // Plain object (Node SSR)++ target[key] = safe;++ }++ }++}++++/**++ * Resolve a theme by ID from the built-in catalog.++ * Falls back to 'vibe' if not found.++ */++function resolveBuiltinTheme(themeId) {++ return BUILTIN_THEMES.find(t => t.id === themeId || t.slug === themeId)++ || BUILTIN_THEMES[0];++}++++// ── Browser-Only Helpers ─────────────────────────────────────++// These are safe to call in Node (they no-op if `window` is undefined).++++const STORAGE_KEY = 'ov_theme';++const THEME_API_BASE = 'https://openvibe.network/api/themes';++++function loadFromStorage() {++ if (typeof localStorage === 'undefined') return null;++ try {++ const raw = localStorage.getItem(STORAGE_KEY);++ return raw ? JSON.parse(raw) : null;++ } catch { return null; }++}++++function saveToStorage(theme) {++ if (typeof localStorage === 'undefined') return;++ try {++ localStorage.setItem(STORAGE_KEY, JSON.stringify({++ id: theme.id,++ slug: theme.slug,++ name: theme.name,++ mode: theme.mode,++ variables: theme.variables,++ }));++ } catch { /* quota exceeded, etc. */ }++}++++/**++ * Sync theme choice to the central OpenVibe.Network server.++ * Requires an auth token. Fails silently if offline.++ */++async function syncThemeToServer(themeId, customVars, token) {++ if (typeof fetch === 'undefined' || !token) return;++ try {++ await fetch(`${THEME_API_BASE}/me`, {++ method: 'PUT',++ headers: {++ 'Content-Type': 'application/json',++ 'Authorization': `Bearer ${token}`,++ },++ body: JSON.stringify({ theme_id: themeId, custom_variables: customVars || null }),++ });++ } catch { /* offline, CORS, etc. */ }++}++++module.exports = {++ CSS_VARIABLES,++ DEFAULT_VARS: NORMALIZED_DEFAULT_VARS,++ BUILTIN_THEMES,++ sanitizeCssValue,++ applyTheme,++ resolveBuiltinTheme,++ loadFromStorage,++ saveToStorage,++ syncThemeToServer,++ STORAGE_KEY,++};nnew file mode 100644@@@ -0,0 +1,597 @@++'use strict';++++const URL_DEFINITIONS = Object.freeze({++ OV_LIVE_URL: {++ key: 'OV_LIVE_URL',++ label: 'OpenVibe.Live Public URL',++ category: 'public_first_party_urls',++ service: 'live',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.live',++ description: 'Canonical public origin for OpenVibe.Live.',++ },++ OV_TOOLS_URL: {++ key: 'OV_TOOLS_URL',++ label: 'OpenVibe.Tools Public URL',++ category: 'public_first_party_urls',++ service: 'tools',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.tools',++ description: 'Canonical public origin for the OpenVibe.Tools gateway.',++ },++ OV_GAMES_URL: {++ key: 'OV_GAMES_URL',++ label: 'OpenVibe.Games Public URL',++ category: 'public_first_party_urls',++ service: 'games',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.games',++ description: 'Canonical public origin for OpenVibe.Games.',++ },++ OV_MEDIA_URL: {++ key: 'OV_MEDIA_URL',++ label: 'OpenVibe.Media Public URL',++ category: 'public_first_party_urls',++ service: 'media',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.media',++ description: 'Canonical public origin for OpenVibe.Media.',++ },++ WEBRTC_PUBLIC_URL: {++ key: 'WEBRTC_PUBLIC_URL',++ label: 'WebRTC Public URL',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'origin',++ default: 'http://localhost:3000',++ description: 'Public origin used for WebRTC endpoint discovery and browser consumers.',++ },++ WHIP_PUBLIC_URL: {++ key: 'WHIP_PUBLIC_URL',++ label: 'WHIP Public URL',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'origin',++ default: 'http://localhost:3000',++ description: 'Canonical WHIP ingestion origin for OBS and other WHIP clients.',++ },++ WHIP_PUBLIC_URL_ENABLED: {++ key: 'WHIP_PUBLIC_URL_ENABLED',++ label: 'Dedicated WHIP Host Enabled',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'boolean',++ default: false,++ description: 'Enable use of the dedicated WHIP hostname configured by WHIP_PUBLIC_URL. If disabled, the app falls back to the safe public WebRTC origin.',++ },++ JSMPEG_PUBLIC_URL: {++ key: 'JSMPEG_PUBLIC_URL',++ label: 'JSMPEG Public URL',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'origin',++ default: 'http://localhost:3000',++ description: 'Public origin used to construct JSMPEG relay endpoints for browsers and FFmpeg clients.',++ },++ TURN_URL: {++ key: 'TURN_URL',++ label: 'TURN URL',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'turn_url',++ default: '',++ description: 'TURN server connection string used for WebRTC NAT traversal.',++ },++ MEDIASOUP_ANNOUNCED_IP: {++ key: 'MEDIASOUP_ANNOUNCED_IP',++ label: 'Mediasoup Announced IP/Hostname',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'hostname',++ default: 'localhost',++ description: 'Hostname or public IP advertised in Mediasoup ICE candidates.',++ },++ RTMP_HOST: {++ key: 'RTMP_HOST',++ label: 'RTMP Host',++ category: 'protocol_ingest_urls',++ service: 'live',++ scope: 'global',++ type: 'rtmp_host',++ default: '',++ description: 'Hostname used for RTMP ingest. Do not include protocol or path.',++ },++ OV_NETWORK_URL: {++ key: 'OV_NETWORK_URL',++ label: 'OpenVibe.Network URL',++ category: 'public_first_party_urls',++ service: 'network',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.network',++ description: 'Public URL for the OpenVibe.Network SSO, account, and registry service.',++ },++ OV_NETWORK_LOGIN_URL: {++ key: 'OV_NETWORK_LOGIN_URL',++ label: 'OpenVibe.Network Login URL',++ category: 'public_first_party_urls',++ service: 'network',++ scope: 'global',++ type: 'origin',++ default: 'https://openvibe.network',++ description: 'Public login URL for OpenVibe.Network. Typically the same as the public OpenVibe.Network origin.',++ },++ OV_NETWORK_INTERNAL_URL: {++ key: 'OV_NETWORK_INTERNAL_URL',++ label: 'OpenVibe.Network Internal URL',++ category: 'internal_service_urls',++ service: 'network',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4000',++ description: 'Internal URL used by services to call the OpenVibe.Network internal API.',++ },++ OV_LIVE_INTERNAL_URL: {++ key: 'OV_LIVE_INTERNAL_URL',++ label: 'OpenVibe.Live Internal URL',++ category: 'internal_service_urls',++ service: 'live',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:3000',++ description: 'Internal URL used by OpenVibe.Network or other OpenVibe services to reach OpenVibe.Live.',++ },++ OV_TOOLS_INTERNAL_URL: {++ key: 'OV_TOOLS_INTERNAL_URL',++ label: 'OpenVibe.Tools Internal URL',++ category: 'internal_service_urls',++ service: 'tools',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4001',++ description: 'Internal URL used by OpenVibe.Network to reach the OpenVibe.Tools gateway.',++ },++ OV_GAMES_INTERNAL_URL: {++ key: 'OV_GAMES_INTERNAL_URL',++ label: 'OpenVibe.Games Internal URL',++ category: 'internal_service_urls',++ service: 'games',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:8000',++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Games.',++ },++ OV_MEDIA_INTERNAL_URL: {++ key: 'OV_MEDIA_INTERNAL_URL',++ label: 'OpenVibe.Media Internal URL',++ category: 'internal_service_urls',++ service: 'media',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4100',++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Media.',++ },++ OV_MAPS_INTERNAL_URL: {++ key: 'OV_MAPS_INTERNAL_URL',++ label: 'Maps.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'maps',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4010',++ description: 'Internal URL of the Maps.OpenVibe tools satellite.',++ },++ OV_FOOD_INTERNAL_URL: {++ key: 'OV_FOOD_INTERNAL_URL',++ label: 'Food.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'food',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4011',++ description: 'Internal URL of the Food.OpenVibe tools satellite.',++ },++ OV_IMG_INTERNAL_URL: {++ key: 'OV_IMG_INTERNAL_URL',++ label: 'Img.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'img',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4012',++ description: 'Internal URL of the Img.OpenVibe tools satellite.',++ },++ OV_YT_INTERNAL_URL: {++ key: 'OV_YT_INTERNAL_URL',++ label: 'YT.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'yt',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4013',++ description: 'Internal URL of the YT.OpenVibe tools satellite.',++ },++ OV_AUDIO_INTERNAL_URL: {++ key: 'OV_AUDIO_INTERNAL_URL',++ label: 'Audio.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'audio',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4014',++ description: 'Internal URL of the Audio.OpenVibe tools satellite.',++ },++ OV_TEXT_INTERNAL_URL: {++ key: 'OV_TEXT_INTERNAL_URL',++ label: 'Text.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'text',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4015',++ description: 'Internal URL of the Text.OpenVibe tools satellite.',++ },++ OV_DOCS_INTERNAL_URL: {++ key: 'OV_DOCS_INTERNAL_URL',++ label: 'Docs.OpenVibe Internal URL',++ category: 'internal_service_urls',++ service: 'docs',++ scope: 'global',++ type: 'internal_base_url',++ default: 'http://127.0.0.1:4016',++ description: 'Internal URL of the Docs.OpenVibe tools satellite.',++ },++++ // ── Brand / Site Identity ─────────────────────────────────────────────────++ // Configurable during first-time setup and editable in admin.++ // These values drive display names, JWT metadata, email copy, and CORS policy.++ // Defaults match the OpenVibe — change for white-label installs.++++ NETWORK_NAME: {++ key: 'NETWORK_NAME',++ label: 'Network / Organization Name',++ category: 'brand_identity',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: 'OpenVibe',++ description: 'Human-readable name for the overall platform (e.g. "OpenVibe" or your custom brand).',++ },++ TOOLS_SERVICE_NAME: {++ key: 'TOOLS_SERVICE_NAME',++ label: 'Tools Service Name',++ category: 'brand_identity',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: 'OpenVibe.Tools',++ description: 'Display name for the SSO/tools service (e.g. "OpenVibe.Tools" or "CoolTools").',++ },++ STREAMER_SERVICE_NAME: {++ key: 'STREAMER_SERVICE_NAME',++ label: 'Streamer Service Name',++ category: 'brand_identity',++ service: 'live',++ scope: 'global',++ type: 'string',++ default: 'OpenVibe.Live',++ description: 'Display name for the streaming service (e.g. "OpenVibe.Live").',++ },++ GAMES_SERVICE_NAME: {++ key: 'GAMES_SERVICE_NAME',++ label: 'Games Service Name',++ category: 'brand_identity',++ service: 'games',++ scope: 'global',++ type: 'string',++ default: 'OpenVibe.Games',++ description: 'Display name for the games service (e.g. "OpenVibe.Games").',++ },++ MEDIA_SERVICE_NAME: {++ key: 'MEDIA_SERVICE_NAME',++ label: 'Media Service Name',++ category: 'brand_identity',++ service: 'media',++ scope: 'global',++ type: 'string',++ default: 'OpenVibe.Media',++ description: 'Display name for the media service (e.g. "OpenVibe.Media").',++ },++++ // ── CORS / Origin Policy ──────────────────────────────────────────────────++ // Controls which additional origins are allowed cross-origin access.++ // The public service URLs above are always auto-allowed.++++ TOOLS_SUBDOMAIN_BASE: {++ key: 'TOOLS_SUBDOMAIN_BASE',++ label: 'Tools Subdomain Base Domain',++ category: 'cors_policy',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: 'openvibe.tools',++ description: 'Base domain for which all https://*.{domain} subdomains are trusted first-party origins (e.g. "openvibe.tools"). Leave empty to disable subdomain wildcarding.',++ },++ ALLOWED_EXTRA_ORIGINS: {++ key: 'ALLOWED_EXTRA_ORIGINS',++ label: 'Extra Allowed Origins (JSON array)',++ category: 'cors_policy',++ service: 'network',++ scope: 'global',++ type: 'json_array',++ default: '[]',++ description: 'JSON array of additional origins allowed by CORS across all services (e.g. ["https://my-cdn.com"]). Auto-includes all configured service public URLs.',++ },++++ // ── Deploy Infrastructure ─────────────────────────────────────────────────++ // Certificate, DNS, and reverse-proxy management config.++ // These drive the automated TLS issuance and Nginx generation system.++++ DEPLOY_ACME_EMAIL: {++ key: 'DEPLOY_ACME_EMAIL',++ label: 'ACME / Let\'s Encrypt Email',++ category: 'deploy_tls',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: '',++ description: 'Email address used for Let\'s Encrypt certificate registration and expiry notifications.',++ },++ DEPLOY_CERT_MODE: {++ key: 'DEPLOY_CERT_MODE',++ label: 'Certificate Mode',++ category: 'deploy_tls',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: 'manual',++ description: 'Certificate provisioning mode: "cloudflare" for automated DNS-01 via Cloudflare API, "manual" for manual DNS challenge, or "none" to skip.',++ },++ DEPLOY_CLOUDFLARE_TOKEN: {++ key: 'DEPLOY_CLOUDFLARE_TOKEN',++ label: 'Cloudflare API Token (DNS Edit)',++ category: 'deploy_tls',++ service: 'network',++ scope: 'global',++ type: 'secret',++ default: '',++ description: 'Cloudflare API token with Zone:DNS:Edit permissions. Used for automated DNS-01 wildcard certificate challenges.',++ },++ DEPLOY_DOMAINS: {++ key: 'DEPLOY_DOMAINS',++ label: 'Managed Domains (JSON)',++ category: 'deploy_tls',++ service: 'network',++ scope: 'global',++ type: 'json_array',++ default: '[]',++ description: 'JSON array of domain objects managed by the deploy system. Each entry: {"domain":"openvibe.network","wildcard":false,"certName":"openvibe.network","services":["network"]}.',++ },++ DEPLOY_NGINX_MODE: {++ key: 'DEPLOY_NGINX_MODE',++ label: 'Nginx Management Mode',++ category: 'deploy_nginx',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: 'preview',++ description: 'Nginx config management mode: "preview" (generate only), "apply" (write + validate + reload), or "disabled".',++ },++ DEPLOY_NGINX_SITES_PATH: {++ key: 'DEPLOY_NGINX_SITES_PATH',++ label: 'Nginx sites-enabled Path',++ category: 'deploy_nginx',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: '/etc/nginx/sites-enabled',++ description: 'Directory where Nginx site configs are written. Defaults to /etc/nginx/sites-enabled.',++ },++ DEPLOY_NGINX_BACKUP_PATH: {++ key: 'DEPLOY_NGINX_BACKUP_PATH',++ label: 'Nginx Backup Path',++ category: 'deploy_nginx',++ service: 'network',++ scope: 'global',++ type: 'string',++ default: '/etc/nginx/sites-backup',++ description: 'Directory for Nginx config backups before apply. Created automatically.',++ },++ DEPLOY_SERVICE_MAP: {++ key: 'DEPLOY_SERVICE_MAP',++ label: 'Service → Port / Domain Map (JSON)',++ category: 'deploy_nginx',++ service: 'network',++ scope: 'global',++ type: 'json_map',++ default: '{}',++ description: 'JSON map of service configurations for Nginx generation. Each key is a service ID, value has port, domains[], wildcardDomain, maxBodySize, etc.',++ },++});++++function normalizeOrigin(value) {++ if (!value || typeof value !== 'string') return null;++ try {++ const url = new URL(value.trim());++ if (!url.protocol || !url.hostname) return null;++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;++ } catch {++ return null;++ }++}++++function normalizeInternalBaseUrl(value) {++ if (!value || typeof value !== 'string') return null;++ try {++ const url = new URL(value.trim());++ if (!url.protocol || !url.hostname) return null;++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;++ } catch {++ return null;++ }++}++++function normalizeWsOrigin(value) {++ if (!value || typeof value !== 'string') return null;++ try {++ const url = new URL(value.trim());++ if (!url.hostname) return null;++ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;++ } catch {++ return null;++ }++}++++function normalizeHostname(value) {++ if (!value || typeof value !== 'string') return null;++ const trimmed = value.trim();++ if (/^\w+:\/\//.test(trimmed)) {++ try {++ const url = new URL(trimmed);++ return url.hostname || null;++ } catch {++ return null;++ }++ }++ if (/[^a-zA-Z0-9.\-:]/.test(trimmed)) return null;++ return trimmed;++}++++function normalizeTurnUrl(value) {++ if (!value || typeof value !== 'string') return null;++ let trimmed = value.trim();++ if (/^turns?:[^/]/i.test(trimmed)) {++ trimmed = trimmed.replace(/^turns?:/i, (s) => s + '//');++ }++ if (!/^turns?:\/\//i.test(trimmed)) return null;++ try {++ const url = new URL(trimmed);++ if (!url.hostname) return null;++ const protocol = url.protocol.toLowerCase();++ if (protocol !== 'turn:' && protocol !== 'turns:') return null;++ if (url.username || url.password) return null;++ return `${protocol}${url.hostname}${url.port ? `:${url.port}` : ''}${url.pathname}${url.search}`;++ } catch {++ return null;++ }++}++++function normalizeBoolean(value) {++ if (value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE' || value === 'True') {++ return true;++ }++ if (value === false || value === 0 || value === '0' || value === 'false' || value === 'FALSE' || value === 'False') {++ return false;++ }++ return null;++}++++function normalizeJson(value, type) {++ if (typeof value === 'string') {++ try {++ return JSON.parse(value);++ } catch {++ return null;++ }++ }++ if (type === 'json_map' && typeof value === 'object' && value !== null && !Array.isArray(value)) {++ return value;++ }++ if (type === 'json_array' && Array.isArray(value)) {++ return value;++ }++ return null;++}++++function normalizeValue(value, type) {++ if (value === undefined || value === null) return null;++ switch (type) {++ case 'origin': return normalizeOrigin(value);++ case 'internal_base_url': return normalizeInternalBaseUrl(value);++ case 'ws_origin': return normalizeWsOrigin(value);++ case 'hostname': return normalizeHostname(value);++ case 'rtmp_host': return normalizeHostname(value);++ case 'turn_url': return normalizeTurnUrl(value);++ case 'json_map': return normalizeJson(value, 'json_map');++ case 'json_array': return normalizeJson(value, 'json_array');++ case 'boolean': return normalizeBoolean(value);++ case 'secret': return typeof value === 'string' ? value.trim() : String(value);++ case 'string': return typeof value === 'string' ? value.trim() : String(value);++ default: return typeof value === 'string' ? value.trim() : String(value);++ }++}++++function validateValue(value, type) {++ return normalizeValue(value, type) !== null;++}++++function resolveRegistryValues(env = {}, overrides = {}, bootstrap = {}, definitions = URL_DEFINITIONS) {++ const resolved = {};++ for (const [key, def] of Object.entries(definitions)) {++ let rawValue = def.default;++ let source = 'default';++ if (env[key]) {++ rawValue = env[key];++ source = 'env';++ }++ if (bootstrap[key] != null && bootstrap[key] !== '') {++ rawValue = bootstrap[key];++ source = 'bootstrap';++ }++ if (overrides[key] != null && overrides[key] !== '') {++ rawValue = overrides[key];++ source = 'admin';++ }++ const normalized = normalizeValue(rawValue, def.type);++ resolved[key] = {++ key,++ label: def.label,++ category: def.category,++ service: def.service,++ scope: def.scope,++ type: def.type,++ description: def.description,++ value: normalized,++ source,++ };++ }++ return resolved;++}++++function formatRegistryEntry(entry) {++ if (!entry || typeof entry !== 'object') return null;++ return {++ key: entry.key,++ label: entry.label,++ category: entry.category,++ service: entry.service,++ scope: entry.scope,++ type: entry.type,++ description: entry.description,++ value: entry.value || null,++ source: entry.source || 'admin',++ updatedBy: entry.updated_by || null,++ updatedAt: entry.updated_at || null,++ };++}++++module.exports = {++ URL_DEFINITIONS,++ normalizeValue,++ validateValue,++ resolveRegistryValues,++ formatRegistryEntry,++};nnew file mode 100644@@@ -0,0 +1,252 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — User Card & Context Menu++// Right-click any username to see profile card, quick actions,++// linked services, name effects, and moderator tools.++// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })++// OpenVibeUserCard.attach(element, userId)++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };++ let _cardEl = null;++ let _cache = new Map();++++ function injectStyles() {++ if (document.getElementById('openvibe-usercard-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-usercard-styles';++ s.textContent = `++ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }++ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }++ .openvibe-usercard {++ position: fixed; z-index: 99999;++ width: 300px; background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 12px;++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ animation: openvibe-card-in .2s ease; overflow: hidden;++ }++ .openvibe-usercard-banner {++ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #8b5cf6));++ position: relative;++ }++ .openvibe-usercard-avatar {++ width: 56px; height: 56px; border-radius: 50%;++ border: 3px solid var(--bg-card, #22222c);++ position: absolute; bottom: -28px; left: 16px;++ background: var(--bg-tertiary, #2a2a38);++ object-fit: cover;++ }++ .openvibe-usercard-body { padding: 36px 16px 12px; }++ .openvibe-usercard-name {++ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;++ }++ .openvibe-usercard-name .role-badge {++ font-size: 9px; padding: 2px 6px; border-radius: 3px;++ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;++ }++ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }++ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }++ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }++ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }++ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }++ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }++ .openvibe-usercard-services .svc {++ font-size: 10px; padding: 3px 8px; border-radius: 4px;++ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);++ display: flex; align-items: center; gap: 4px;++ }++ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }++ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }++ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }++ .openvibe-usercard-actions button {++ display: flex; align-items: center; gap: 8px;++ width: 100%; padding: 8px 12px; background: none;++ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);++ font-size: 12px; cursor: pointer; text-align: left;++ transition: background .12s;++ }++ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }++ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }++++ /* Name effects (CSS classes applied to usernames) */++ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }++ @keyframes openvibe-rainbow { to { background-position: 200% center; } }++ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }++ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }++ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }++ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }++ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }++ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }++ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }++ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }++ `;++ document.head.appendChild(s);++ }++++ // ── Fetch User Data ──────────────────────────────────────++ async function fetchUser(userId) {++ if (_cache.has(userId)) return _cache.get(userId);++ try {++ const headers = {};++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });++ if (!res.ok) return null;++ const data = await res.json();++ _cache.set(userId, data);++ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache++ return data;++ } catch { return null; }++ }++++ // ── Render Card ──────────────────────────────────────────++ function renderCard(data, x, y) {++ closeCard();++ const user = data.user;++ const linked = data.linked_accounts || [];++++ const roleBadge = user.role !== 'user'++ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';++++ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';++ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';++++ const services = linked.map(l => {++ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };++ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;++ }).join('');++++ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);++ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);++++ let actions = '';++ if (!isSelf) {++ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;++ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;++ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;++ if (isMod) {++ actions += `<div class="openvibe-usercard-divider"></div>`;++ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;++ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;++ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;++ }++ } else {++ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;++ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;++ }++++ const card = document.createElement('div');++ card.className = 'openvibe-usercard';++++ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;++ card.innerHTML = `++ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">++ <div class="openvibe-usercard-body">++ <div class="openvibe-usercard-name">++ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>++ ${roleBadge}++ </div>++ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>++ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}++ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}++ <div class="openvibe-usercard-divider"></div>++ <div class="openvibe-usercard-actions">${actions}</div>++ </div>++ `;++++ // Position++ const vw = window.innerWidth, vh = window.innerHeight;++ card.style.left = Math.min(x, vw - 320) + 'px';++ card.style.top = Math.min(y, vh - 400) + 'px';++++ // Overlay to close on click-outside++ const overlay = document.createElement('div');++ overlay.className = 'openvibe-usercard-overlay';++ overlay.addEventListener('click', closeCard);++ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });++++ document.body.appendChild(overlay);++ document.body.appendChild(card);++ _cardEl = { card, overlay };++++ // Action handlers++ card.querySelectorAll('[data-act]').forEach(btn => {++ btn.addEventListener('click', () => {++ const act = btn.dataset.act;++ closeCard();++ if (act === 'profile') {++ const url = isSelf ? `${_config.apiBase}/my`++ : `${_config.apiBase}/user/${user.username}`;++ window.open(url, '_blank');++ } else if (act === 'message') {++ // Dispatch custom event for the host app to handle++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {++ detail: { action: act, user }++ }));++ } else {++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {++ detail: { action: act, user }++ }));++ }++ });++ });++ }++++ function closeCard() {++ if (_cardEl) {++ _cardEl.card.remove();++ _cardEl.overlay.remove();++ _cardEl = null;++ }++ }++++ // ── Public API ───────────────────────────────────────────++ const OpenVibeUserCard = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ },++++ /** Attach right-click card to an element. */++ attach(el, userId) {++ el.style.cursor = 'pointer';++ el.addEventListener('contextmenu', async e => {++ e.preventDefault();++ const data = await fetchUser(userId);++ if (data) renderCard(data, e.clientX, e.clientY);++ });++ // Also support click on mobile++ el.addEventListener('click', async e => {++ if (e.ctrlKey || e.metaKey) {++ e.preventDefault();++ const data = await fetchUser(userId);++ if (data) renderCard(data, e.clientX, e.clientY);++ }++ });++ },++++ /** Programmatically show a card at position. */++ async show(userId, x, y) {++ const data = await fetchUser(userId);++ if (data) renderCard(data, x, y);++ },++++ close: closeCard,++ clearCache() { _cache.clear(); },++++ /** Apply name effect CSS class to an element. */++ applyNameEffect(el, effectName) {++ if (!effectName) return;++ el.classList.add(`openvibe-name-fx-${effectName}`);++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;++ else root.OpenVibeUserCard = OpenVibeUserCard;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+# ── OpenVibe.Media — environment ─────────────────────────────
+# Copy to .env (or /etc/openvibe/media.env in production) and adjust.
+
+# HTTP
+PORT=4100
+HOST=0.0.0.0
+NODE_ENV=development
+# Public base URL used when building absolute playback/asset URLs.
+MEDIA_PUBLIC_URL=https://openvibe.media
+
+# Database (better-sqlite3, WAL)
+DB_PATH=./data/media.db
+
+# Storage paths (all created on boot)
+VOD_PATH=./data/vods
+CLIPS_PATH=./data/clips
+PASTES_PATH=./data/pastes
+THUMBNAILS_PATH=./data/thumbnails
+FILES_PATH=./data/files
+
+# RTP ingest port pool (UDP, bound on 127.0.0.1 by ffmpeg)
+RTP_PORT_MIN=12000
+RTP_PORT_MAX=12199
+
+# Identity provider (OpenVibe.Network) — JWKS fetched at boot for offline
+# RS256 verification of user JWTs on browser endpoints.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# Per-service key for Network's /internal/* endpoints (header X-Internal-Key).
+INTERNAL_API_KEY=
+
+# ── Tenant (app) seeding ─────────────────────────────────────
+# JSON array, upserted into the `apps` table on every boot (api_key is hashed,
+# never stored in plaintext). allowed_origins guards browser (user-JWT) calls.
+# MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"change-me","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"change-me-too","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
+MEDIA_APPS_SEED=
+# Fallback short form (no webhooks/origins): "live:key1,games:key2"
+MEDIA_APP_KEYS=
+
+# ── Object storage tiering (optional; local-only when unset) ─
+# Backblaze B2 (canonical cold tier)
+MEDIA_B2_ENDPOINT=
+MEDIA_B2_REGION=us-west-004
+MEDIA_B2_BUCKET=
+MEDIA_B2_KEY_ID=
+MEDIA_B2_APP_KEY=
+# Cloudflare R2 (popularity cache, free egress)
+MEDIA_R2_ENDPOINT=
+MEDIA_R2_REGION=auto
+MEDIA_R2_BUCKET=
+MEDIA_R2_ACCESS_KEY_ID=
+MEDIA_R2_SECRET_ACCESS_KEY=
+
+# ── Recording knobs (defaults match the inherited recorder) ──
+# VOD_STOP_GRACE_MS=60000
+# VOD_DISK_WARN_GB=15
+# VOD_DISK_CRIT_GB=5
+# MAX_VOD_SIZE_MB=2048
+# VOD_NO_NORMALIZE=0
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+node_modules/
+data/
+.env
+*.log
+.smoke/
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
+# OpenVibe.Media
+
+Standalone multi-tenant media service (port **4100**) extracted from the
+OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
+(local → Backblaze B2 → Cloudflare R2), clips, pastes, generic files, and
+thumbnails, for all OpenVibe apps (`live`, `games`, `tools`, `network`).
+
+Implements **Media API v1** from `../CONTRACTS.md`.
+
+```
+npm install
+cp .env.example .env # set MEDIA_APPS_SEED at minimum
+npm start # node server/index.js
+```
+
+Requires Node ≥ 20, `ffmpeg`/`ffprobe` on PATH. SQLite (better-sqlite3, WAL).
+
+## Layout
+
+```
+server/
+ index.js boot, route mounts, background jobs, graceful shutdown
+ config.js env config (PORT, DB_PATH, *_PATH, MEDIA_B2_*/R2_*, RTP pool, OV_NETWORK_URL)
+ auth.js tenancy + auth middleware, JWKS fetch, app seeding
+ webhooks.js HMAC-signed outbound webhooks
+ db/schema.sql apps, vods, clips, pastes(+likes/comments), files, content_views, media_settings
+ db/database.js better-sqlite3 helpers, all app_id-scoped; legacy row importer
+ vod/recorder.js ffmpeg recording: RTMP pull + RTP (SDP) ingest, codec passthrough
+ vod/media-tools.js probes, seekable remux, DVR sidecar, chunk-segment concat
+ vod/finalize.js finalize pipeline (remux → probe → master recovery → thumbnail → webhook)
+ vod/routes.js /api/v1/:app/vods (+ ingest + chunks)
+ vod/clip-cutter.js re-encoding clip cutter (local file or presigned URL source)
+ vod/clips-routes.js /api/v1/:app/clips
+ vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
+ vod/health-scanner.js probe/decode/master-recovery primitives
+ vod/health-job.js background health scan + quarantine cleanup + master sweep
+ pastes/routes.js /api/v1/:app/pastes
+ files/routes.js /api/v1/:app/files
+ thumbnails/ thumbnail service + /api/v1/:app/thumbnails
+ public/routes.js public /v /c /p /t /f
+vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)
+scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
+```
+
+## Tenancy & auth
+
+Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
+
+| column | meaning |
+|---|---|
+| `app_id` | tenant id (`live`, `games`, …) |
+| `api_key_hash` | sha256 of the app API key (never stored in plaintext) |
+| `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) |
+| `allowed_origins` | JSON array; guards browser (user-JWT) calls |
+| `quota_bytes` | files-subsystem quota (0 = unlimited) |
+
+Two credential types on `/api/v1/:app/...`:
+
+1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared in
+ constant time against the app's stored hash. A key is only valid for its
+ own `:app` segment; presenting another app's key returns **403**.
+2. **Network user JWT** (browser endpoints only — chunks/complete, clips,
+ pastes, files, thumbnails) — RS256, verified **offline** against the JWKS
+ public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot
+ (cached, refreshed every 6 h). `aud` must include `openvibe.media` when
+ present. If the request carries an `Origin` header it must be in the app's
+ `allowed_origins` (CORS is reflected for allow-listed origins only).
+
+### App seeding
+
+On every boot the service upserts apps from env (keys are hashed):
+
+```bash
+MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
+# fallback short form (no webhooks/origins):
+MEDIA_APP_KEYS="live:key1,games:key2"
+```
+
+## API (all under `/api/v1/:app`)
+
+### VODs
+
+| method | path | notes |
+|---|---|---|
+| POST | `/vods` | `{ title?, stream_id?, stream_key?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
+| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4` |
+| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
+| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
+| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
+| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
+| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |
+| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, … }` |
+| GET | `/vods?limit&offset&user_id&stream_id` | list |
+| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
+| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
+
+`status`: `pending → recording → ready | failed` (derived; failures come from
+health quarantine: corrupt / zero-byte / missing file).
+
+**Recording formats** (inherited codec-passthrough behavior — VODs are *not*
+always `.webm`): RTMP and RTP-H.264 record by lossless stream copy into a
+fragmented **`.mp4`** (audio → AAC on the RTP path); RTP-VP8/VP9 copies into
+**`.webm`** (Opus copied); exotic codecs fall back to a libvpx re-encode with a
+lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
+60 s during recording so `/v/:id` is DVR-seekable while live.
+
+### Clips
+
+| method | path | notes |
+|---|---|---|
+| POST | `/clips` | `{ vod_id, start_s, end_s, title?, user_id? }` → **202** `{ id, status: 'processing' }`; cut runs in background (from the local file or a presigned B2/R2 URL); duplicate windows are deduplicated; live recordings are clamped to flushed footage |
+| GET | `/clips/:id` | status: `processing | ready | failed` |
+| GET | `/clips?limit&offset&vod_id&stream_id&user_id` | list |
+| PUT | `/clips/:id` | `{ title?, visibility? }` |
+| DELETE | `/clips/:id` | local + offloaded objects + row |
+
+### Pastes
+
+| method | path | notes |
+|---|---|---|
+| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` |
+| GET | `/pastes?limit&offset&type&search&user_id` | public list |
+| GET | `/pastes/:slug` | full paste (private: owner/app only) |
+| PUT | `/pastes/:slug` | update (owner/app) |
+| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |
+| POST | `/pastes/:slug/fork` | fork a text paste |
+| POST | `/pastes/:slug/like` | toggle like (needs a user identity) |
+| POST | `/pastes/:slug/copy` | track a copy event |
+| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
+| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |
+
+Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,
+`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers
+are trusted server-to-server. AI summary/tags generation was **dropped** (Live
+owns AI) but the columns remain for imported rows.
+
+### Files
+
+| method | path | notes |
+|---|---|---|
+| POST | `/files` | multipart `file` → `{ key, url, size, mime }`; key = `<sha256[0:12]>-<name>`; per-app `quota_bytes` enforced (413 on exceed) |
+| GET | `/files` | list + `used_bytes`/`quota_bytes` |
+| GET | `/files/:key` | meta |
+| DELETE | `/files/:key` | delete |
+
+### Thumbnails
+
+| method | path | notes |
+|---|---|---|
+| POST | `/thumbnails/:kind/:id` | kind `vod`/`clip`: multipart `thumbnail` (or `{image: base64}`) uploads a custom image, or with no body (re)generates from the media (VOD @10%, clip near first frame); kind `live`: upload a broadcaster frame, stored under the stable name `stream-<app>-<id>.jpg` → `{ url }` |
+
+### Webhooks (outbound)
+
+`POST` to the app's `webhook_url` with body
+`{ "event": "vod.ready"|"vod.failed"|"clip.ready"|"clip.failed", "app_id", "data" }`
+and header `X-OVMedia-Signature: sha256=<hex hmac-sha256 of the raw body with
+the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
+
+## Public serving (no auth unless the item is private)
+
+| path | behavior |
+|---|---|
+| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex` |
+| `GET /c/:id` | clip playback, same logic, `noindex` |
+| `GET /p/:slug` | server-rendered paste HTML page (**indexable** — Media is the canonical home for pastes) |
+| `GET /p/:slug/raw` | `text/plain` |
+| `GET /p/:slug/screenshot` | paste screenshot image |
+| `GET /t/:id` | thumbnail (id = filename), `noindex` |
+| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
+
+Private items respond 403/404 unless the request bears the owning app's API
+key or the owning user's JWT.
+
+## Storage tiering
+
+`vod/vod-storage.js` (ported nearly as-is): local → B2 canonical cold tier →
+R2 popularity cache; verified uploads before any deletion; periodic sweep
+(offload by age/views/last-access, aggressive drain under disk pressure, R2
+promote/demote); presigned-302 playback. Knobs live in `media_settings` under
+`storage_tier.*` (JSON values). CLI:
+
+```
+node server/vod/vod-storage.js check|migrate-legacy|drain [targetPct]
+```
+
+Background jobs wired in `index.js`: tiering sweep, VOD health job (probe scan,
+master-recovery repair, quarantine + cleanup, orphaned-master sweep), disk
+guardian (refuses new recordings when free space is critical), stale live-thumb
+cleanup, and an on-boot sweep that finalizes recordings orphaned by an unclean
+shutdown.
+
+## Migration from the predecessor DB
+
+Every domain table carries `app_id TEXT NOT NULL DEFAULT 'live'`, and the new
+schema keeps all predecessor columns (including legacy `storage_tier`), so the
+cutover script can bulk-copy rows unchanged:
+
+```js
+const media = require('./server/db/database');
+const old = require('better-sqlite3')('/path/to/old-live.db', { readonly: true });
+for (const table of ['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views']) {
+ const rows = old.prepare(`SELECT * FROM ${table}`).all();
+ console.log(table, media.importLegacyRows(table, rows, 'live'));
+}
+```
+
+`importLegacyRows` inserts by column-name intersection (unknown legacy columns
+ignored, new columns take defaults), preserves ids, backfills `app_id`, and is
+idempotent (`INSERT OR IGNORE`). After the copy, run
+`node server/vod/vod-storage.js migrate-legacy` to map legacy `storage_tier =
+'cold'` rows to provider `b2`.
+
+## Not ported
+
+- **Song-request media queue** (`media/` yt-dlp downloader/queue): deeply
+ coupled to Live's channels/chat/coins subsystems; Live keeps it local.
+- Server-side live thumbnail grabbers (RTMP-FLV / JSMPEG-WS / SFU PlainRTP)
+ and JSMPEG stdin recording: sources live inside the apps now; apps POST
+ frames / use RTMP-RTP-chunk ingest instead.
+- VOD part rotation + live-recording reconciler: tied to the predecessor's
+ stream registry; apps re-trigger ingest on their side.
+- Comments on VODs/clips, notifications, permissions/moderation ranks: owned
+ by the apps.
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.media www.openvibe.media;
+ return 301 https://openvibe.media$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name openvibe.media www.openvibe.media;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;
+
+ client_max_body_size 4096m;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+
+ location / {
+ proxy_pass http://127.0.0.1:4100;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_request_buffering off;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+# OpenVibe.Media — multi-tenant media service (VOD recording/serving, clips, pastes, files).
+# Secrets live in /etc/openvibe/media.env (0600).
+[Unit]
+Description=OpenVibe.Media service
+After=network.target
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.media
+EnvironmentFile=/etc/openvibe/media.env
+ExecStart=/usr/bin/env node /opt/openvibe.media/server/index.js
+Restart=always
+RestartSec=3
+TimeoutStopSec=30
+KillSignal=SIGTERM
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/opt/openvibe.media/data
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,2508 @@
@@ -0,0 +1,2508 @@
+{
+ "name": "openvibe-media",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-media",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.600.0",
+ "@aws-sdk/lib-storage": "^3.600.0",
+ "@aws-sdk/s3-request-presigner": "^3.600.0",
+ "better-sqlite3": "^11.0.0",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "multer": "^1.4.5-lts.1",
+ "openvibe-shared": "file:./vendor/openvibe-shared",
+ "sharp": "^0.33.4"
+ }
+ },
+ "node_modules/@aws-sdk/checksums": {
+ "version": "3.1000.28",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",
+ "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.1111.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz",
+ "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/checksums": "^3.1000.28",
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/credential-provider-node": "^3.972.80",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.74",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/fetch-http-handler": "^5.6.13",
+ "@smithy/node-http-handler": "^4.9.13",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.977.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz",
+ "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.4",
+ "@aws-sdk/xml-builder": "^3.972.39",
+ "@aws/lambda-invoke-store": "^0.3.0",
+ "@smithy/core": "^3.31.1",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.16.1",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.69",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz",
+ "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.71",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz",
+ "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/fetch-http-handler": "^5.6.13",
+ "@smithy/node-http-handler": "^4.9.13",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.973.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz",
+ "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/credential-provider-env": "^3.972.69",
+ "@aws-sdk/credential-provider-http": "^3.972.71",
+ "@aws-sdk/credential-provider-login": "^3.972.76",
+ "@aws-sdk/credential-provider-process": "^3.972.69",
+ "@aws-sdk/credential-provider-sso": "^3.973.13",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.75",
+ "@aws-sdk/nested-clients": "^3.997.43",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz",
+ "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/nested-clients": "^3.997.43",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.80",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz",
+ "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.69",
+ "@aws-sdk/credential-provider-http": "^3.972.71",
+ "@aws-sdk/credential-provider-ini": "^3.973.14",
+ "@aws-sdk/credential-provider-process": "^3.972.69",
+ "@aws-sdk/credential-provider-sso": "^3.973.13",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.75",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.69",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz",
+ "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.973.13",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz",
+ "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/nested-clients": "^3.997.43",
+ "@aws-sdk/token-providers": "3.1111.0",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.75",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz",
+ "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/nested-clients": "^3.997.43",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/lib-storage": {
+ "version": "3.1111.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1111.0.tgz",
+ "integrity": "sha512-UjLJE0Zxv/m8Xgd3DLBXssZYISgoEPZpJiPWY+MadEdP36oOcjHVsR8ToUq6MMmKdzuBFYB6qGHWS/yKAG4fow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "buffer": "5.6.0",
+ "events": "3.3.0",
+ "stream-browserify": "3.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-s3": "^3.1111.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.74",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz",
+ "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.43",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz",
+ "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/fetch-http-handler": "^5.6.13",
+ "@smithy/node-http-handler": "^4.9.13",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.1111.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz",
+ "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.45",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz",
+ "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1111.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz",
+ "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.8",
+ "@aws-sdk/nested-clients": "^3.997.43",
+ "@aws-sdk/types": "^3.974.4",
+ "@smithy/core": "^3.31.1",
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.974.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz",
+ "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.39",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz",
+ "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.16.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@smithy/core": {
+ "version": "3.33.2",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz",
+ "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
+ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.11.2",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz",
+ "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz",
+ "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",
+ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/bl/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
+ "node_modules/buffer": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
+ "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "engines": [
+ "node >= 0.8"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/multer": {
+ "version": "1.4.5-lts.2",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
+ "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+ "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.0.0",
+ "concat-stream": "^1.5.2",
+ "mkdirp": "^0.5.4",
+ "object-assign": "^4.1.1",
+ "type-is": "^1.6.4",
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.94.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
+ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "resolved": "vendor/openvibe-shared",
+ "link": true
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/stream-browserify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
+ "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "~2.0.4",
+ "readable-stream": "^3.5.0"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar-stream/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "vendor/openvibe-shared": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "name": "openvibe-media",
+ "version": "1.0.0",
+ "description": "OpenVibe.Media — multi-tenant media service (VOD ingest/recording, storage tiering, clips, pastes, files, thumbnails)",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "init-db": "node server/db/init.js",
+ "smoke": "bash scripts/smoke-test.sh"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.600.0",
+ "@aws-sdk/lib-storage": "^3.600.0",
+ "@aws-sdk/s3-request-presigner": "^3.600.0",
+ "better-sqlite3": "^11.0.0",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "multer": "^1.4.5-lts.1",
+ "openvibe-shared": "file:./vendor/openvibe-shared",
+ "sharp": "^0.33.4"
+ }
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+# OpenVibe.Media — end-to-end smoke test.
+# Boots the service against a throwaway data dir, then exercises:
+# vod create → chunk upload → complete → meta (duration+thumbnail) →
+# clip cut → /v range playback → paste (+ /p page + /raw) → file upload →
+# /f fetch → bad API key rejected → cross-tenant key rejected.
+set -u
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+WORK="$ROOT/.smoke"
+PORT="${SMOKE_PORT:-41300}"
+BASE="http://127.0.0.1:$PORT"
+LIVE_KEY="smoke-live-key-1234567890"
+GAMES_KEY="smoke-games-key-0987654321"
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); echo " PASS: $1"; }
+bad() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
+check() { # check <description> <expected> <actual>
+ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi
+}
+jsonget() { # jsonget <json> <dotted.path>
+ node -e 'const o=JSON.parse(process.argv[1]);const v=process.argv[2].split(".").reduce((a,k)=>a?.[k],o);console.log(v===undefined?"":v)' "$1" "$2"
+}
+
+echo "== OpenVibe.Media smoke test =="
+rm -rf "$WORK"; mkdir -p "$WORK/data"
+
+echo "-- generating 5s test clip (ffmpeg testsrc)"
+ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \
+ -f lavfi -i "sine=frequency=440:duration=5" \
+ -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest \
+ "$WORK/test.mp4" >/dev/null 2>&1
+[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }
+
+echo "-- booting server on :$PORT (temp data dir)"
+SEED="[{\"app_id\":\"live\",\"name\":\"Live\",\"api_key\":\"$LIVE_KEY\",\"allowed_origins\":[\"http://localhost:3000\"]},{\"app_id\":\"games\",\"name\":\"Games\",\"api_key\":\"$GAMES_KEY\",\"quota_bytes\":10485760}]"
+env PORT="$PORT" HOST=127.0.0.1 \
+ DB_PATH="$WORK/data/media.db" \
+ VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \
+ PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \
+ FILES_PATH="$WORK/data/files" \
+ MEDIA_APPS_SEED="$SEED" \
+ OV_NETWORK_URL="http://127.0.0.1:1" \
+ node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &
+SRV=$!
+trap 'kill "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null' EXIT
+
+for i in $(seq 1 50); do
+ sleep 0.2
+ if curl -sf "$BASE/healthz" >/dev/null 2>&1; then break; fi
+ if [ "$i" = 50 ]; then bad "server boot"; cat "$WORK/server.log"; exit 1; fi
+done
+ok "server is up ($(curl -s "$BASE/healthz"))"
+
+AUTH="Authorization: Bearer $LIVE_KEY"
+
+echo "-- vod: create"
+R=$(curl -s -X POST "$BASE/api/v1/live/vods" -H "$AUTH" -H 'Content-Type: application/json' \
+ -d '{"title":"Smoke VOD","stream_id":42,"user_id":7}')
+VOD_ID=$(jsonget "$R" id)
+[ -n "$VOD_ID" ] && ok "created vod id=$VOD_ID" || { bad "vod create ($R)"; exit 1; }
+
+echo "-- vod: upload media via chunks endpoint + complete"
+R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/chunks" -H "$AUTH" \
+ -F "chunk=@$WORK/test.mp4;type=video/mp4")
+check "chunk accepted" "created" "$(jsonget "$R" status)"
+R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/complete" -H "$AUTH")
+ST=$(jsonget "$R" vod.status)
+check "complete → vod ready" "ready" "$ST"
+
+echo "-- vod: meta shows duration + thumbnail"
+R=$(curl -s "$BASE/api/v1/live/vods/$VOD_ID" -H "$AUTH")
+DUR=$(jsonget "$R" vod.duration)
+THUMB=$(jsonget "$R" vod.thumbnail_url)
+[ "$DUR" = "5" ] && ok "duration probed = 5s" || bad "duration (got '$DUR')"
+case "$THUMB" in /t/*) ok "thumbnail_url = $THUMB";; *) bad "thumbnail_url (got '$THUMB')";; esac
+TCODE=$(curl -s -o "$WORK/thumb.jpg" -w '%{http_code}' "$BASE$THUMB")
+check "GET $THUMB serves image" "200" "$TCODE"
+
+echo "-- clips: cut a 2s clip"
+R=$(curl -s -X POST "$BASE/api/v1/live/clips" -H "$AUTH" -H 'Content-Type: application/json' \
+ -d "{\"vod_id\":$VOD_ID,\"start_s\":1,\"end_s\":3,\"title\":\"Smoke clip\"}")
+CLIP_ID=$(jsonget "$R" id)
+[ -n "$CLIP_ID" ] && ok "clip queued id=$CLIP_ID (status=$(jsonget "$R" status))" || bad "clip create ($R)"
+CLIP_ST=""
+for i in $(seq 1 60); do
+ sleep 0.5
+ CLIP_ST=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.status)
+ [ "$CLIP_ST" = "ready" ] && break
+ [ "$CLIP_ST" = "failed" ] && break
+done
+check "clip processed" "ready" "$CLIP_ST"
+CDUR=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.duration)
+echo " (clip duration: ${CDUR}s)"
+CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")
+check "GET /c/$CLIP_ID plays" "200" "$CCODE"
+
+echo "-- playback: /v/:id with Range"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v/$VOD_ID")
+check "GET /v/$VOD_ID" "200" "$CODE"
+HDRS=$(curl -s -D - -o /dev/null -H 'Range: bytes=0-99' "$BASE/v/$VOD_ID")
+echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Content" || bad "Range request ($(echo "$HDRS" | head -1))"
+echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
+echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
+
+echo "-- pastes: create + public page + raw"
+R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
+ -d '{"title":"Smoke paste","content":"hello from the smoke test\nline two","user_id":7}')
+SLUG=$(jsonget "$R" slug)
+[ -n "$SLUG" ] && ok "paste created slug=$SLUG" || bad "paste create ($R)"
+PAGE=$(curl -s "$BASE/p/$SLUG")
+echo "$PAGE" | grep -q 'Smoke paste' && ok "/p/$SLUG HTML page renders title" || bad "/p page content"
+echo "$PAGE" | grep -q 'OpenVibe' && ok "/p page carries OpenVibe branding" || bad "/p page branding"
+RAW=$(curl -s "$BASE/p/$SLUG/raw")
+check "/p/$SLUG/raw returns content" "hello from the smoke test
+line two" "$RAW"
+
+echo "-- files: upload + public fetch"
+echo "smoke-file-payload-$(date +%s)" > "$WORK/upload.txt"
+R=$(curl -s -X POST "$BASE/api/v1/live/files" -H "$AUTH" -F "file=@$WORK/upload.txt;type=text/plain")
+FKEY=$(jsonget "$R" key)
+[ -n "$FKEY" ] && ok "file stored key=$FKEY" || bad "file upload ($R)"
+FBODY=$(curl -s "$BASE/f/$FKEY")
+check "GET /f/$FKEY returns payload" "$(cat "$WORK/upload.txt")" "$FBODY"
+FCT=$(curl -s -o /dev/null -w '%{content_type}' "$BASE/f/$FKEY")
+check "file served with stored MIME" "text/plain" "$FCT"
+
+echo "-- auth: bad API key rejected"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods" -H 'Authorization: Bearer wrong-key')
+check "bad key → 401" "401" "$CODE"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods")
+check "no key → 401" "401" "$CODE"
+
+echo "-- tenancy: live key cannot touch /api/v1/games/*"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "$AUTH")
+check "live key on games → 403" "403" "$CODE"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "Authorization: Bearer $GAMES_KEY")
+check "games key on games → 200" "200" "$CODE"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/nosuchapp/vods" -H "$AUTH")
+check "unknown app → 404" "404" "$CODE"
+
+echo "-- shutting down server"
+kill "$SRV" 2>/dev/null
+wait "$SRV" 2>/dev/null
+trap - EXIT
+ok "server stopped"
+
+echo
+echo "== RESULT: $PASS passed, $FAIL failed =="
+[ "$FAIL" = 0 ]
new file mode 100644
new file mode 100644
@@ -0,0 +1,247 @@
@@ -0,0 +1,247 @@
+/**
+ * OpenVibe.Media — Tenancy & Auth middleware
+ *
+ * Two credential types (per CONTRACTS.md, Media API v1):
+ * 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).
+ * Constant-time hash compare against the `apps` row; a key is ONLY valid
+ * for its own `:app` path segment.
+ * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key
+ * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).
+ * Browser endpoints additionally check the Origin header against the
+ * app's allowed_origins list.
+ *
+ * App seeding: MEDIA_APPS_SEED (JSON array) and/or MEDIA_APP_KEYS ("id:key,…")
+ * are upserted on boot; keys are stored as sha256 hashes.
+ */
+'use strict';
+
+const crypto = require('crypto');
+const db = require('./db/database');
+const config = require('./config');
+
+// ── JWKS → PEM (offline user-JWT verification) ───────────────
+
+let _networkPublicKeyPem = null;
+let _jwksTimer = null;
+
+async function fetchNetworkPublicKey() {
+ const url = `${config.network.url}/api/.well-known/jwks`;
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];
+ if (!jwk) throw new Error('JWKS contained no keys');
+ const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });
+ _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });
+ console.log('[Auth] Network JWKS public key loaded');
+ return _networkPublicKeyPem;
+ } catch (err) {
+ console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);
+ return null;
+ }
+}
+
+function startJwksRefresh() {
+ fetchNetworkPublicKey().catch(() => {});
+ _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);
+ if (_jwksTimer.unref) _jwksTimer.unref();
+}
+
+function stopJwksRefresh() {
+ if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }
+}
+
+/** Verify an RS256 user JWT offline. Returns the payload or null. */
+function verifyUserJwt(token) {
+ if (!_networkPublicKeyPem || !token) return null;
+ const parts = String(token).split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
+ if (header.alg !== 'RS256') return null;
+ const ok = crypto.verify(
+ 'RSA-SHA256',
+ Buffer.from(`${parts[0]}.${parts[1]}`),
+ _networkPublicKeyPem,
+ Buffer.from(parts[2], 'base64url')
+ );
+ if (!ok) return null;
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ if (payload.exp && Date.now() / 1000 > payload.exp) return null;
+ if (payload.iss && payload.iss !== config.network.url) return null;
+ // aud must include this service when present.
+ if (payload.aud) {
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
+ if (!aud.includes('openvibe.media')) return null;
+ }
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
+// ── App key check (constant-time) ────────────────────────────
+
+function checkAppKey(app, presentedKey) {
+ if (!app || !presentedKey) return false;
+ const presentedHash = Buffer.from(db.hashApiKey(presentedKey), 'hex');
+ const storedHash = Buffer.from(String(app.api_key_hash || ''), 'hex');
+ if (presentedHash.length !== storedHash.length || !storedHash.length) return false;
+ return crypto.timingSafeEqual(presentedHash, storedHash);
+}
+
+function bearerToken(req) {
+ const h = req.headers.authorization;
+ if (h && h.startsWith('Bearer ')) return h.slice(7).trim();
+ return null;
+}
+
+// ── Middleware ───────────────────────────────────────────────
+
+/**
+ * Resolve the `:app` path segment to req.appRow / req.appId, then authenticate.
+ *
+ * @param {object} opts
+ * @param {boolean} [opts.allowUser=false] also accept a Network user JWT
+ * (browser endpoints); Origin, when present, must be allow-listed.
+ */
+function tenantAuth({ allowUser = false } = {}) {
+ return (req, res, next) => {
+ const appId = String(req.params.app || '').trim();
+ const app = appId ? db.getApp(appId) : null;
+ if (!app) return res.status(404).json({ error: 'Unknown app' });
+ req.appId = appId;
+ req.appRow = app;
+
+ const token = bearerToken(req);
+
+ // 1) App API key — only valid for its own :app segment. A valid key of a
+ // DIFFERENT app must NOT fall through to anything else.
+ if (token && checkAppKey(app, token)) {
+ req.authType = 'app';
+ return next();
+ }
+ if (token && isKeyOfOtherApp(token, appId)) {
+ return res.status(403).json({ error: 'API key not valid for this app' });
+ }
+
+ // 2) Network user JWT (browser endpoints only).
+ if (allowUser && token) {
+ const payload = verifyUserJwt(token);
+ if (payload) {
+ const origin = req.headers.origin;
+ if (origin && !db.appAllowedOrigins(app).includes(origin)) {
+ return res.status(403).json({ error: 'Origin not allowed for this app' });
+ }
+ req.authType = 'user';
+ req.user = payload;
+ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);
+ return next();
+ }
+ }
+
+ return res.status(401).json({ error: 'Authentication required' });
+ };
+}
+
+/** Does the presented key belong to some OTHER app? (for a precise 403) */
+function isKeyOfOtherApp(presentedKey, exceptAppId) {
+ const hash = db.hashApiKey(presentedKey);
+ const row = db.get('SELECT app_id FROM apps WHERE api_key_hash = ?', [hash]);
+ return !!(row && row.app_id !== exceptAppId);
+}
+
+/**
+ * Optional identity on PUBLIC serving routes (/v /c /p /t /f): attaches the
+ * owning app (via any valid app key) or user payload if a credential is sent.
+ * Never rejects.
+ */
+function optionalIdentity(req, _res, next) {
+ const token = bearerToken(req);
+ if (token) {
+ const hash = db.hashApiKey(token);
+ const appRow = db.get('SELECT * FROM apps WHERE api_key_hash = ?', [hash]);
+ if (appRow) {
+ req.authType = 'app';
+ req.appRow = appRow;
+ req.appId = appRow.app_id;
+ } else {
+ const payload = verifyUserJwt(token);
+ if (payload) {
+ req.authType = 'user';
+ req.user = payload;
+ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);
+ }
+ }
+ }
+ next();
+}
+
+/** CORS for tenant API routes — reflects allow-listed origins per app. */
+function tenantCors(req, res, next) {
+ const origin = req.headers.origin;
+ if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Vary', 'Origin');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ }
+ next();
+}
+
+// ── App seeding ──────────────────────────────────────────────
+
+function seedApps() {
+ let seeded = 0;
+
+ if (config.apps.seedJson) {
+ try {
+ const list = JSON.parse(config.apps.seedJson);
+ for (const entry of Array.isArray(list) ? list : []) {
+ if (!entry || !entry.app_id || !entry.api_key) continue;
+ db.upsertApp(entry);
+ seeded++;
+ }
+ } catch (err) {
+ console.error('[Auth] MEDIA_APPS_SEED is not valid JSON:', err.message);
+ }
+ }
+
+ if (config.apps.seedKeys) {
+ for (const pair of config.apps.seedKeys.split(',')) {
+ const idx = pair.indexOf(':');
+ if (idx < 1) continue;
+ const app_id = pair.slice(0, idx).trim();
+ const api_key = pair.slice(idx + 1).trim();
+ if (!app_id || !api_key) continue;
+ // Don't clobber a richer MEDIA_APPS_SEED entry for the same app.
+ const existing = db.getApp(app_id);
+ if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;
+ db.upsertApp({
+ app_id,
+ name: existing?.name || app_id,
+ api_key,
+ webhook_url: existing?.webhook_url || null,
+ webhook_secret: existing?.webhook_secret || null,
+ allowed_origins: existing ? db.appAllowedOrigins(existing) : [],
+ quota_bytes: existing?.quota_bytes || 0,
+ });
+ seeded++;
+ }
+ }
+
+ if (seeded) console.log(`[Auth] Seeded/updated ${seeded} app(s): ${db.listApps().map(a => a.app_id).join(', ')}`);
+ else if (!db.listApps().length) console.warn('[Auth] No apps configured — set MEDIA_APPS_SEED (all API calls will 404)');
+}
+
+module.exports = {
+ tenantAuth,
+ tenantCors,
+ optionalIdentity,
+ verifyUserJwt,
+ seedApps,
+ startJwksRefresh,
+ stopJwksRefresh,
+ fetchNetworkPublicKey,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+/**
+ * OpenVibe.Media — Config
+ *
+ * Lean env-driven configuration per CONTRACTS.md (Media API v1).
+ * Shapes (config.vod.path, config.vod.clipsPath, config.thumbnails.path)
+ * are kept compatible with the inherited vod/thumbnail modules.
+ */
+require('dotenv').config();
+
+const path = require('path');
+
+function intEnv(name, fallback) {
+ const v = parseInt(process.env[name] || '', 10);
+ return Number.isFinite(v) ? v : fallback;
+}
+
+const config = {
+ port: intEnv('PORT', 4100),
+ host: process.env.HOST || '0.0.0.0',
+ nodeEnv: process.env.NODE_ENV || 'development',
+ publicUrl: (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, ''),
+
+ db: {
+ path: process.env.DB_PATH || './data/media.db',
+ },
+
+ vod: {
+ path: process.env.VOD_PATH || './data/vods',
+ clipsPath: process.env.CLIPS_PATH || './data/clips',
+ maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),
+ },
+ pastes: {
+ path: process.env.PASTES_PATH || './data/pastes',
+ },
+ thumbnails: {
+ path: process.env.THUMBNAILS_PATH || './data/thumbnails',
+ },
+ files: {
+ path: process.env.FILES_PATH || './data/files',
+ maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),
+ },
+
+ rtp: {
+ portMin: intEnv('RTP_PORT_MIN', 12000),
+ portMax: intEnv('RTP_PORT_MAX', 12199),
+ },
+
+ network: {
+ url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
+ internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
+ internalApiKey: process.env.INTERNAL_API_KEY || '',
+ },
+
+ apps: {
+ // JSON array: [{app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes}]
+ seedJson: process.env.MEDIA_APPS_SEED || '',
+ // Fallback short form: "live:key1,games:key2"
+ seedKeys: process.env.MEDIA_APP_KEYS || '',
+ },
+};
+
+// Resolve all storage paths to absolute
+for (const p of ['path']) {
+ config.vod[p] = path.resolve(config.vod[p]);
+}
+config.vod.clipsPath = path.resolve(config.vod.clipsPath);
+config.pastes.path = path.resolve(config.pastes.path);
+config.thumbnails.path = path.resolve(config.thumbnails.path);
+config.files.path = path.resolve(config.files.path);
+config.db.path = path.resolve(config.db.path);
+
+module.exports = config;
new file mode 100644
new file mode 100644
@@ -0,0 +1,456 @@
@@ -0,0 +1,456 @@
+/**
+ * OpenVibe.Media — Database
+ *
+ * Lean port of the predecessor's vod/clip/paste helpers with app_id (tenant)
+ * scoping on every query. better-sqlite3 in WAL mode.
+ *
+ * Migration path: `importLegacyRows(table, rows, appId)` bulk-inserts rows from
+ * the old streamer DB unchanged — only columns that exist in the new table are
+ * used (extras ignored), ids preserved, app_id backfilled. The cutover script
+ * can do: ATTACH old db, SELECT * per table, importLegacyRows('vods', rows, 'live').
+ */
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const Database = require('better-sqlite3');
+const config = require('../config');
+
+let database = null;
+
+function getDb() {
+ if (database) return database;
+ const dataDir = path.dirname(config.db.path);
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
+ database = new Database(config.db.path);
+ database.pragma('journal_mode = WAL');
+ const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
+ database.exec(schema);
+ seedSettings();
+ return database;
+}
+
+function seedSettings() {
+ const defaults = [
+ // Clip system
+ ['max_clip_duration', '60', 'Maximum clip length in seconds', 'number'],
+ // Paste system (same keys the inherited routes read)
+ ['paste_max_size_kb', '512', 'Maximum paste content size in KB', 'number'],
+ ['paste_screenshot_max_size_mb', '8', 'Maximum screenshot upload size in MB', 'number'],
+ ['paste_cooldown_seconds', '30', 'Cooldown between paste submissions in seconds (user-JWT callers)', 'number'],
+ ['paste_max_per_user_per_day', '200', 'Maximum pastes per user per day (0 = unlimited)', 'number'],
+ ['paste_comment_cooldown_seconds', '10', 'Cooldown between paste comments in seconds', 'number'],
+ ['paste_comment_max_length', '2000', 'Maximum paste comment length in characters', 'number'],
+ ['paste_comment_anon_allowed', 'true', 'Allow anonymous comments on pastes', 'boolean'],
+ ];
+ const stmt = database.prepare('INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)');
+ for (const [k, v, d, t] of defaults) stmt.run(k, v, d, t);
+}
+
+// ── Generic helpers ──────────────────────────────────────────
+
+function run(sql, params = []) {
+ return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));
+}
+
+function get(sql, params = []) {
+ return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));
+}
+
+function all(sql, params = []) {
+ return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));
+}
+
+function close() {
+ if (database) { try { database.close(); } catch { /* ignore */ } database = null; }
+}
+
+// ── Settings ─────────────────────────────────────────────────
+
+function getSetting(key) {
+ const row = get('SELECT value, type FROM media_settings WHERE key = ?', [key]);
+ if (!row) return null;
+ if (row.type === 'number') return Number(row.value);
+ if (row.type === 'boolean') return row.value === 'true' || row.value === '1';
+ return row.value;
+}
+
+function setSetting(key, value, type = 'string') {
+ run(`INSERT INTO media_settings (key, value, type) VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
+ [key, String(value), type]);
+}
+
+// ── Apps (tenants) ───────────────────────────────────────────
+
+function hashApiKey(key) {
+ return crypto.createHash('sha256').update(String(key)).digest('hex');
+}
+
+function getApp(appId) {
+ return get('SELECT * FROM apps WHERE app_id = ?', [appId]);
+}
+
+function listApps() {
+ return all('SELECT app_id, name, webhook_url, allowed_origins, quota_bytes, created_at FROM apps ORDER BY app_id');
+}
+
+function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {
+ if (!app_id || !api_key) throw new Error('app_id and api_key required');
+ const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);
+ run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(app_id) DO UPDATE SET
+ name = excluded.name,
+ api_key_hash = excluded.api_key_hash,
+ webhook_url = excluded.webhook_url,
+ webhook_secret = excluded.webhook_secret,
+ allowed_origins = excluded.allowed_origins,
+ quota_bytes = excluded.quota_bytes`,
+ [app_id, name || app_id, hashApiKey(api_key), webhook_url || null, webhook_secret || null, origins, quota_bytes || 0]);
+ return getApp(app_id);
+}
+
+function appAllowedOrigins(app) {
+ try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }
+}
+
+// ── VOD helpers ──────────────────────────────────────────────
+
+function createVod({ app_id, stream_id, stream_key, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta }) {
+ return run(
+ `INSERT INTO vods (app_id, stream_id, stream_key, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
+ [app_id, stream_id || null, stream_key || null, user_id || null, title || 'Recording', description || '',
+ file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
+ JSON.stringify(meta || {})]
+ );
+}
+
+function getVodById(id, appId = null) {
+ const clause = appId ? ' AND app_id = ?' : '';
+ const params = appId ? [id, appId] : [id];
+ return get(`SELECT *, COALESCE(duration_seconds, probe_duration_seconds, 0) AS duration_seconds
+ FROM vods WHERE id = ?${clause}`, params);
+}
+
+function listVods(appId, { limit = 50, offset = 0, user_id = null, stream_id = null, includeRecording = true } = {}) {
+ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
+ const params = [appId];
+ if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
+ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
+ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
+ params.push(limit, offset);
+ return all(`SELECT * FROM vods WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
+}
+
+function countVods(appId, { user_id = null, stream_id = null } = {}) {
+ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
+ const params = [appId];
+ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
+ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
+ return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
+}
+
+const VALID_VISIBILITY = new Set(['public', 'unlisted', 'private']);
+function _normVisibility(v) { return VALID_VISIBILITY.has(v) ? v : 'public'; }
+
+// Set VOD/clip visibility; is_public mirrors (1 iff public) so listing filters hold.
+function setVodVisibility(vodId, visibility) {
+ const vis = _normVisibility(visibility);
+ return run('UPDATE vods SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, vodId]);
+}
+
+function updateVodHealth(vodId, { status, score, issues = [], probeDuration, probeFormat, quarantine = false, keepPublic = false }) {
+ const updates = [];
+ const params = [];
+ if (status) { updates.push('health_status = ?'); params.push(status); }
+ if (typeof score === 'number') { updates.push('health_score = ?'); params.push(score); }
+ if (issues) { updates.push('health_issues_json = ?'); params.push(JSON.stringify(issues)); }
+ if (typeof probeDuration === 'number') { updates.push('probe_duration_seconds = ?'); params.push(probeDuration); }
+ if (probeFormat !== undefined) { updates.push('probe_format_json = ?'); params.push(JSON.stringify(probeFormat || {})); }
+ if (quarantine) {
+ updates.push("quarantined_at = datetime('now')");
+ if (!keepPublic) updates.push('is_public = 0');
+ }
+ updates.push("last_health_scan_at = datetime('now')");
+ params.push(vodId);
+ if (!updates.length) return null;
+ return run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
+}
+
+function repairVodDuration(vodId, duration, fileSize) {
+ return run(
+ `UPDATE vods SET duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, last_health_scan_at = datetime('now') WHERE id = ?`,
+ [duration, fileSize, duration, vodId]
+ );
+}
+
+// VODs the periodic health job should scan: finished (not recording), and either never
+// scanned or last scanned longer ago than `staleDays`. Never-scanned + oldest-scanned first.
+function getVodsNeedingHealthScan({ staleDays = 30, limit = 3 } = {}) {
+ return all(`SELECT * FROM vods
+ WHERE COALESCE(is_recording, 0) = 0
+ AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file'))
+ AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?))
+ ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC
+ LIMIT ?`, [`-${Math.max(1, staleDays)} days`, limit]);
+}
+
+// Genuinely-broken VODs quarantined long enough that they should be cleaned up.
+function getQuarantinedVodsForCleanup({ graceDays = 14, limit = 5 } = {}) {
+ return all(`SELECT * FROM vods
+ WHERE quarantined_at IS NOT NULL
+ AND quarantined_at <= datetime('now', ?)
+ AND health_status IN ('corrupt','zero_byte','missing_file')
+ AND COALESCE(is_recording, 0) = 0
+ ORDER BY quarantined_at ASC
+ LIMIT ?`, [`-${Math.max(1, graceDays)} days`, limit]);
+}
+
+// Derived VOD status for the API: pending → recording → ready | failed.
+function vodStatus(vod) {
+ if (!vod) return 'unknown';
+ if (vod.is_recording) return 'recording';
+ if (['corrupt', 'zero_byte', 'missing_file'].includes(vod.health_status)) return 'failed';
+ if (vod.file_path && (vod.duration_seconds > 0 || vod.file_size > 0)) return 'ready';
+ return 'pending';
+}
+
+// ── Clip helpers ─────────────────────────────────────────────
+
+function createClip({ app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, auto_generated, status }) {
+ const pub = (is_public === 0 || is_public === false) ? 0 : 1;
+ return run(
+ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [app_id, vod_id || null, stream_id || null, user_id || null, title || 'Untitled Clip', description || '',
+ file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
+ pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']
+ );
+}
+
+function getClipById(id, appId = null) {
+ const clause = appId ? ' AND app_id = ?' : '';
+ const params = appId ? [id, appId] : [id];
+ return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
+}
+
+function listClips(appId, { limit = 50, offset = 0, vod_id = null, stream_id = null, user_id = null } = {}) {
+ const conds = ['app_id = ?'];
+ const params = [appId];
+ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
+ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
+ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
+ params.push(limit, offset);
+ return all(`SELECT * FROM clips WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
+}
+
+function countClips(appId, { vod_id = null, stream_id = null, user_id = null } = {}) {
+ const conds = ['app_id = ?'];
+ const params = [appId];
+ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
+ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
+ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
+ return get(`SELECT COUNT(*) AS count FROM clips WHERE ${conds.join(' AND ')}`, params)?.count || 0;
+}
+
+function setClipVisibility(clipId, visibility) {
+ const vis = _normVisibility(visibility);
+ return run('UPDATE clips SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, clipId]);
+}
+
+function findDuplicateClip({ appId, streamId = null, vodId = null, startTime = 0, endTime = 0, startWindow = 8, endWindow = 10, createdSinceMinutes = 10 }) {
+ const filters = [];
+ const params = [appId];
+ if (streamId) { filters.push('stream_id = ?'); params.push(streamId); }
+ if (vodId) { filters.push('vod_id = ?'); params.push(vodId); }
+ if (!filters.length) return null;
+ return get(`
+ SELECT * FROM clips
+ WHERE app_id = ? AND (${filters.join(' OR ')})
+ AND ABS(COALESCE(start_time, 0) - ?) <= ?
+ AND ABS(COALESCE(end_time, 0) - ?) <= ?
+ AND created_at >= datetime('now', ?)
+ ORDER BY created_at DESC
+ LIMIT 1
+ `, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);
+}
+
+// ── Paste helpers ────────────────────────────────────────────
+
+function getPasteBySlug(slug, appId = null) {
+ const clause = appId ? ' AND app_id = ?' : '';
+ const params = appId ? [slug, appId] : [slug];
+ return get(`SELECT * FROM pastes WHERE slug = ?${clause}`, params);
+}
+
+function likePaste(pasteId, userId) {
+ run('INSERT OR IGNORE INTO paste_likes (paste_id, user_id) VALUES (?, ?)', [pasteId, userId]);
+ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
+ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
+}
+
+function unlikePaste(pasteId, userId) {
+ run('DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
+ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
+ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
+}
+
+function hasUserLikedPaste(pasteId, userId) {
+ return !!get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
+}
+
+function incrementPasteCopies(slug) {
+ return run('UPDATE pastes SET copies = copies + 1 WHERE slug = ?', [slug]);
+}
+
+function countUserPastesToday(appId, userId, ip) {
+ if (userId) {
+ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')", [appId, userId])?.c || 0;
+ }
+ if (ip) {
+ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')", [appId, ip])?.c || 0;
+ }
+ return 0;
+}
+
+function getLastPasteTime(appId, userId, ip) {
+ let row;
+ if (userId) {
+ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1', [appId, userId]);
+ } else if (ip) {
+ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1', [appId, ip]);
+ }
+ return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;
+}
+
+// ── Paste comment helpers ────────────────────────────────────
+
+function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
+ return run(
+ `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]
+ );
+}
+
+function getPasteComments(pasteId, limit = 50, offset = 0) {
+ return all(`
+ SELECT * FROM paste_comments
+ WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ `, [pasteId, limit, offset]);
+}
+
+function getPasteCommentReplies(parentId) {
+ return all(`
+ SELECT * FROM paste_comments
+ WHERE parent_id = ? AND is_deleted = 0
+ ORDER BY created_at ASC
+ `, [parentId]);
+}
+
+function getPasteCommentById(commentId) {
+ return get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);
+}
+
+function getPasteCommentCount(pasteId) {
+ return get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId])?.count || 0;
+}
+
+function deletePasteComment(commentId) {
+ return run('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [commentId]);
+}
+
+function getRecentPasteCommentsByIp(ip, seconds = 10) {
+ return all(`
+ SELECT * FROM paste_comments
+ WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')
+ ORDER BY created_at DESC
+ `, [ip, seconds]);
+}
+
+// ── File helpers ─────────────────────────────────────────────
+
+function createFile({ key, app_id, user_id, original_name, size, mime, sha256 }) {
+ return run(
+ `INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [key, app_id, user_id || null, original_name || null, size || 0, mime || 'application/octet-stream', sha256 || null]
+ );
+}
+
+function getFileByKey(key, appId = null) {
+ const clause = appId ? ' AND app_id = ?' : '';
+ const params = appId ? [key, appId] : [key];
+ return get(`SELECT * FROM files WHERE key = ?${clause}`, params);
+}
+
+function listFiles(appId, { limit = 100, offset = 0 } = {}) {
+ return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);
+}
+
+function deleteFileRow(key) {
+ return run('DELETE FROM files WHERE key = ?', [key]);
+}
+
+function appFilesBytes(appId) {
+ return get('SELECT COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?', [appId])?.bytes || 0;
+}
+
+// ── Legacy import (cutover from the old streamer DB) ─────────
+
+/**
+ * Bulk-insert rows exported from the predecessor DB. Only columns present in
+ * the destination table are used; missing ones take schema defaults; app_id is
+ * backfilled. Row ids are preserved (INSERT OR IGNORE keeps re-runs idempotent).
+ * Returns { inserted, skipped }.
+ */
+function importLegacyRows(table, rows, appId = 'live') {
+ const allowed = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views', 'files']);
+ if (!allowed.has(table)) throw new Error(`importLegacyRows: table ${table} not importable`);
+ if (!Array.isArray(rows) || !rows.length) return { inserted: 0, skipped: 0 };
+
+ const cols = getDb().prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
+ const colSet = new Set(cols);
+ const hasAppId = colSet.has('app_id');
+
+ let inserted = 0, skipped = 0;
+ const tx = getDb().transaction((batch) => {
+ for (const raw of batch) {
+ const row = { ...raw };
+ if (hasAppId && row.app_id == null) row.app_id = appId;
+ const keys = Object.keys(row).filter(k => colSet.has(k));
+ if (!keys.length) { skipped++; continue; }
+ const sql = `INSERT OR IGNORE INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`;
+ const res = getDb().prepare(sql).run(...keys.map(k => row[k]));
+ if (res.changes > 0) inserted++; else skipped++;
+ }
+ });
+ tx(rows);
+ return { inserted, skipped };
+}
+
+module.exports = {
+ getDb, run, get, all, close,
+ getSetting, setSetting,
+ // apps
+ hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
+ // vods
+ createVod, getVodById, listVods, countVods, setVodVisibility, vodStatus,
+ updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
+ // clips
+ createClip, getClipById, listClips, countClips, setClipVisibility, findDuplicateClip,
+ // pastes
+ getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
+ countUserPastesToday, getLastPasteTime,
+ createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
+ getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
+ // files
+ createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,
+ // migration
+ importLegacyRows,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+/**
+ * OpenVibe.Media — Database Initialization Script
+ * Run: npm run init-db
+ */
+const db = require('./database');
+
+db.getDb();
+console.log(`Database initialized at: ${require('../config').db.path}`);
+db.close();
+process.exit(0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+-- OpenVibe.Media — Schema
+-- Multi-tenant media service. Every domain table carries app_id (tenant key,
+-- default 'live' so rows bulk-imported from the predecessor DB need no rewrite).
+
+PRAGMA journal_mode = WAL;
+
+-- ── Tenants ──────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS apps (
+ app_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL DEFAULT '',
+ api_key_hash TEXT NOT NULL,
+ webhook_url TEXT,
+ webhook_secret TEXT,
+ allowed_origins TEXT DEFAULT '[]', -- JSON array of origins for browser (user-JWT) calls
+ quota_bytes INTEGER DEFAULT 0, -- files quota; 0 = unlimited
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- ── VODs ─────────────────────────────────────────────────────
+-- Full column set inherited from the predecessor (health, probe, tiering,
+-- clips_only, is_recording, last_accessed_at) + tenant/ingest metadata.
+CREATE TABLE IF NOT EXISTS vods (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL DEFAULT 'live',
+ stream_id INTEGER,
+ stream_key TEXT,
+ user_id INTEGER,
+ title TEXT,
+ description TEXT DEFAULT '',
+ file_path TEXT,
+ thumbnail_url TEXT,
+ master_file_path TEXT,
+ file_size INTEGER DEFAULT 0,
+ duration_seconds INTEGER DEFAULT 0,
+ probe_duration_seconds REAL DEFAULT 0,
+ probe_format_json TEXT DEFAULT '',
+ health_status TEXT DEFAULT 'unknown',
+ health_score INTEGER DEFAULT 0,
+ health_issues_json TEXT DEFAULT '[]',
+ last_health_scan_at DATETIME,
+ quarantined_at DATETIME,
+ is_public INTEGER DEFAULT 1,
+ visibility TEXT DEFAULT 'public',
+ is_recording INTEGER DEFAULT 0,
+ clips_only INTEGER DEFAULT 0,
+ view_count INTEGER DEFAULT 0,
+ storage_tier TEXT DEFAULT 'hot', -- legacy column (pre object-store); kept for import
+ storage_provider TEXT DEFAULT 'local',
+ storage_key TEXT,
+ last_accessed_at DATETIME,
+ ai_overview TEXT, -- kept for import; Live owns AI generation
+ ai_transcript TEXT,
+ ai_analyzed_at DATETIME,
+ meta_json TEXT DEFAULT '{}',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_vods_app ON vods(app_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_vods_recording ON vods(is_recording);
+CREATE INDEX IF NOT EXISTS idx_vods_provider ON vods(storage_provider);
+
+-- ── Clips ────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS clips (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL DEFAULT 'live',
+ vod_id INTEGER,
+ stream_id INTEGER,
+ user_id INTEGER,
+ title TEXT DEFAULT 'Untitled Clip',
+ description TEXT DEFAULT '',
+ file_path TEXT,
+ thumbnail_url TEXT,
+ start_time REAL NOT NULL DEFAULT 0,
+ end_time REAL NOT NULL DEFAULT 0,
+ duration_seconds REAL DEFAULT 0,
+ is_public INTEGER DEFAULT 1,
+ visibility TEXT DEFAULT 'public',
+ status TEXT DEFAULT 'ready', -- processing | ready | failed (imports default ready)
+ view_count INTEGER DEFAULT 0,
+ auto_generated INTEGER DEFAULT 0,
+ storage_provider TEXT DEFAULT 'local',
+ storage_key TEXT,
+ ai_overview TEXT,
+ ai_transcript TEXT,
+ ai_analyzed_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_clips_app ON clips(app_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_clips_vod ON clips(vod_id);
+CREATE INDEX IF NOT EXISTS idx_clips_stream ON clips(stream_id);
+
+-- ── Unique view tracking (IP dedup for VODs and clips) ───────
+CREATE TABLE IF NOT EXISTS content_views (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')),
+ content_id INTEGER NOT NULL,
+ ip TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(content_type, content_id, ip)
+);
+CREATE INDEX IF NOT EXISTS idx_content_views_lookup ON content_views(content_type, content_id);
+
+-- ── Pastes ───────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS pastes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL DEFAULT 'live',
+ slug TEXT UNIQUE NOT NULL,
+ user_id INTEGER,
+ type TEXT DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
+ title TEXT NOT NULL DEFAULT 'Untitled',
+ content TEXT,
+ language TEXT DEFAULT 'text',
+ visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
+ stream_id INTEGER,
+ screenshot_path TEXT,
+ metadata TEXT,
+ burn_after_read INTEGER DEFAULT 0,
+ forked_from INTEGER,
+ pinned INTEGER DEFAULT 0,
+ views INTEGER DEFAULT 0,
+ copies INTEGER DEFAULT 0,
+ likes INTEGER DEFAULT 0,
+ is_nsfw INTEGER DEFAULT 0,
+ ip_address TEXT,
+ ai_summary TEXT, -- kept for import; Live owns AI generation
+ ai_tags TEXT,
+ ai_analyzed_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_pastes_slug ON pastes(slug);
+CREATE INDEX IF NOT EXISTS idx_pastes_app ON pastes(app_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_pastes_user ON pastes(user_id);
+CREATE INDEX IF NOT EXISTS idx_pastes_visibility ON pastes(visibility);
+
+CREATE TABLE IF NOT EXISTS paste_likes (
+ paste_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (paste_id, user_id),
+ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS paste_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ paste_id INTEGER NOT NULL,
+ user_id INTEGER,
+ parent_id INTEGER,
+ anon_name TEXT,
+ message TEXT NOT NULL,
+ ip_address TEXT,
+ is_deleted INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,
+ FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id);
+CREATE INDEX IF NOT EXISTS idx_paste_comments_ip ON paste_comments(ip_address);
+
+-- ── Files (new: generic per-app file storage) ────────────────
+CREATE TABLE IF NOT EXISTS files (
+ key TEXT PRIMARY KEY,
+ app_id TEXT NOT NULL DEFAULT 'live',
+ user_id INTEGER,
+ original_name TEXT,
+ size INTEGER NOT NULL DEFAULT 0,
+ mime TEXT DEFAULT 'application/octet-stream',
+ sha256 TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_files_app ON files(app_id, created_at DESC);
+
+-- ── Service settings (storage-tier knobs, paste limits, …) ───
+-- Same key names the inherited modules used in site_settings.
+CREATE TABLE IF NOT EXISTS media_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ description TEXT DEFAULT '',
+ type TEXT DEFAULT 'string',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+/**
+ * OpenVibe.Media — Files API (mounted at /api/v1/:app/files)
+ *
+ * Generic per-app file storage (new subsystem — no predecessor equivalent):
+ *
+ * POST / multipart 'file' → { key, url, size, mime }
+ * GET / list (?limit&offset)
+ * GET /:key meta
+ * DELETE /:key delete
+ *
+ * Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.
+ * Public serving (Content-Type + Range) is at GET /f/:key.
+ * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the sum of
+ * the app's stored file sizes.
+ */
+'use strict';
+
+const express = require('express');
+const crypto = require('crypto');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const db = require('../db/database');
+const config = require('../config');
+const { tenantAuth, tenantCors } = require('../auth');
+
+const router = express.Router({ mergeParams: true });
+router.use(tenantCors);
+
+const upload = multer({
+ dest: path.join(config.files.path, '.tmp'),
+ limits: { fileSize: config.files.maxSizeMb * 1024 * 1024 },
+});
+
+function sanitizeName(name) {
+ const base = path.basename(String(name || 'file'));
+ return base.replace(/[^a-zA-Z0-9._\-]/g, '_').slice(0, 120) || 'file';
+}
+
+function appDir(appId) {
+ const dir = path.join(config.files.path, appId);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function filePathForKey(row) {
+ return path.join(config.files.path, row.app_id, row.key);
+}
+
+function filePublic(row) {
+ return {
+ key: row.key,
+ app_id: row.app_id,
+ user_id: row.user_id,
+ original_name: row.original_name,
+ size: row.size,
+ mime: row.mime,
+ sha256: row.sha256,
+ url: `/f/${row.key}`,
+ created_at: row.created_at,
+ };
+}
+
+function sha256File(filePath) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256');
+ const stream = fs.createReadStream(filePath);
+ stream.on('data', d => hash.update(d));
+ stream.on('end', () => resolve(hash.digest('hex')));
+ stream.on('error', reject);
+ });
+}
+
+// ── Upload ───────────────────────────────────────────────────
+router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
+
+ // Per-app quota check against quota_bytes (0 = unlimited).
+ const quota = Number(req.appRow.quota_bytes) || 0;
+ if (quota > 0) {
+ const used = db.appFilesBytes(req.appId);
+ if (used + req.file.size > quota) {
+ try { fs.unlinkSync(req.file.path); } catch { /* */ }
+ return res.status(413).json({
+ error: 'App file quota exceeded',
+ quota_bytes: quota,
+ used_bytes: used,
+ });
+ }
+ }
+
+ const digest = await sha256File(req.file.path);
+ const name = sanitizeName(req.file.originalname);
+ const key = `${digest.slice(0, 12)}-${name}`;
+
+ const existing = db.getFileByKey(key);
+ if (existing) {
+ // Same content + name already stored (any app hash-collides only on
+ // identical bytes+name for its own app; cross-app: key is global, so
+ // treat a foreign owner as a conflict).
+ try { fs.unlinkSync(req.file.path); } catch { /* */ }
+ if (existing.app_id !== req.appId) {
+ return res.status(409).json({ error: 'Key conflict — rename the file and retry' });
+ }
+ return res.status(200).json({ ...filePublic(existing), deduplicated: true });
+ }
+
+ const dest = path.join(appDir(req.appId), key);
+ try {
+ fs.renameSync(req.file.path, dest);
+ } catch {
+ fs.copyFileSync(req.file.path, dest);
+ try { fs.unlinkSync(req.file.path); } catch { /* */ }
+ }
+
+ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
+ db.createFile({
+ key,
+ app_id: req.appId,
+ user_id: userId,
+ original_name: req.file.originalname || name,
+ size: req.file.size,
+ mime: req.file.mimetype || 'application/octet-stream',
+ sha256: digest,
+ });
+
+ const row = db.getFileByKey(key, req.appId);
+ console.log(`[Files] Stored ${key} for app ${req.appId} (${(req.file.size / 1024).toFixed(1)} KB)`);
+ res.status(201).json(filePublic(row));
+ } catch (err) {
+ console.error('[Files] Upload error:', err.message);
+ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
+ res.status(500).json({ error: 'Failed to store file' });
+ }
+});
+
+// ── List ─────────────────────────────────────────────────────
+router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const files = db.listFiles(req.appId, { limit, offset });
+ res.json({
+ files: files.map(filePublic),
+ used_bytes: db.appFilesBytes(req.appId),
+ quota_bytes: Number(req.appRow.quota_bytes) || 0,
+ limit, offset,
+ });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to list files' });
+ }
+});
+
+// ── Meta ─────────────────────────────────────────────────────
+router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const row = db.getFileByKey(String(req.params.key), req.appId);
+ if (!row) return res.status(404).json({ error: 'File not found' });
+ res.json(filePublic(row));
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to get file' });
+ }
+});
+
+// ── Delete ───────────────────────────────────────────────────
+router.delete('/:key', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const row = db.getFileByKey(String(req.params.key), req.appId);
+ if (!row) return res.status(404).json({ error: 'File not found' });
+ if (req.authType === 'user' && !(req.userId != null && row.user_id === req.userId)) {
+ return res.status(403).json({ error: 'Not authorized to delete this file' });
+ }
+
+ const filePath = filePathForKey(row);
+ try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }
+ db.deleteFileRow(row.key);
+ res.json({ message: 'File deleted' });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to delete file' });
+ }
+});
+
+module.exports = router;
+module.exports.filePathForKey = filePathForKey;
+module.exports.filePublic = filePublic;
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+/**
+ * OpenVibe.Media — service entrypoint (port 4100)
+ *
+ * Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser
+ * chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,
+ * pastes, files, thumbnails, and HMAC-signed webhooks. See CONTRACTS.md
+ * (Media API v1) and README.md.
+ */
+'use strict';
+
+const fs = require('fs');
+const express = require('express');
+const config = require('./config');
+const db = require('./db/database');
+const auth = require('./auth');
+
+// ── Bootstrap ────────────────────────────────────────────────
+
+for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, config.thumbnails.path, config.files.path]) {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+}
+
+db.getDb(); // init schema (WAL)
+auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
+auth.startJwksRefresh();
+
+const app = express();
+app.set('trust proxy', true);
+app.use(express.json({ limit: '2mb' }));
+app.use(express.urlencoded({ extended: true, limit: '2mb' }));
+
+// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
+app.options('/api/v1/:app/*', (req, res) => {
+ const origin = req.headers.origin;
+ const appRow = db.getApp(String(req.params.app || ''));
+ if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Vary', 'Origin');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ }
+ res.sendStatus(204);
+});
+
+// ── Routes ───────────────────────────────────────────────────
+
+app.get('/healthz', (req, res) => {
+ res.json({
+ ok: true,
+ service: 'openvibe-media',
+ apps: db.listApps().length,
+ recordings: require('./vod/recorder').activeCount(),
+ uptime_s: Math.round(process.uptime()),
+ });
+});
+
+app.use('/api/v1/:app/vods', require('./vod/routes'));
+app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
+app.use('/api/v1/:app/pastes', require('./pastes/routes'));
+app.use('/api/v1/:app/files', require('./files/routes'));
+app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
+app.use('/', require('./public/routes')); // /v /c /p /t /f
+
+// eslint-disable-next-line no-unused-vars
+app.use((err, req, res, next) => {
+ console.error('[HTTP] Unhandled error:', err.message);
+ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Internal error' });
+});
+
+// ── Background jobs ──────────────────────────────────────────
+
+const recorder = require('./vod/recorder');
+const vodStorage = require('./vod/vod-storage');
+const healthJob = require('./vod/health-job');
+const thumbService = require('./thumbnails/thumbnail-service');
+
+const timers = [];
+function every(ms, fn) {
+ const t = setInterval(fn, ms);
+ if (t.unref) t.unref();
+ timers.push(t);
+}
+
+vodStorage.checkProviders()
+ .then(() => vodStorage.migrateLegacy().catch(() => {}))
+ .catch(err => console.warn('[Boot] Provider check failed:', err.message));
+vodStorage.start(); // tiering sweep
+healthJob.start(); // health scan + quarantine cleanup
+every(60 * 1000, () => recorder.checkDisk()); // disk guardian
+every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs
+
+// Recover from an unclean shutdown: rows stuck in is_recording with no live
+// ffmpeg are finalized from whatever hit the disk.
+setTimeout(() => {
+ try {
+ const stuck = db.all('SELECT id FROM vods WHERE is_recording = 1');
+ for (const row of stuck) {
+ if (recorder.isRecording(row.id)) continue;
+ console.log(`[Boot] Finalizing orphaned recording vod ${row.id}`);
+ require('./vod/finalize').finalizeVod(row.id).catch(() => {});
+ }
+ } catch (err) {
+ console.warn('[Boot] Orphan finalize sweep failed:', err.message);
+ }
+}, 5000).unref?.();
+
+// ── Listen + graceful shutdown ───────────────────────────────
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Media] OpenVibe.Media listening on ${config.host}:${config.port} (${config.nodeEnv})`);
+ console.log(`[Media] Data: db=${config.db.path} vods=${config.vod.path} clips=${config.vod.clipsPath}`);
+ console.log(`[Media] RTP ingest pool: udp ${config.rtp.portMin}-${config.rtp.portMax} (127.0.0.1)`);
+});
+
+let shuttingDown = false;
+function shutdown(signal) {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ console.log(`[Media] ${signal} received — shutting down`);
+ try { recorder.stopAll(); } catch { /* */ }
+ try { vodStorage.stop(); } catch { /* */ }
+ try { healthJob.stop(); } catch { /* */ }
+ try { auth.stopJwksRefresh(); } catch { /* */ }
+ for (const t of timers) clearInterval(t);
+ server.close(() => {
+ try { db.close(); } catch { /* */ }
+ process.exit(0);
+ });
+ // Recordings get STOP_GRACE_MS to flush trailers; don't hang forever.
+ setTimeout(() => {
+ console.warn('[Media] Forced exit after shutdown grace');
+ try { db.close(); } catch { /* */ }
+ process.exit(0);
+ }, 70_000).unref();
+}
+process.on('SIGINT', () => shutdown('SIGINT'));
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+
+module.exports = { app, server };
new file mode 100644
new file mode 100644
@@ -0,0 +1,627 @@
@@ -0,0 +1,627 @@
+/**
+ * OpenVibe.Media — Pastes API (mounted at /api/v1/:app/pastes)
+ *
+ * Pastebin-style sharing ported from the predecessor: text pastes, code
+ * snippets, and screenshots (EXIF-stripped via sharp). Public pages live at
+ * /p/:slug (HTML) and /p/:slug/raw.
+ *
+ * POST / { title?, content?, language?, user_id?, visibility?,
+ * burn_after_read?, is_nsfw?, screenshot (multipart)? }
+ * → { id, slug, url }
+ * GET / list (?limit&offset&type&search&user_id)
+ * GET /:slug paste meta/content
+ * PUT /:slug update
+ * DELETE /:slug delete (+ screenshot local & legacy B2)
+ * POST /:slug/fork fork a text paste
+ * POST /:slug/like toggle-like (user identity required)
+ * POST /:slug/copy track a copy event
+ * GET /:slug/comments list comments (+replies)
+ * POST /:slug/comments add comment
+ * DELETE /:slug/comments/:id delete comment
+ *
+ * Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are
+ * trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)
+ * but the columns remain for imported rows.
+ */
+'use strict';
+
+const express = require('express');
+const crypto = require('crypto');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const sharp = require('sharp');
+const db = require('../db/database');
+const config = require('../config');
+const { tenantAuth, tenantCors } = require('../auth');
+
+const router = express.Router({ mergeParams: true });
+router.use(tenantCors);
+
+// ── Screenshot upload storage ───────────────────────────────
+const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');
+if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
+
+const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
+const screenshotStorage = multer.diskStorage({
+ destination: (_req, _file, cb) => cb(null, SCREENSHOTS_DIR),
+ filename: (_req, file, cb) => {
+ const ext = MIME_TO_EXT[file.mimetype] || '.png';
+ cb(null, `ss-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);
+ },
+});
+const screenshotUpload = multer({
+ storage: screenshotStorage,
+ limits: { fileSize: 16 * 1024 * 1024 }, // hard cap; per-app setting enforced below
+ fileFilter: (_req, file, cb) => {
+ if (/^image\/(png|jpeg|webp|gif)$/.test(file.mimetype)) cb(null, true);
+ else cb(new Error('Only PNG, JPEG, WebP, or GIF images allowed'));
+ },
+});
+
+// ── Helpers ─────────────────────────────────────────────────
+
+// Word pools for readable paste slugs (adj-noun-number)
+const SLUG_ADJECTIVES = [
+ 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
+ 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
+ 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
+ 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
+ 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
+ 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
+ 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
+ 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
+ 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
+ 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
+ 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
+ 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
+ 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
+ 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
+ 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
+];
+const SLUG_NOUNS = [
+ 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
+ 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
+ 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
+ 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
+ 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
+ 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
+ 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
+ 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
+ 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
+ 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
+ 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
+ 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
+ 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
+ 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
+ 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
+ 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
+ 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
+];
+
+function generateSlug(attempts = 0) {
+ if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');
+ const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];
+ const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];
+ const num = crypto.randomInt(10, 100); // 10–99
+ const slug = `${adj}-${noun}-${num}`;
+ const existing = db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);
+ if (existing) return generateSlug(attempts + 1);
+ return slug;
+}
+
+function sanitizeTitle(title) {
+ return String(title || '').trim().slice(0, 200) || 'Untitled';
+}
+
+function detectLanguage(content, hint) {
+ if (hint && hint !== 'auto') return hint;
+ const first = String(content || '').slice(0, 500);
+ if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';
+ if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';
+ if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';
+ if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';
+ if (/^\{[\s\n]*"/.test(first)) return 'json';
+ if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';
+ if (/^#!\/(bin|usr)/m.test(first)) return 'bash';
+ if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';
+ if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';
+ if (/^<\?php/m.test(first)) return 'php';
+ if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';
+ return 'text';
+}
+
+function getPasteConfig() {
+ return {
+ maxSizeKb: Number(db.getSetting('paste_max_size_kb')) || 512,
+ screenshotMaxSizeMb: Number(db.getSetting('paste_screenshot_max_size_mb')) || 8,
+ cooldownSeconds: Number(db.getSetting('paste_cooldown_seconds')) || 30,
+ maxPerUserPerDay: Number(db.getSetting('paste_max_per_user_per_day')) || 0,
+ };
+}
+
+function screenshotUrl(paste) {
+ return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;
+}
+
+function pastePublic(paste) {
+ if (!paste) return null;
+ return {
+ id: paste.id,
+ app_id: paste.app_id,
+ slug: paste.slug,
+ user_id: paste.user_id,
+ type: paste.type,
+ title: paste.title,
+ content: paste.content,
+ language: paste.language,
+ visibility: paste.visibility,
+ stream_id: paste.stream_id,
+ screenshot_url: screenshotUrl(paste),
+ metadata: paste.metadata,
+ burn_after_read: !!paste.burn_after_read,
+ forked_from: paste.forked_from,
+ pinned: !!paste.pinned,
+ views: paste.views || 0,
+ copies: paste.copies || 0,
+ likes: paste.likes || 0,
+ is_nsfw: !!paste.is_nsfw,
+ url: `/p/${paste.slug}`,
+ raw_url: `/p/${paste.slug}/raw`,
+ created_at: paste.created_at,
+ updated_at: paste.updated_at,
+ };
+}
+
+// Effective actor for rate limits: user-JWT callers get cooldowns; app-key
+// callers are trusted server-to-server (their platform enforces its own).
+function _actor(req) {
+ if (req.authType === 'user') return { userId: req.userId, ip: req.ip, limited: true };
+ return { userId: (req.body && req.body.user_id) || null, ip: req.ip, limited: false };
+}
+
+function _rateLimitCheck(req, res) {
+ const actor = _actor(req);
+ if (!actor.limited) return true;
+ const cfg = getPasteConfig();
+ if (cfg.cooldownSeconds > 0) {
+ const lastTime = db.getLastPasteTime(req.appId, actor.userId, actor.ip);
+ const elapsed = (Date.now() - lastTime) / 1000;
+ if (elapsed < cfg.cooldownSeconds) {
+ const wait = Math.ceil(cfg.cooldownSeconds - elapsed);
+ res.status(429).json({ error: `Please wait ${wait}s before creating another paste`, cooldown: wait });
+ return false;
+ }
+ }
+ if (cfg.maxPerUserPerDay > 0) {
+ const todayCount = db.countUserPastesToday(req.appId, actor.userId, actor.ip);
+ if (todayCount >= cfg.maxPerUserPerDay) {
+ res.status(429).json({ error: `Daily paste limit reached (${cfg.maxPerUserPerDay}/day)` });
+ return false;
+ }
+ }
+ return true;
+}
+
+function _getPasteScoped(req, res) {
+ const paste = db.getPasteBySlug(String(req.params.slug), req.appId);
+ if (!paste) { res.status(404).json({ error: 'Paste not found' }); return null; }
+ return paste;
+}
+
+// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
+function removePasteScreenshot(paste) {
+ if (!paste) return;
+ if (paste.screenshot_path) {
+ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
+ }
+ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
+}
+
+// ── List pastes ─────────────────────────────────────────────
+router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
+ const type = req.query.type; // 'paste', 'screenshot', or undefined for all
+ const search = req.query.search ? `%${req.query.search}%` : null;
+ const userId = req.query.user_id != null ? req.query.user_id : null;
+
+ let sql = `SELECT * FROM pastes WHERE app_id = ? AND visibility = 'public'`;
+ const params = [req.appId];
+
+ if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }
+ if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }
+ if (search) { sql += ` AND (title LIKE ? OR content LIKE ?)`; params.push(search, search); }
+
+ const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';
+ sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;
+ params.push(limit, offset);
+
+ const pastes = db.all(sql, params).map(p => ({
+ ...pastePublic(p),
+ content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list
+ }));
+
+ let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? AND visibility = 'public'`;
+ const countParams = [req.appId];
+ if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }
+ if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }
+ if (search) { countSql += ` AND (title LIKE ? OR content LIKE ?)`; countParams.push(search, search); }
+ const { total } = db.get(countSql, countParams);
+
+ res.json({ pastes, total, limit, offset });
+ } catch (err) {
+ console.error('[Pastes] List error:', err.message);
+ res.status(500).json({ error: 'Failed to list pastes' });
+ }
+});
+
+// ── Get single paste by slug ────────────────────────────────
+router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+
+ // Private pastes: owner (or the app itself) only.
+ if (paste.visibility === 'private') {
+ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
+ if (!allowed) return res.status(404).json({ error: 'Paste not found' });
+ }
+
+ paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;
+ res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
+ } catch (err) {
+ console.error('[Pastes] Get error:', err.message);
+ res.status(500).json({ error: 'Failed to get paste' });
+ }
+});
+
+// ── Create paste (text, or screenshot via multipart) ────────
+router.post('/', tenantAuth({ allowUser: true }), screenshotUpload.single('screenshot'), async (req, res) => {
+ try {
+ const body = req.body || {};
+ const cfg = getPasteConfig();
+
+ if (!_rateLimitCheck(req, res)) {
+ if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
+ return;
+ }
+
+ const actor = _actor(req);
+ const userId = req.authType === 'user' ? req.userId : (body.user_id != null ? body.user_id : null);
+ const vis = ['unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';
+ const burn = body.burn_after_read ? 1 : 0;
+ const nsfw = body.is_nsfw ? 1 : 0;
+ const slug = generateSlug();
+
+ // ── Screenshot paste ────────────────────────────────
+ if (req.file) {
+ if (req.file.size > cfg.screenshotMaxSizeMb * 1024 * 1024) {
+ try { fs.unlinkSync(req.file.path); } catch { /* */ }
+ return res.status(400).json({ error: `File too large (max ${cfg.screenshotMaxSizeMb} MB)` });
+ }
+
+ // Strip EXIF/GPS metadata by re-encoding with sharp (GIFs kept as-is
+ // so animations survive).
+ const mime = req.file.mimetype;
+ if (mime === 'image/png' || mime === 'image/jpeg' || mime === 'image/webp') {
+ try {
+ const img = sharp(req.file.path);
+ let buf;
+ if (mime === 'image/png') buf = await img.png().toBuffer();
+ else if (mime === 'image/jpeg') buf = await img.jpeg({ quality: 90 }).toBuffer();
+ else buf = await img.webp({ quality: 90 }).toBuffer();
+ fs.writeFileSync(req.file.path, buf);
+ req.file.size = buf.length;
+ } catch (stripErr) {
+ console.warn('[Pastes] Metadata strip warning:', stripErr.message);
+ // Continue with original file if re-encoding fails
+ }
+ }
+
+ const metadata = JSON.stringify({
+ page_url: body.page_url || null,
+ user_agent: body.user_agent || req.get('user-agent') || null,
+ original_name: req.file.originalname,
+ size_bytes: req.file.size,
+ mime_type: req.file.mimetype,
+ });
+
+ db.run(
+ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, screenshot_path, metadata, burn_after_read, is_nsfw, ip_address)
+ VALUES (?, ?, ?, 'screenshot', ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?)`,
+ [req.appId, slug, userId, sanitizeTitle(body.title || 'Screenshot'),
+ body.description || body.content || '', vis, body.stream_id || null, req.file.path, metadata, burn, nsfw, actor.ip]
+ );
+
+ const paste = db.getPasteBySlug(slug, req.appId);
+ return res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
+ }
+
+ // ── Text paste ──────────────────────────────────────
+ const content = body.content;
+ if (!content || typeof content !== 'string' || content.trim().length === 0) {
+ return res.status(400).json({ error: 'Content is required' });
+ }
+ const maxBytes = cfg.maxSizeKb * 1024;
+ if (content.length > maxBytes) {
+ return res.status(400).json({ error: `Paste too large (max ${cfg.maxSizeKb} KB)` });
+ }
+
+ const lang = detectLanguage(content, body.language);
+ db.run(
+ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, burn_after_read, is_nsfw, ip_address)
+ VALUES (?, ?, ?, 'paste', ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [req.appId, slug, userId, sanitizeTitle(body.title), content.trim(), lang, vis,
+ body.stream_id || null, burn, nsfw, actor.ip]
+ );
+
+ const paste = db.getPasteBySlug(slug, req.appId);
+ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
+ } catch (err) {
+ console.error('[Pastes] Create error:', err.message);
+ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
+ res.status(500).json({ error: 'Failed to create paste' });
+ }
+});
+
+// ── Update paste ────────────────────────────────────────────
+router.put('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
+ return res.status(403).json({ error: 'Not authorized for this paste' });
+ }
+
+ const { title, content, language, visibility, pinned, is_nsfw } = req.body || {};
+ const updates = [];
+ const params = [];
+
+ if (title !== undefined) { updates.push('title = ?'); params.push(sanitizeTitle(title)); }
+ if (content !== undefined && paste.type === 'paste') {
+ const cfg = getPasteConfig();
+ if (content.length > cfg.maxSizeKb * 1024) return res.status(400).json({ error: 'Too large' });
+ updates.push('content = ?'); params.push(content);
+ updates.push('language = ?'); params.push(detectLanguage(content, language));
+ }
+ if (visibility !== undefined) { updates.push('visibility = ?'); params.push(['unlisted', 'private'].includes(visibility) ? visibility : 'public'); }
+ if (is_nsfw !== undefined) { updates.push('is_nsfw = ?'); params.push(is_nsfw ? 1 : 0); }
+ if (pinned !== undefined && req.authType === 'app') {
+ updates.push('pinned = ?'); params.push(pinned ? 1 : 0);
+ }
+
+ if (updates.length === 0) return res.status(400).json({ error: 'Nothing to update' });
+
+ updates.push('updated_at = CURRENT_TIMESTAMP');
+ params.push(paste.id);
+ db.run(`UPDATE pastes SET ${updates.join(', ')} WHERE id = ?`, params);
+ res.json({ paste: pastePublic(db.getPasteBySlug(paste.slug, req.appId)) });
+ } catch (err) {
+ console.error('[Pastes] Update error:', err.message);
+ res.status(500).json({ error: 'Failed to update paste' });
+ }
+});
+
+// ── Delete paste ────────────────────────────────────────────
+router.delete('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
+ return res.status(403).json({ error: 'Not authorized for this paste' });
+ }
+
+ removePasteScreenshot(paste);
+ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('[Pastes] Delete error:', err.message);
+ res.status(500).json({ error: 'Failed to delete paste' });
+ }
+});
+
+// ── Fork (copy) a paste ─────────────────────────────────────
+router.post('/:slug/fork', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const original = _getPasteScoped(req, res);
+ if (!original) return;
+ if (original.type !== 'paste') return res.status(400).json({ error: 'Only text pastes can be forked' });
+ if (!_rateLimitCheck(req, res)) return;
+
+ const actor = _actor(req);
+ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
+ const slug = generateSlug();
+ db.run(
+ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, forked_from, ip_address)
+ VALUES (?, ?, ?, 'paste', ?, ?, ?, 'public', ?, ?)`,
+ [req.appId, slug, userId, `Fork of ${original.title}`,
+ original.content, original.language, original.id, actor.ip]
+ );
+
+ const paste = db.getPasteBySlug(slug, req.appId);
+ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
+ } catch (err) {
+ console.error('[Pastes] Fork error:', err.message);
+ res.status(500).json({ error: 'Failed to fork paste' });
+ }
+});
+
+// ── Like / Unlike a paste ───────────────────────────────────
+router.post('/:slug/like', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
+ if (userId == null) return res.status(400).json({ error: 'user_id required to like' });
+
+ const alreadyLiked = db.hasUserLikedPaste(paste.id, userId);
+ const result = alreadyLiked ? db.unlikePaste(paste.id, userId) : db.likePaste(paste.id, userId);
+ res.json({ liked: !alreadyLiked, likes: result?.likes || 0 });
+ } catch (err) {
+ console.error('[Pastes] Like error:', err.message);
+ res.status(500).json({ error: 'Failed to toggle like' });
+ }
+});
+
+// ── Track a copy event ──────────────────────────────────────
+router.post('/:slug/copy', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+ db.incrementPasteCopies(paste.slug);
+ res.json({ copies: (paste.copies || 0) + 1 });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to track copy' });
+ }
+});
+
+// ═════════════════════════════════════════════════════════════
+// ── Paste Comments (supports anonymous via app callers) ─────
+// ═════════════════════════════════════════════════════════════
+
+// Rate-limit state: IP → timestamp of last comment
+const commentCooldowns = new Map();
+const _cooldownSweep = setInterval(() => {
+ const now = Date.now();
+ for (const [ip, ts] of commentCooldowns) {
+ if (now - ts > 120_000) commentCooldowns.delete(ip);
+ }
+}, 600_000);
+if (_cooldownSweep.unref) _cooldownSweep.unref();
+
+router.get('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+
+ const limit = Math.min(parseInt(req.query.limit || '50'), 100);
+ const offset = parseInt(req.query.offset || '0');
+
+ const comments = db.getPasteComments(paste.id, limit, offset);
+ const total = db.getPasteCommentCount(paste.id);
+
+ for (const c of comments) {
+ c.replies = db.getPasteCommentReplies(c.id);
+ c.reply_count = c.replies.length;
+ }
+
+ res.json({ comments, total });
+ } catch (err) {
+ console.error('[PasteComments] List error:', err.message);
+ res.status(500).json({ error: 'Failed to load comments' });
+ }
+});
+
+router.post('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+
+ const ip = req.ip || 'unknown';
+ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
+
+ // ── Anon check ──────────────────────────────────────
+ if (userId == null && db.getSetting('paste_comment_anon_allowed') === false) {
+ return res.status(401).json({ error: 'You must be logged in to comment' });
+ }
+
+ // ── Message validation ──────────────────────────────
+ const message = (req.body?.message || '').trim();
+ const maxLen = Number(db.getSetting('paste_comment_max_length')) || 2000;
+ if (!message) return res.status(400).json({ error: 'Comment cannot be empty' });
+ if (message.length > maxLen) {
+ return res.status(400).json({ error: `Comment must be under ${maxLen} characters` });
+ }
+
+ // ── Anon name validation ────────────────────────────
+ let anonName = null;
+ if (userId == null) {
+ anonName = (req.body?.anon_name || '').trim().substring(0, 32) || 'Anonymous';
+ anonName = anonName.replace(/[^a-zA-Z0-9 _\-]/g, '').trim() || 'Anonymous';
+ }
+
+ // ── Rate limits (user-JWT/browser callers) ──────────
+ if (req.authType === 'user') {
+ const cooldownSec = Number(db.getSetting('paste_comment_cooldown_seconds')) || 10;
+ const lastComment = commentCooldowns.get(ip);
+ if (lastComment && (Date.now() - lastComment) < cooldownSec * 1000) {
+ const wait = Math.ceil((cooldownSec * 1000 - (Date.now() - lastComment)) / 1000);
+ return res.status(429).json({ error: `Please wait ${wait}s before commenting again` });
+ }
+ const recentFromIp = db.getRecentPasteCommentsByIp(ip, 60);
+ if (recentFromIp.length >= 5) {
+ return res.status(429).json({ error: 'Too many comments. Please slow down.' });
+ }
+ if (recentFromIp.length > 0 && recentFromIp[0].message === message) {
+ return res.status(400).json({ error: 'Duplicate comment' });
+ }
+ }
+
+ // ── Parent comment validation ───────────────────────
+ const parentId = req.body?.parent_id ? parseInt(req.body.parent_id) : null;
+ let parent = null;
+ if (parentId) {
+ parent = db.getPasteCommentById(parentId);
+ if (!parent || parent.paste_id !== paste.id) {
+ return res.status(400).json({ error: 'Invalid parent comment' });
+ }
+ // Prevent deeply nested replies — only allow replies to top-level
+ if (parent.parent_id) {
+ return res.status(400).json({ error: 'Cannot reply to a reply — reply to the original comment instead' });
+ }
+ }
+
+ const result = db.createPasteComment({
+ paste_id: paste.id,
+ user_id: userId,
+ parent_id: parentId,
+ anon_name: anonName,
+ message,
+ ip_address: ip,
+ });
+
+ commentCooldowns.set(ip, Date.now());
+
+ const comment = db.getPasteCommentById(result.lastInsertRowid);
+ res.status(201).json({ comment });
+ } catch (err) {
+ console.error('[PasteComments] Create error:', err.message);
+ res.status(500).json({ error: 'Failed to post comment' });
+ }
+});
+
+router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const paste = _getPasteScoped(req, res);
+ if (!paste) return;
+
+ const comment = db.getPasteCommentById(parseInt(req.params.commentId));
+ if (!comment || comment.paste_id !== paste.id) {
+ return res.status(404).json({ error: 'Comment not found' });
+ }
+
+ // App callers moderate freely; user callers must be the comment author
+ // or the paste owner.
+ if (req.authType === 'user') {
+ const isAuthor = comment.user_id != null && comment.user_id === req.userId;
+ const isPasteOwner = paste.user_id != null && paste.user_id === req.userId;
+ if (!isAuthor && !isPasteOwner) {
+ return res.status(403).json({ error: 'Not authorized to delete this comment' });
+ }
+ }
+
+ db.deletePasteComment(comment.id);
+ res.json({ message: 'Comment deleted' });
+ } catch (err) {
+ console.error('[PasteComments] Delete error:', err.message);
+ res.status(500).json({ error: 'Failed to delete comment' });
+ }
+});
+
+module.exports = router;
+module.exports.pastePublic = pastePublic;
+module.exports.screenshotUrl = screenshotUrl;
+module.exports.removePasteScreenshot = removePasteScreenshot;
+module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
new file mode 100644
new file mode 100644
@@ -0,0 +1,351 @@
@@ -0,0 +1,351 @@
+/**
+ * OpenVibe.Media — Public serving routes (no auth unless the item is private)
+ *
+ * GET /v/:id VOD playback: local stream w/ Range, or 302 presigned
+ * B2/R2 (inherited logic). Live recordings serve the
+ * fully-indexed .seekable sidecar for DVR.
+ * GET /c/:id clip playback (same tiering/range logic)
+ * GET /p/:slug paste HTML page (indexable)
+ * GET /p/:slug/raw paste raw text
+ * GET /p/:slug/screenshot paste screenshot image
+ * GET /t/:id thumbnails (id = filename)
+ * GET /f/:key files with correct Content-Type + Range
+ *
+ * /v /c /t /f carry X-Robots-Tag: noindex — the owning app has the canonical
+ * page. Pastes are Media-canonical and stay indexable.
+ */
+'use strict';
+
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const db = require('../db/database');
+const tools = require('../vod/media-tools');
+const { optionalIdentity } = require('../auth');
+
+const router = express.Router();
+
+const MIME_TYPES = {
+ '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
+};
+
+function getRequesterIp(req) {
+ return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';
+}
+
+function trackUniqueView(kind, id, req) {
+ try {
+ const ip = getRequesterIp(req);
+ const inserted = db.run(
+ 'INSERT OR IGNORE INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?)',
+ [kind, id, ip]
+ );
+ if (inserted.changes > 0) {
+ const count = db.get('SELECT COUNT(*) as c FROM content_views WHERE content_type = ? AND content_id = ?', [kind, id]);
+ const table = kind === 'vod' ? 'vods' : 'clips';
+ db.run(`UPDATE ${table} SET view_count = ? WHERE id = ?`, [count.c, id]);
+ }
+ } catch { /* non-critical */ }
+}
+
+function canAccessPrivate(record, req) {
+ // Private items: the owning app (its API key) or the owning user's JWT.
+ if (req.authType === 'app' && req.appId === record.app_id) return true;
+ if (req.authType === 'user' && req.userId != null && record.user_id === req.userId) return true;
+ return false;
+}
+
+function streamFileWithRange(req, res, filePath, extraHeaders = {}) {
+ const stat = fs.statSync(filePath);
+ const range = req.headers.range;
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+
+ if (range) {
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10) || 0;
+ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
+ if (start >= stat.size || end < start) {
+ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...extraHeaders });
+ return res.end();
+ }
+ const chunkSize = Math.min(end, stat.size - 1) - start + 1;
+ res.writeHead(206, {
+ 'Content-Range': `bytes ${start}-${Math.min(end, stat.size - 1)}/${stat.size}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': chunkSize,
+ 'Content-Type': contentType,
+ ...extraHeaders,
+ });
+ fs.createReadStream(filePath, { start, end: Math.min(end, stat.size - 1) }).pipe(res);
+ } else {
+ res.writeHead(200, {
+ 'Content-Length': stat.size,
+ 'Content-Type': contentType,
+ 'Accept-Ranges': 'bytes',
+ ...extraHeaders,
+ });
+ fs.createReadStream(filePath).pipe(res);
+ }
+}
+
+/**
+ * Serve a vods/clips row: local file (Range + live-DVR sidecar) or 302
+ * presigned B2/R2 — the inherited playback logic.
+ */
+async function serveMediaRecord(kind, record, req, res) {
+ const vodStorage = require('../vod/vod-storage');
+ const noindex = { 'X-Robots-Tag': 'noindex' };
+
+ const visibility = record.visibility || (record.is_public ? 'public' : 'private');
+ if (visibility === 'private' && !canAccessPrivate(record, req)) {
+ return res.status(403).json({ error: 'This media is private' });
+ }
+
+ trackUniqueView(kind, record.id, req);
+
+ // Track last access time for storage tier decisions
+ if (kind === 'vod') {
+ try { db.run("UPDATE vods SET last_accessed_at = datetime('now') WHERE id = ?", [record.id]); } catch {}
+ }
+
+ // Resolve the local file. Clips keep their own absolute path; VODs resolve
+ // by basename under VOD_PATH (legacy rows carry old absolute paths).
+ let filePath = null;
+ if (record.file_path) {
+ const candidates = kind === 'vod'
+ ? [vodStorage.localPathForVod(record), record.file_path]
+ : [record.file_path, path.join(path.resolve(require('../config').vod.clipsPath), path.basename(record.file_path))];
+ for (const p of candidates) {
+ if (p && fs.existsSync(p)) { filePath = p; break; }
+ }
+ }
+
+ // Offloaded (B2/R2) — redirect to a presigned object-store URL. Range
+ // requests are handled natively by the object store.
+ if (!filePath && vodStorage.isRemote(record)) {
+ const plan = await vodStorage.resolvePlayback(record);
+ if (plan?.kind === 'redirect') {
+ res.set('Cache-Control', 'private, max-age=0');
+ res.set('X-Robots-Tag', 'noindex');
+ return res.redirect(302, plan.url);
+ }
+ if (plan?.kind === 'file') filePath = plan.path;
+ }
+
+ if (!filePath) return res.status(404).json({ error: 'Media file unavailable' });
+
+ // For a live recording, serve the fully-indexed seekable sidecar so DVR
+ // viewers can seek anywhere.
+ const seekablePath = tools.seekableSidecarPath(filePath);
+ const isLiveSeekable = !!record.is_recording && !!seekablePath && seekablePath !== filePath && fs.existsSync(seekablePath);
+ if (isLiveSeekable) filePath = seekablePath;
+ // A still-recording MP4 grows in place — serve it no-cache so the player
+ // keeps discovering new bytes (DVR to the live edge).
+ const isLiveMp4 = !!record.is_recording && filePath.endsWith('.mp4');
+
+ const cacheHeaders = (isLiveSeekable || isLiveMp4)
+ ? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }
+ : {};
+
+ streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex });
+}
+
+// ── VOD playback ─────────────────────────────────────────────
+router.get('/v/:id', optionalIdentity, async (req, res) => {
+ try {
+ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
+ const vod = db.getVodById(parseInt(req.params.id, 10));
+ if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
+ await serveMediaRecord('vod', vod, req, res);
+ } catch (err) {
+ console.error('[Public] /v error:', err.message);
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
+ }
+});
+
+// ── Clip playback ────────────────────────────────────────────
+router.get('/c/:id', optionalIdentity, async (req, res) => {
+ try {
+ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
+ const clip = db.getClipById(parseInt(req.params.id, 10));
+ if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });
+ await serveMediaRecord('clip', clip, req, res);
+ } catch (err) {
+ console.error('[Public] /c error:', err.message);
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
+ }
+});
+
+// ── Thumbnails ───────────────────────────────────────────────
+router.get('/t/:id', (req, res) => {
+ require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
+});
+
+// ── Files ────────────────────────────────────────────────────
+router.get('/f/:key', (req, res) => {
+ try {
+ const row = db.getFileByKey(String(req.params.key));
+ if (!row) return res.status(404).json({ error: 'Not found' });
+ const filesRoutes = require('../files/routes');
+ const filePath = filesRoutes.filePathForKey(row);
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
+
+ const stat = fs.statSync(filePath);
+ const range = req.headers.range;
+ const contentType = row.mime || 'application/octet-stream';
+ const headers = {
+ 'X-Robots-Tag': 'noindex',
+ 'Cache-Control': 'public, max-age=86400',
+ };
+ if (range) {
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10) || 0;
+ const end = parts[1] ? Math.min(parseInt(parts[1], 10), stat.size - 1) : stat.size - 1;
+ if (start >= stat.size || end < start) {
+ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...headers });
+ return res.end();
+ }
+ res.writeHead(206, {
+ 'Content-Range': `bytes ${start}-${end}/${stat.size}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': end - start + 1,
+ 'Content-Type': contentType,
+ ...headers,
+ });
+ fs.createReadStream(filePath, { start, end }).pipe(res);
+ } else {
+ res.writeHead(200, {
+ 'Content-Length': stat.size,
+ 'Content-Type': contentType,
+ 'Accept-Ranges': 'bytes',
+ ...headers,
+ });
+ fs.createReadStream(filePath).pipe(res);
+ }
+ } catch (err) {
+ console.error('[Public] /f error:', err.message);
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
+ }
+});
+
+// ── Pastes ───────────────────────────────────────────────────
+
+function escapeHtml(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function renderPastePage(paste) {
+ const title = escapeHtml(paste.title || 'Untitled');
+ const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;
+ const body = isScreenshot
+ ? `<figure class="shot"><img src="/p/${escapeHtml(paste.slug)}/screenshot" alt="${title}"></figure>
+ ${paste.content ? `<p class="desc">${escapeHtml(paste.content)}</p>` : ''}`
+ : `<pre class="code" data-language="${escapeHtml(paste.language || 'text')}"><code>${escapeHtml(paste.content || '')}</code></pre>`;
+ const created = escapeHtml(String(paste.created_at || ''));
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title} — OpenVibe.Media</title>
+<meta name="description" content="${title} — shared via OpenVibe.Media pastes">
+<style>
+ :root { --accent: #8b5cf6; --accent-light: #a78bfa; --bg: #131318; --panel: #1b1b22; --text: #e8e8ee; --muted: #9a9aa8; }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; }
+ header { display: flex; align-items: center; gap: .6rem; padding: .8rem 1.2rem; border-bottom: 1px solid #26262f; }
+ header .brand { font-weight: 700; color: var(--accent-light); text-decoration: none; font-size: 1.05rem; }
+ header .brand span { color: var(--text); }
+ main { max-width: 960px; margin: 0 auto; padding: 1.2rem; }
+ h1 { font-size: 1.3rem; margin: .2rem 0 .3rem; }
+ .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1rem; }
+ .meta a { color: var(--accent-light); text-decoration: none; }
+ .code { background: var(--panel); border: 1px solid #26262f; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; }
+ .shot img { max-width: 100%; border-radius: 10px; border: 1px solid #26262f; }
+ .desc { color: var(--muted); }
+ footer { max-width: 960px; margin: 0 auto; padding: 1rem 1.2rem 2rem; color: var(--muted); font-size: .82rem; }
+ footer a { color: var(--accent-light); text-decoration: none; }
+</style>
+</head>
+<body>
+<header>
+ <a class="brand" href="https://openvibe.network">OpenVibe<span>.Media</span></a>
+</header>
+<main>
+ <h1>${title}</h1>
+ <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>
+ ${body}
+</main>
+<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>
+</body>
+</html>`;
+}
+
+router.get('/p/:slug', optionalIdentity, (req, res) => {
+ try {
+ const paste = db.getPasteBySlug(String(req.params.slug));
+ if (!paste) return res.status(404).send('Paste not found');
+
+ // Unlisted pastes stay reachable by direct link (that's the point).
+ // Private pastes: owning app/user only.
+ if (paste.visibility === 'private' && !canAccessPrivate(paste, req)) {
+ return res.status(404).send('Paste not found');
+ }
+
+ // Increment view count (don't count the owner's views)
+ const isOwner = req.userId != null && paste.user_id === req.userId;
+ if (!isOwner) {
+ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
+ paste.views += 1;
+ }
+
+ // Burn-after-read: allow one non-owner read, then delete.
+ if (paste.burn_after_read && !isOwner && paste.views > 1) {
+ require('../pastes/routes').removePasteScreenshot(paste);
+ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
+ return res.status(410).send('This paste has been burned after reading.');
+ }
+
+ res.type('html').send(renderPastePage(paste));
+ } catch (err) {
+ console.error('[Public] /p error:', err.message);
+ res.status(500).send('Error');
+ }
+});
+
+router.get('/p/:slug/raw', (req, res) => {
+ try {
+ const paste = db.getPasteBySlug(String(req.params.slug));
+ if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
+ if (paste.visibility === 'private') return res.status(404).send('Not found');
+
+ // Burn after read
+ if (paste.burn_after_read && paste.views > 0) {
+ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
+ return res.status(410).send('This paste has been burned after reading.');
+ }
+
+ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
+ res.type('text/plain').send(paste.content);
+ } catch {
+ res.status(500).send('Error');
+ }
+});
+
+router.get('/p/:slug/screenshot', (req, res) => {
+ try {
+ const paste = db.getPasteBySlug(String(req.params.slug));
+ if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');
+ if (paste.visibility === 'private') return res.status(404).send('Not found');
+ if (!fs.existsSync(paste.screenshot_path)) return res.status(404).send('Not found');
+ streamFileWithRange(req, res, paste.screenshot_path, { 'Cache-Control': 'public, max-age=86400' });
+ } catch {
+ res.status(500).send('Error');
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+/**
+ * OpenVibe.Media — Thumbnail API routes (mounted at /api/v1/:app/thumbnails)
+ *
+ * POST /:kind/:id (upload or generate) → { url }
+ * kind = 'vod' | 'clip' → multipart 'thumbnail' (or JSON { image: base64 })
+ * uploads a custom image; with no image, the
+ * thumbnail is (re)generated from the media file.
+ * kind = 'live'|'stream' → upload only; stored under a stable per-app/id
+ * filename so the URL survives refreshes.
+ *
+ * Public serving is at GET /t/:id (see public routes).
+ */
+'use strict';
+
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const db = require('../db/database');
+const thumbService = require('./thumbnail-service');
+const { tenantAuth, tenantCors } = require('../auth');
+
+const router = express.Router({ mergeParams: true });
+router.use(tenantCors);
+
+// Multer for raw image upload (max 2 MB)
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 2 * 1024 * 1024 },
+});
+
+function _imageFromRequest(req) {
+ if (req.file) return req.file.buffer;
+ if (req.body && req.body.image) return req.body.image;
+ return null;
+}
+
+router.post('/:kind/:id', tenantAuth({ allowUser: true }), upload.single('thumbnail'), async (req, res) => {
+ try {
+ const kind = String(req.params.kind || '').toLowerCase();
+ const id = String(req.params.id);
+ const imageData = _imageFromRequest(req);
+
+ // ── Live stream thumbnail (upload from the owning app's broadcaster) ──
+ if (kind === 'live' || kind === 'stream') {
+ if (!imageData) return res.status(400).json({ error: 'No image data provided' });
+ const url = thumbService.saveLiveThumbnail(req.appId, id, imageData);
+ if (!url) return res.status(400).json({ error: 'Invalid image data' });
+ return res.json({ url });
+ }
+
+ if (kind !== 'vod' && kind !== 'clip') {
+ return res.status(400).json({ error: "kind must be 'vod', 'clip', or 'live'" });
+ }
+
+ const numId = parseInt(id, 10);
+ const row = kind === 'vod' ? db.getVodById(numId, req.appId) : db.getClipById(numId, req.appId);
+ if (!row) return res.status(404).json({ error: `${kind} not found` });
+
+ // Custom upload → save as the entity's thumbnail.
+ if (imageData) {
+ let buffer = Buffer.isBuffer(imageData)
+ ? imageData
+ : Buffer.from(String(imageData).replace(/^data:image\/\w+;base64,/, ''), 'base64');
+ if (buffer.length < 4) return res.status(400).json({ error: 'Invalid image data' });
+ const filename = `${kind}-${numId}-${Date.now()}.jpg`;
+ fs.writeFileSync(path.join(thumbService.THUMB_DIR, filename), buffer);
+ const url = `/t/${filename}`;
+ const table = kind === 'vod' ? 'vods' : 'clips';
+ db.run(`UPDATE ${table} SET thumbnail_url = ? WHERE id = ?`, [url, numId]);
+ return res.json({ url });
+ }
+
+ // Generate from the media file (local or presigned remote).
+ const vodStorage = require('../vod/vod-storage');
+ const source = await vodStorage.resolveMediaSource(row);
+ if (!source) return res.status(404).json({ error: 'Media file unavailable' });
+ const url = kind === 'vod'
+ ? await thumbService.generateVodThumbnail(numId, source.value)
+ : await thumbService.generateClipThumbnail(numId, source.value);
+ if (!url) return res.status(500).json({ error: 'Failed to generate thumbnail' });
+ res.json({ url });
+ } catch (err) {
+ console.error('[Thumbnails] Error:', err.message);
+ res.status(500).json({ error: 'Failed to process thumbnail' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,290 @@
@@ -0,0 +1,290 @@
+/**
+ * OpenVibe.Media — Universal Thumbnail Service
+ *
+ * Handles thumbnail generation, storage, and serving for:
+ * • Live streams — JPEG frames POSTed by the owning app's broadcaster
+ * (stable per-app/id filename, refreshed in place)
+ * • VODs — extracted via ffmpeg at ~10% into the video
+ * • Clips — extracted via ffmpeg near the first frames
+ *
+ * Thumbnails are stored as JPEGs in THUMBNAILS_PATH and served publicly at
+ * GET /t/:id (id = filename). The vods/clips `thumbnail_url` column holds the
+ * public path (e.g. "/t/vod-3-1672531200.jpg").
+ *
+ * The predecessor's server-side live grabbers (RTMP-FLV / JSMPEG-WS / SFU
+ * PlainRTP) are dropped — live sources belong to the apps now; apps push
+ * frames via POST /api/v1/:app/thumbnails/live/:id instead.
+ */
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const { spawn } = require('child_process');
+const config = require('../config');
+const db = require('../db/database');
+
+// ── Constants ────────────────────────────────────────────────
+const THUMB_DIR = path.resolve(config.thumbnails.path);
+const THUMB_WIDTH = 640;
+const THUMB_QUALITY = 6; // ffmpeg qscale:v (2=best, 31=worst, 6 is good balance)
+const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;
+
+if (!fs.existsSync(THUMB_DIR)) {
+ fs.mkdirSync(THUMB_DIR, { recursive: true });
+}
+
+// ── Generate Thumbnail from Video File (VODs & Clips) ────────
+const _activeThumbJobs = new Set(); // Dedup concurrent VOD/clip thumbnail generation
+
+/**
+ * Extract a thumbnail frame from a video file using ffmpeg.
+ * @param {string} videoPath – Absolute path (or presigned URL) of the video
+ * @param {string} prefix – Filename prefix ('vod' or 'clip')
+ * @param {number} entityId – DB id (vod or clip id)
+ * @param {object} opts – { seekPercent?, seekSeconds? }
+ * @returns {Promise<string|null>} public thumbnail URL (/t/<file>), or null
+ */
+function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
+ const jobKey = `${prefix}-${entityId}`;
+ if (_activeThumbJobs.has(jobKey)) {
+ return Promise.resolve(null); // Already generating for this entity
+ }
+ _activeThumbJobs.add(jobKey);
+
+ return new Promise((resolve) => {
+ const isUrl = /^https?:\/\//i.test(String(videoPath || ''));
+ if (!videoPath || (!isUrl && !fs.existsSync(videoPath))) {
+ _activeThumbJobs.delete(jobKey);
+ return resolve(null);
+ }
+
+ const outFilename = `${prefix}-${entityId}-${Date.now()}.jpg`;
+ const outPath = path.join(THUMB_DIR, outFilename);
+
+ // First, probe the duration
+ const probe = spawn('ffprobe', [
+ '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath,
+ ]);
+ let probeData = '';
+ probe.stdout.on('data', (d) => (probeData += d));
+
+ probe.on('close', (probeCode) => {
+ let seekTime = opts.seekSeconds || 1;
+
+ if (probeCode === 0 && !opts.seekSeconds) {
+ try {
+ const info = JSON.parse(probeData);
+ const duration = parseFloat(info.format?.duration || '0');
+ if (duration > 2) {
+ seekTime = Math.min(
+ duration * ((opts.seekPercent || 10) / 100),
+ duration - 0.5
+ );
+ }
+ } catch {}
+ }
+
+ // Extract frame
+ const args = [
+ '-y',
+ '-ss', String(Math.max(0, seekTime)),
+ '-i', videoPath,
+ '-vframes', '1',
+ '-vf', `scale=${THUMB_WIDTH}:-1`,
+ '-q:v', String(THUMB_QUALITY),
+ outPath,
+ ];
+
+ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
+ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch {} }, isUrl ? 35000 : 20000);
+ ff.on('close', (code) => {
+ clearTimeout(killTimer);
+ if (code === 0 && fs.existsSync(outPath)) {
+ resolve(`/t/${outFilename}`);
+ } else {
+ resolve(null);
+ }
+ });
+ ff.on('error', () => { clearTimeout(killTimer); resolve(null); });
+ });
+
+ probe.on('error', () => resolve(null));
+ }).finally(() => _activeThumbJobs.delete(jobKey));
+}
+
+// ── Generate VOD Thumbnail (~10% in) ─────────────────────────
+async function generateVodThumbnail(vodId, filePath) {
+ const thumbUrl = await generateFromVideo(filePath, 'vod', vodId, { seekPercent: 10 });
+ if (thumbUrl) {
+ // Clean up the previous thumbnail for this VOD
+ _removeOldThumb(db.get('SELECT thumbnail_url FROM vods WHERE id = ?', [vodId])?.thumbnail_url, thumbUrl);
+ db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumbUrl, vodId]);
+ }
+ return thumbUrl;
+}
+
+// ── Generate Clip Thumbnail (first frames) ───────────────────
+async function generateClipThumbnail(clipId, filePath) {
+ const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: 0.5 });
+ if (thumbUrl) {
+ _removeOldThumb(db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId])?.thumbnail_url, thumbUrl);
+ db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]);
+ }
+ return thumbUrl;
+}
+
+function _removeOldThumb(oldUrl, newUrl) {
+ if (!oldUrl || oldUrl === newUrl) return;
+ const oldFile = path.join(THUMB_DIR, path.basename(oldUrl));
+ try { if (fs.existsSync(oldFile)) fs.unlinkSync(oldFile); } catch { /* */ }
+}
+
+// ── Save Live Stream Thumbnail (POSTed by the owning app) ────
+/**
+ * Save a live-stream thumbnail for an app's stream. Accepts a Buffer of
+ * JPEG/PNG data or a base64 string (with or without data: prefix). Stored
+ * under a STABLE filename (stream-<app>-<id>.jpg) refreshed in place so the
+ * URL is stable and files never accumulate.
+ * @returns {string|null} public thumbnail URL, or null on failure
+ */
+function saveLiveThumbnail(appId, streamId, imageData) {
+ try {
+ const filename = `stream-${appId}-${streamId}.jpg`;
+ const outPath = path.join(THUMB_DIR, filename);
+
+ // Rate-limit rewrites (the predecessor's client-write cooldown)
+ try {
+ const stat = fs.statSync(outPath);
+ if (Date.now() - stat.mtimeMs < CLIENT_THUMB_WRITE_MIN_INTERVAL_MS) {
+ return `/t/${filename}`;
+ }
+ } catch { /* no existing file */ }
+
+ let buffer;
+ if (Buffer.isBuffer(imageData)) {
+ buffer = imageData;
+ } else if (typeof imageData === 'string') {
+ const base64 = imageData.replace(/^data:image\/\w+;base64,/, '');
+ buffer = Buffer.from(base64, 'base64');
+ } else {
+ return null;
+ }
+
+ // Validate it's a JPEG (FF D8) or PNG (89 50)
+ if (buffer.length < 3 || buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
+ if (buffer.length < 4 || buffer[0] !== 0x89 || buffer[1] !== 0x50) {
+ console.warn('[Thumbnails] Invalid image data for stream', streamId);
+ return null;
+ }
+ }
+
+ fs.writeFileSync(outPath, buffer);
+ return `/t/${filename}`;
+ } catch (err) {
+ console.error('[Thumbnails] Save live thumbnail error:', err.message);
+ return null;
+ }
+}
+
+// ── Serve Thumbnail File (public /t/:id) ─────────────────────
+function serveThumbnail(req, res) {
+ try {
+ const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
+ const filePath = path.join(THUMB_DIR, filename);
+
+ if (!filename || !fs.existsSync(filePath)) {
+ // Return a 1x1 transparent pixel JPEG as fallback
+ const pixel = Buffer.from(
+ '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
+ 'base64'
+ );
+ res.writeHead(200, {
+ 'Content-Type': 'image/jpeg',
+ 'Content-Length': pixel.length,
+ 'Cache-Control': 'no-cache',
+ 'X-Robots-Tag': 'noindex',
+ });
+ return res.end(pixel);
+ }
+
+ const stat = fs.statSync(filePath);
+ const ext = path.extname(filename).toLowerCase();
+ const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';
+
+ // Cache briefly for live thumbnails, longer for VOD/clip
+ const isLive = filename.startsWith('stream-');
+ const maxAge = isLive ? 30 : 86400;
+
+ res.writeHead(200, {
+ 'Content-Type': contentType,
+ 'Content-Length': stat.size,
+ 'Cache-Control': `public, max-age=${maxAge}`,
+ 'X-Robots-Tag': 'noindex',
+ });
+ fs.createReadStream(filePath).pipe(res);
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to serve thumbnail' });
+ }
+}
+
+// ── Cleanup Old Live Thumbnails ──────────────────────────────
+/**
+ * Remove live-stream thumbnails not refreshed within `maxAgeMs` (default 24h —
+ * filenames are stable per stream, so this only reaps dead streams).
+ */
+function cleanupOldThumbnails(maxAgeMs = 24 * 3600000) {
+ try {
+ const files = fs.readdirSync(THUMB_DIR);
+ const now = Date.now();
+ let cleaned = 0;
+
+ for (const file of files) {
+ // Only auto-clean live stream thumbs (vod/clip thumbs are permanent)
+ if (!file.startsWith('stream-')) continue;
+
+ const filePath = path.join(THUMB_DIR, file);
+ const stat = fs.statSync(filePath);
+ if (now - stat.mtimeMs > maxAgeMs) {
+ fs.unlinkSync(filePath);
+ cleaned++;
+ }
+ }
+
+ if (cleaned > 0) {
+ console.log(`[Thumbnails] Cleaned up ${cleaned} old live thumbnails`);
+ }
+ } catch (err) {
+ console.error('[Thumbnails] Cleanup error:', err.message);
+ }
+}
+
+/**
+ * Extract one frame to an arbitrary file. `source` may be a local path OR an
+ * http(s) URL (e.g. a presigned B2/R2 URL) — with `-ss` before `-i`, ffmpeg
+ * seeks via HTTP range requests and only pulls the bytes around that
+ * timestamp, so remote/cold VODs work without downloading. Resolves boolean.
+ */
+function extractFrameToFile(source, seekSeconds, outAbsPath) {
+ return new Promise((resolve) => {
+ const isUrl = /^https?:\/\//i.test(String(source || ''));
+ if (!source || (!isUrl && !fs.existsSync(source))) return resolve(false);
+ try { fs.mkdirSync(path.dirname(outAbsPath), { recursive: true }); } catch { /* */ }
+ const args = ['-y', '-ss', String(Math.max(0.5, Number(seekSeconds) || 1)), '-i', source,
+ '-vframes', '1', '-vf', `scale=${THUMB_WIDTH}:-1`, '-q:v', String(THUMB_QUALITY), outAbsPath];
+ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
+ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, isUrl ? 35000 : 15000);
+ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0 && fs.existsSync(outAbsPath)); });
+ ff.on('error', () => { clearTimeout(to); resolve(false); });
+ });
+}
+
+module.exports = {
+ generateFromVideo,
+ generateVodThumbnail,
+ generateClipThumbnail,
+ saveLiveThumbnail,
+ serveThumbnail,
+ cleanupOldThumbnails,
+ extractFrameToFile,
+ THUMB_DIR,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+/**
+ * OpenVibe.Media — server-side clip cutter
+ *
+ * Cuts a clip from any source ffmpeg can read (a local VOD/recording file OR a
+ * presigned B2/R2 URL) and creates/updates the clip row + thumbnail. Ported
+ * from the predecessor (AI overview + chat-notify hooks dropped — Live owns
+ * those); no req/res coupling.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+const db = require('../db/database');
+const config = require('../config');
+
+const CLIPS_DIR = path.resolve(config.vod.clipsPath);
+const MAX_CONCURRENT = parseInt(process.env.CLIP_MAX_CONCURRENT_FFMPEG || '3', 10);
+let _active = 0;
+
+function _ffprobe(file) {
+ return new Promise((resolve) => {
+ const ff = spawn('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file], { stdio: ['ignore', 'pipe', 'ignore'] });
+ let out = '';
+ ff.stdout.on('data', d => { out += d; });
+ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);
+ ff.on('close', () => { clearTimeout(to); try { resolve(JSON.parse(out)); } catch { resolve(null); } });
+ ff.on('error', () => { clearTimeout(to); resolve(null); });
+ });
+}
+
+function isBusy() {
+ return _active >= MAX_CONCURRENT;
+}
+
+/**
+ * Cut a clip file. Returns { ok, filePath, duration } or { ok: false, error }.
+ * @param {object} o
+ * @param {string} o.source local path OR http(s) URL ffmpeg can read
+ * @param {number} o.startTime seconds into the source to start
+ * @param {number} o.duration clip length in seconds
+ */
+async function cutClipFile({ source, startTime, duration }) {
+ if (!source || !(duration >= 1)) return { ok: false, error: 'Invalid cut parameters' };
+ const isUrl = /^https?:\/\//i.test(String(source));
+ if (!isUrl && !fs.existsSync(source)) return { ok: false, error: 'Source missing' };
+ if (isBusy()) return { ok: false, error: 'busy' };
+ try { if (!fs.existsSync(CLIPS_DIR)) fs.mkdirSync(CLIPS_DIR, { recursive: true }); } catch { /* */ }
+
+ const ss = Math.max(0, Number(startTime) || 0);
+ const dur = Math.max(1, Number(duration) || 20);
+ const filename = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.webm`;
+ const outPath = path.join(CLIPS_DIR, filename);
+
+ _active++;
+ // Re-encode the (short) clip instead of stream-copying. `-ss` before `-i`
+ // is a fast seek to the nearest keyframe, and because we re-encode, ffmpeg
+ // then decodes to the EXACT requested start — the clip begins precisely
+ // where asked with a fresh keyframe at t=0. A `-c copy` cut can only start
+ // on a source keyframe, which on sparsely-keyframed live recordings means
+ // an opening of black/frozen frames.
+ const ok = await new Promise((resolve) => {
+ const args = [
+ '-y', '-ss', String(ss), '-i', source, '-t', String(dur),
+ '-c:v', 'libvpx', '-b:v', '2000k', '-crf', '18', '-deadline', 'realtime', '-cpu-used', '4',
+ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-c:a', 'libopus', '-b:a', '128k',
+ '-avoid_negative_ts', 'make_zero', '-f', 'webm', outPath,
+ ];
+ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
+ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, Math.round(dur) * 2000 + (isUrl ? 40000 : 20000));
+ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0); });
+ ff.on('error', () => { clearTimeout(to); resolve(false); });
+ }).finally(() => { _active = Math.max(0, _active - 1); });
+
+ if (!ok) { try { fs.existsSync(outPath) && fs.unlinkSync(outPath); } catch { /* */ } return { ok: false, error: 'ffmpeg cut failed' }; }
+
+ // Validate real decodable footage (a seek near EOF can yield a header-only file).
+ const pi = await _ffprobe(outPath);
+ const hasVideo = pi && (pi.streams || []).some(s => s.codec_type === 'video');
+ const realDur = pi && pi.format ? Number(pi.format.duration) : 0;
+ if (!hasVideo || !(realDur >= 1)) {
+ try { fs.unlinkSync(outPath); } catch { /* */ }
+ return { ok: false, error: 'No decodable footage in the requested window' };
+ }
+
+ return { ok: true, filePath: outPath, duration: realDur };
+}
+
+/**
+ * Cut a clip and create its row (used by internal jobs; the API route creates
+ * the row first and updates it). Returns the created clip row, or null.
+ */
+async function cutClip(o) {
+ const { appId, source, startTime, duration, streamId, vodId = null, userId, title, description = '', isPublic = 1, autoGenerated = true } = o || {};
+ if (!appId) return null;
+ const cut = await cutClipFile({ source, startTime, duration });
+ if (!cut.ok) return null;
+
+ const ss = Math.max(0, Number(startTime) || 0);
+ const res = db.createClip({
+ app_id: appId, vod_id: vodId, stream_id: streamId, user_id: userId,
+ title: (title || 'Auto Clip').slice(0, 200), description: description || '',
+ file_path: cut.filePath, start_time: ss, end_time: ss + cut.duration, duration_seconds: cut.duration,
+ is_public: (isPublic === 0 || isPublic === false) ? 0 : 1,
+ auto_generated: autoGenerated ? 1 : 0,
+ status: 'ready',
+ });
+ const clipId = res && res.lastInsertRowid;
+ if (!clipId) return null;
+ const clip = db.getClipById(clipId);
+ try { require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath).catch(() => {}); } catch { /* */ }
+ return clip;
+}
+
+module.exports = { cutClip, cutClipFile, isBusy, CLIPS_DIR };
new file mode 100644
new file mode 100644
@@ -0,0 +1,240 @@
@@ -0,0 +1,240 @@
+/**
+ * OpenVibe.Media — Clips API routes (mounted at /api/v1/:app/clips)
+ *
+ * POST / { vod_id, start_s, end_s, title?, user_id? } → { id, status }
+ * The cut runs in the background (works on the local file or a
+ * presigned B2/R2 URL); webhook clip.ready|clip.failed fires on
+ * completion. Also accepts a live cut against a still-recording
+ * VOD (clamped to flushed footage, like the predecessor).
+ * GET /:id clip meta (status: processing | ready | failed)
+ * GET / list (?limit&offset&vod_id&stream_id&user_id)
+ * PUT /:id update title/visibility
+ * DELETE /:id delete file + offloaded objects + row
+ */
+'use strict';
+
+const express = require('express');
+const fs = require('fs');
+const db = require('../db/database');
+const tools = require('./media-tools');
+const cutter = require('./clip-cutter');
+const { tenantAuth, tenantCors } = require('../auth');
+const { sendWebhook } = require('../webhooks');
+
+const router = express.Router({ mergeParams: true });
+router.use(tenantCors);
+
+function clipPublic(clip) {
+ if (!clip) return null;
+ return {
+ id: clip.id,
+ app_id: clip.app_id,
+ vod_id: clip.vod_id,
+ stream_id: clip.stream_id,
+ user_id: clip.user_id,
+ title: clip.title,
+ description: clip.description,
+ status: clip.status || (clip.file_path ? 'ready' : 'processing'),
+ start_time: clip.start_time,
+ end_time: clip.end_time,
+ duration: clip.duration_seconds || 0,
+ playback_url: `/c/${clip.id}`,
+ thumbnail_url: clip.thumbnail_url || null,
+ visibility: clip.visibility || 'public',
+ storage_provider: clip.storage_provider || 'local',
+ auto_generated: !!clip.auto_generated,
+ view_count: clip.view_count || 0,
+ created_at: clip.created_at,
+ };
+}
+
+/** Sanitize user-provided title: strip HTML tags, limit length */
+function sanitizeClipTitle(title, fallback = 'Untitled Clip') {
+ if (!title || typeof title !== 'string') return fallback;
+ return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;
+}
+
+function _getClipScoped(req, res) {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
+ const clip = db.getClipById(id, req.appId);
+ if (!clip) { res.status(404).json({ error: 'Clip not found' }); return null; }
+ return clip;
+}
+
+// ── Create clip ──────────────────────────────────────────────
+router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
+ try {
+ const body = req.body || {};
+ const vodId = parseInt(body.vod_id, 10);
+ if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
+ const vod = db.getVodById(vodId, req.appId);
+ if (!vod) return res.status(404).json({ error: 'VOD not found' });
+
+ let startTime = Number.parseFloat(body.start_s ?? body.start_time);
+ let endTime = Number.parseFloat(body.end_s ?? body.end_time);
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) {
+ return res.status(400).json({ error: 'start_s and end_s are required' });
+ }
+ if (startTime < 0 || endTime < 0) return res.status(400).json({ error: 'Time values cannot be negative' });
+
+ let duration = endTime - startTime;
+ const maxClipDuration = db.getSetting('max_clip_duration') || 60;
+ if (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });
+ if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
+
+ // Duplicate detection — reuse a just-made clip for the same window.
+ const duplicate = db.findDuplicateClip({
+ appId: req.appId, vodId, streamId: vod.stream_id || null,
+ startTime, endTime,
+ });
+ if (duplicate && duplicate.status !== 'failed') {
+ return res.status(200).json({ id: duplicate.id, status: duplicate.status, deduplicated: true });
+ }
+
+ if (cutter.isBusy()) {
+ return res.status(503).json({ error: 'Server is busy processing other clips. Please try again in a few seconds.' });
+ }
+
+ // Resolve a source ffmpeg can read: local file, or presigned B2/R2 URL.
+ const vodStorage = require('./vod-storage');
+ const source = await vodStorage.resolveMediaSource(vod);
+ if (!source) return res.status(404).json({ error: 'VOD media unavailable' });
+
+ // Live recording: clamp to what's ACTUALLY on disk, not wall-clock — a
+ // seek past end-of-file emits a header-only "broken clip".
+ if (vod.is_recording && source.kind === 'file') {
+ let recordedDur = 0;
+ try { const pi = await tools.probeVodInfo(source.value); recordedDur = pi.duration || 0; } catch { /* */ }
+ if (recordedDur > 0) {
+ const safeEdge = Math.max(0, recordedDur - 0.5);
+ endTime = Math.min(endTime, safeEdge);
+ startTime = Math.max(0, Math.min(startTime, endTime - 1));
+ duration = endTime - startTime;
+ if (duration < 1) {
+ return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });
+ }
+ }
+ } else if (vod.duration_seconds && Number.isFinite(vod.duration_seconds)) {
+ if (startTime > vod.duration_seconds + 5) return res.status(400).json({ error: 'Start time exceeds video duration' });
+ if (endTime > vod.duration_seconds + 10) endTime = vod.duration_seconds;
+ duration = endTime - startTime;
+ if (duration < 1) return res.status(400).json({ error: 'Clip window is outside the video' });
+ }
+
+ const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
+ const result = db.createClip({
+ app_id: req.appId,
+ vod_id: vodId,
+ stream_id: vod.stream_id || null,
+ user_id: userId,
+ title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),
+ file_path: '',
+ start_time: startTime,
+ end_time: endTime,
+ duration_seconds: duration,
+ is_public: 1,
+ status: 'processing',
+ });
+ const clipId = result.lastInsertRowid;
+ const appId = req.appId;
+
+ // Cut in the background; webhook on completion.
+ (async () => {
+ const cut = await cutter.cutClipFile({ source: source.value, startTime, duration });
+ if (cut.ok) {
+ db.run('UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = ? WHERE id = ?',
+ [cut.filePath, cut.duration, startTime + cut.duration, 'ready', clipId]);
+ try {
+ await require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath);
+ } catch { /* */ }
+ console.log(`[Clips] Clip ${clipId} cut from vod ${vodId} (${startTime.toFixed(1)}-${(startTime + cut.duration).toFixed(1)}s)`);
+ sendWebhook(appId, 'clip.ready', clipPublic(db.getClipById(clipId))).catch(() => {});
+ } else {
+ console.warn(`[Clips] Clip ${clipId} failed: ${cut.error}`);
+ db.run('UPDATE clips SET status = ? WHERE id = ?', ['failed', clipId]);
+ sendWebhook(appId, 'clip.failed', clipPublic(db.getClipById(clipId))).catch(() => {});
+ }
+ })().catch(err => console.error('[Clips] Background cut error:', err.message));
+
+ res.status(202).json({ id: clipId, status: 'processing' });
+ } catch (err) {
+ console.error('[Clips] Create error:', err.message);
+ res.status(500).json({ error: 'Failed to create clip' });
+ }
+});
+
+// ── List ─────────────────────────────────────────────────────
+router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const filters = {
+ limit, offset,
+ vod_id: req.query.vod_id != null ? parseInt(req.query.vod_id, 10) : null,
+ stream_id: req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null,
+ user_id: req.query.user_id != null ? req.query.user_id : null,
+ };
+ const clips = db.listClips(req.appId, filters);
+ const total = db.countClips(req.appId, filters);
+ res.json({ clips: clips.map(clipPublic), total, limit, offset });
+ } catch (err) {
+ console.error('[Clips] List error:', err.message);
+ res.status(500).json({ error: 'Failed to list clips' });
+ }
+});
+
+// ── Get clip ─────────────────────────────────────────────────
+router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const clip = _getClipScoped(req, res);
+ if (!clip) return;
+ res.json({ clip: clipPublic(clip) });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to get clip' });
+ }
+});
+
+// ── Update (title / visibility) ──────────────────────────────
+router.put('/:id', tenantAuth(), (req, res) => {
+ try {
+ const clip = _getClipScoped(req, res);
+ if (!clip) return;
+ const { title, visibility } = req.body || {};
+ if (title !== undefined) {
+ const t = sanitizeClipTitle(title, '');
+ if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
+ db.run('UPDATE clips SET title = ? WHERE id = ?', [t, clip.id]);
+ }
+ if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);
+ res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to update clip' });
+ }
+});
+
+// ── Delete ───────────────────────────────────────────────────
+router.delete('/:id', tenantAuth(), (req, res) => {
+ try {
+ const clip = _getClipScoped(req, res);
+ if (!clip) return;
+
+ // Delete the local file + any offloaded B2/R2 object (clips carry
+ // storage_provider/storage_key like VODs).
+ if (clip.file_path && fs.existsSync(clip.file_path)) {
+ try { fs.unlinkSync(clip.file_path); } catch { /* ignore */ }
+ }
+ if (clip.storage_provider && clip.storage_provider !== 'local' && clip.storage_key) {
+ require('./vod-storage').deleteVodObjects(clip).catch(err =>
+ console.warn(`[Clips] Remote object cleanup failed for clip ${clip.id}:`, err.message));
+ }
+
+ db.run('DELETE FROM clips WHERE id = ?', [clip.id]);
+ res.json({ message: 'Clip deleted' });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to delete clip' });
+ }
+});
+
+module.exports = router;
+module.exports.clipPublic = clipPublic;
new file mode 100644
new file mode 100644
@@ -0,0 +1,282 @@
@@ -0,0 +1,282 @@
+/**
+ * OpenVibe.Media — VOD finalization pipeline
+ *
+ * Ported from the predecessor's finalizeVodRecording/_doFinalize, keyed by
+ * vodId (there is no stream registry here — apps own stream state):
+ * merge pending chunk segments → seekable remux → probe → master recovery
+ * (truncated-webm rebuild) → DB update → thumbnail → webhook vod.ready.
+ *
+ * Guarded against double-invocation; refuses to finalize while the recorder
+ * still holds the file open (stops it gracefully instead — its ffmpeg exit
+ * handler re-invokes finalize on the fully-flushed file).
+ */
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const { spawn } = require('child_process');
+const db = require('../db/database');
+const tools = require('./media-tools');
+const { sendWebhook } = require('../webhooks');
+
+const _finalizing = new Set();
+
+function isFinalizing(vodId) {
+ return _finalizing.has(Number(vodId));
+}
+
+// Public shape of a vod row for API responses + webhooks.
+function vodPublic(vod) {
+ if (!vod) return null;
+ return {
+ id: vod.id,
+ app_id: vod.app_id,
+ stream_id: vod.stream_id,
+ user_id: vod.user_id,
+ title: vod.title,
+ description: vod.description,
+ status: db.vodStatus(vod),
+ duration: vod.duration_seconds || 0,
+ file_size: vod.file_size || 0,
+ playback_url: `/v/${vod.id}`,
+ thumbnail_url: vod.thumbnail_url || null,
+ storage_provider: vod.storage_provider || 'local',
+ visibility: vod.visibility || 'public',
+ health_status: vod.health_status,
+ clips_only: !!vod.clips_only,
+ is_recording: !!vod.is_recording,
+ view_count: vod.view_count || 0,
+ created_at: vod.created_at,
+ meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
+ };
+}
+
+// Re-encode the lossless master into the served WebM format. Heavy (a real
+// transcode), so it only runs on the recovery path when the primary webm came
+// out truncated.
+function rebuildWebmFromMaster(masterPath, webmPath) {
+ return new Promise((resolve) => {
+ const tmp = webmPath + '.recover.webm';
+ const args = ['-y', '-i', masterPath,
+ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',
+ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',
+ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];
+ let ff;
+ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }
+ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);
+ ff.on('close', (code) => {
+ clearTimeout(to);
+ try {
+ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {
+ fs.renameSync(tmp, webmPath);
+ return resolve(true);
+ }
+ } catch { /* fall through */ }
+ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }
+ resolve(false);
+ });
+ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });
+ });
+}
+
+function _webhookForVod(vod, event) {
+ try {
+ sendWebhook(vod.app_id, event, vodPublic(vod)).catch(() => {});
+ } catch { /* non-critical */ }
+}
+
+/**
+ * Finalize a VOD recording. Returns the updated vod row (or null).
+ * @param {number} vodId
+ * @param {object} [opts]
+ * @param {number} [opts.startTimeMs] wall-clock recording start (for duration sanity)
+ * @param {boolean} [opts.ffmpegCorrupted] recorder flagged heavy corruption
+ * @param {string} [opts.segmentPath] trailing chunk segment not yet merged
+ */
+async function finalizeVod(vodId, opts = {}) {
+ vodId = Number(vodId);
+ if (_finalizing.has(vodId)) return null;
+
+ // If the recorder still holds this VOD open, stop it gracefully instead of
+ // locking in a truncated duration — its exit handler re-finalizes.
+ try {
+ const recorder = require('./recorder');
+ if (recorder.isRecording(vodId)) {
+ console.log(`[VOD] finalize requested for vod ${vodId} while still recording — stopping gracefully first`);
+ recorder.stopRecording(vodId);
+ return null;
+ }
+ } catch { /* proceed */ }
+ _finalizing.add(vodId);
+
+ try {
+ return await _doFinalize(vodId, opts);
+ } finally {
+ _finalizing.delete(vodId);
+ }
+}
+
+async function _doFinalize(vodId, opts) {
+ const vod = db.getVodById(vodId);
+ if (!vod) return null;
+ const filePath = vod.file_path;
+ const startTime = opts.startTimeMs || new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime();
+
+ // Ephemeral clips-only recording: it existed only to serve live clips. Never
+ // published — delete the file (+ any offloaded object) and its row.
+ if (vod.clips_only) {
+ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }
+ try { tools.cleanupSeekableFile(filePath); } catch { /* */ }
+ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }
+ try { if (vod.storage_provider && vod.storage_provider !== 'local') require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }
+ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }
+ console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${vodId})`);
+ return null;
+ }
+
+ if (!filePath || !fs.existsSync(filePath)) {
+ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
+ const failed = db.getVodById(vodId);
+ _webhookForVod(failed, 'vod.failed');
+ return failed;
+ }
+
+ // Merge any pending browser-chunk segments before remuxing.
+ if (opts.segmentPath && opts.segmentPath !== filePath) {
+ await tools.concatWebmFiles(filePath, opts.segmentPath);
+ }
+ await tools.mergePendingSegments(filePath);
+
+ // A recording that never received media (e.g. an RTP ingest that was
+ // started and stopped without packets) leaves a zero-byte file. Quarantine
+ // it as failed — the wall-clock fallback below must never mark it ready.
+ if (tools.getFileSizeSafe(filePath) === 0) {
+ console.warn(`[VOD] vod ${vodId}: zero-byte recording — quarantining`);
+ db.run(`UPDATE vods SET is_recording = 0, health_status = 'zero_byte', health_issues_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
+ [JSON.stringify(['zero_byte']), vodId]);
+ const failed = db.getVodById(vodId);
+ _webhookForVod(failed, 'vod.failed');
+ return failed;
+ }
+
+ // Remux for proper seeking support (fast copy-mode, no re-encode)
+ await tools.remuxForSeeking(filePath);
+
+ // Clean up the live seekable copy (no longer needed after final remux)
+ tools.cleanupSeekableFile(filePath);
+
+ // Probe actual duration with ffprobe
+ const wallClockSeconds = Math.max(0, Math.round((Date.now() - startTime) / 1000));
+ let durationSeconds = wallClockSeconds;
+ let probeFormatJson = JSON.stringify({});
+ try {
+ const probeInfo = await tools.probeVodInfo(filePath);
+ if (probeInfo.duration > 0) {
+ // Guard against a corrupt/inflated container duration: real footage can
+ // never be meaningfully longer than the wall-clock recording time.
+ if (wallClockSeconds > 0 && probeInfo.duration > wallClockSeconds * 1.5 + 30) {
+ console.warn(`[VOD] vod ${vodId}: probed duration ${probeInfo.duration}s >> wall-clock ${wallClockSeconds}s — using wall-clock (inflated/corrupt container)`);
+ durationSeconds = wallClockSeconds;
+ } else {
+ durationSeconds = probeInfo.duration;
+ }
+ }
+ probeFormatJson = JSON.stringify(probeInfo.format || {});
+ } catch (probeErr) {
+ console.warn(`[VOD] ffprobe failed for vod ${vodId}:`, probeErr.message);
+ }
+
+ // ── Recover from the lossless master if the served webm came out truncated ──
+ // Only WebM recordings have a separate lossless master; an RTMP/H.264 MP4 is
+ // ITSELF a lossless stream-copy, so the .webm regex must NOT fall through.
+ const masterPath = filePath.endsWith('.webm')
+ ? (vod.master_file_path || filePath.replace(/\.webm$/, '.master.mkv'))
+ : null;
+ let masterDur = 0;
+ if (masterPath && fs.existsSync(masterPath)) {
+ try { const mi = await tools.probeVodInfo(masterPath); masterDur = mi.duration || 0; } catch { /* */ }
+ if (masterDur > 0 && masterDur > durationSeconds + 15 && masterDur > durationSeconds * 1.15) {
+ console.warn(`[VOD] vod ${vodId}: webm ${durationSeconds}s is short vs master ${masterDur}s — rebuilding webm from master`);
+ const ok = await rebuildWebmFromMaster(masterPath, filePath);
+ if (ok) {
+ try { await tools.remuxForSeeking(filePath); tools.cleanupSeekableFile(filePath); } catch { /* */ }
+ try {
+ const mi2 = await tools.probeVodInfo(filePath);
+ if (mi2.duration > durationSeconds) { durationSeconds = mi2.duration; probeFormatJson = JSON.stringify(mi2.format || {}); }
+ } catch { /* */ }
+ console.log(`[VOD] vod ${vodId}: recovered from master → ${durationSeconds}s`);
+ } else {
+ console.warn(`[VOD] vod ${vodId}: master recovery failed — keeping master for manual recovery`);
+ }
+ }
+ }
+
+ if (opts.ffmpegCorrupted) {
+ console.warn(`[VOD] Finalized VOD ${vodId} marked corrupt by FFmpeg diagnostics; quarantining without deletion`);
+ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
+ ['corrupt', JSON.stringify(['ffmpeg-corruption-detected']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);
+ const failed = db.getVodById(vodId);
+ _webhookForVod(failed, 'vod.failed');
+ return failed;
+ }
+
+ // Very short recordings are quarantined for review instead of deleted.
+ const MIN_VOD_SECONDS = parseInt(process.env.MIN_VOD_SECONDS || '3', 10);
+ if (durationSeconds < MIN_VOD_SECONDS) {
+ console.log(`[VOD] Quarantining short vod ${vodId}: duration ${durationSeconds}s`);
+ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
+ ['needs_review', JSON.stringify(['short_duration']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);
+ const failed = db.getVodById(vodId);
+ _webhookForVod(failed, 'vod.failed');
+ return failed;
+ }
+
+ let stat;
+ try {
+ stat = fs.statSync(filePath);
+ } catch (err) {
+ console.error(`[VOD] Failed to stat finalized VOD ${vodId}:`, err.message);
+ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
+ return null;
+ }
+ db.run('UPDATE vods SET is_recording = 0, duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, probe_format_json = ?, health_status = ? WHERE id = ?',
+ [durationSeconds, stat.size, durationSeconds, probeFormatJson, 'ok', vodId]);
+
+ console.log(`[VOD] Finalized: vod ${vodId}, ${durationSeconds}s, ${(stat.size / 1024 / 1024).toFixed(1)}MB`);
+
+ // The lossless .master.mkv archive is only a fallback. Delete it ONLY once
+ // the served webm is confirmed complete; if recovery failed, KEEP it.
+ if (masterPath) {
+ try {
+ const webmComplete = !masterDur || durationSeconds >= masterDur - 8;
+ if (fs.existsSync(masterPath)) {
+ if (webmComplete) {
+ fs.unlinkSync(masterPath);
+ console.log(`[VOD] Removed master archive for vod ${vodId} (${path.basename(masterPath)})`);
+ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);
+ } else {
+ console.warn(`[VOD] vod ${vodId}: KEEPING master (webm ${durationSeconds}s still < master ${masterDur}s)`);
+ }
+ } else {
+ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);
+ }
+ } catch (e) {
+ console.warn(`[VOD] Master cleanup failed for vod ${vodId}:`, e.message);
+ }
+ }
+
+ // Thumbnail, then webhook vod.ready (thumbnail awaited so the payload has it;
+ // failure is non-fatal).
+ try {
+ const thumbService = require('../thumbnails/thumbnail-service');
+ await thumbService.generateVodThumbnail(vodId, filePath);
+ } catch (err) {
+ console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);
+ }
+
+ const finalVod = db.getVodById(vodId);
+ _webhookForVod(finalVod, 'vod.ready');
+ return finalVod;
+}
+
+module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster };
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+/**
+ * OpenVibe.Media — health-job: keep the VOD library healthy in the background.
+ *
+ * Two responsibilities, both throttled and concurrency-safe:
+ * 1. SCAN finished VODs that were never health-checked (or not checked in a
+ * while) for corruption / zero-byte / bad-duration. On a problem it first
+ * tries to REPAIR from the lossless .master.mkv archive (and repairs a
+ * missing duration); only genuinely unrecoverable files are quarantined
+ * (hidden, is_public=0) — never silently deleted.
+ * 2. CLEAN UP VODs quarantined as unrecoverably-broken for a grace period:
+ * one last recovery attempt, else delete the dead files + row.
+ *
+ * Design: a single serialized worker, at most one ffmpeg pass at a time, small
+ * batches. Backs off to lighter (probe-only) work while any INGEST recording
+ * is active so it never competes with live ffmpeg (the predecessor keyed this
+ * off live streams; here active recordings are the equivalent signal).
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const db = require('../db/database');
+const config = require('../config');
+const scanner = require('./health-scanner');
+
+const TICK_MS = 5 * 60 * 1000; // scan pass every 5 minutes
+const SCAN_STALE_DAYS = 45; // re-scan a healthy VOD at most this often
+const SCAN_BATCH_IDLE = 3; // VODs per pass when nothing is recording
+const SCAN_BATCH_LIVE = 1; // ...and while recording (probe-only, cheap)
+const QUARANTINE_GRACE_DAYS = 21; // clean up unrecoverable quarantined VODs after this
+const CLEANUP_BATCH = 3;
+
+let _running = false;
+let _timer = null;
+let _busy = false;
+
+function _anyRecording() {
+ try { return require('./recorder').activeCount() > 0; }
+ catch { return false; }
+}
+
+// Scan (and, if needed, repair) a single VOD. The routine scan is PROBE-based
+// (fast, non-destructive); the expensive full decode + re-encode only runs to
+// REPAIR a file that already looks broken, and only while idle (`deep`).
+async function _scanOne(vod, { deep }) {
+ let scan = await scanner.scanVod(vod, { decode: false, repairDuration: true, quarantineBad: false });
+
+ const broken = ['corrupt', 'zero_byte', 'missing_file', 'needs_review'].includes(scan.status)
+ || (scan.issues || []).some(i => /decode_failed|probe_failed|invalid_duration/.test(i));
+
+ if (!broken) {
+ // Healthy (or duration just repaired) — record the clean scan.
+ try { db.updateVodHealth(vod.id, { status: 'ok', issues: scan.issues || [] }); } catch { /* */ }
+ // The lossless .master.mkv is only a finalize-time recovery fallback. This
+ // VOD probed healthy and is long finished — reclaim a lingering master.
+ try {
+ // Only WebM recordings have a separate .master.mkv. Guard the extension:
+ // for an MP4 the regex wouldn't match and `master` would resolve to the
+ // VOD file itself — deleting the recording.
+ const master = vod.master_file_path
+ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);
+ if (master && fs.existsSync(master)) {
+ const freedMb = (fs.statSync(master).size / 1024 / 1024).toFixed(0);
+ fs.unlinkSync(master);
+ if (vod.master_file_path) { try { db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vod.id]); } catch { /* */ } }
+ console.log(`[VOD-Health] Reclaimed orphaned master for vod ${vod.id} (${freedMb}MB)`);
+ }
+ } catch { /* */ }
+ return { id: vod.id, status: 'ok' };
+ }
+
+ // Broken but recordings are active → defer the heavy recovery/quarantine to
+ // the next idle pass. Don't stamp last_health_scan_at, so it stays first in
+ // the queue and is handled promptly once idle.
+ if (!deep) return { id: vod.id, status: 'deferred' };
+
+ // Broken → try to rebuild from the lossless master before quarantining.
+ let recovered = false;
+ if (scan.status !== 'missing_file') {
+ try {
+ const r = await scanner.recoverFromMaster(vod);
+ if (r.recovered) {
+ // Re-scan the rebuilt file to confirm it's actually good now.
+ const fresh = db.getVodById(vod.id) || vod;
+ const rescan = await scanner.scanVod(fresh, { decode: deep, repairDuration: true, quarantineBad: false });
+ recovered = !['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)
+ && !(rescan.issues || []).some(i => /decode_failed|probe_failed/.test(i));
+ if (recovered) {
+ console.log(`[VOD-Health] Recovered vod ${vod.id} from master (${Math.round(r.duration)}s)`);
+ return { id: vod.id, status: 'recovered' };
+ }
+ }
+ } catch (e) { console.warn(`[VOD-Health] master recovery error for vod ${vod.id}:`, e.message); }
+ }
+
+ // Still broken → quarantine (hide, keep the file for possible manual recovery).
+ // 'needs_review' (e.g. very short) is flagged but NOT hidden — it may be watchable.
+ const terminal = ['corrupt', 'zero_byte', 'missing_file'].includes(scan.status);
+ try {
+ db.updateVodHealth(vod.id, {
+ status: scan.status,
+ issues: scan.issues || [],
+ probeDuration: scan.probe ? scan.probe.duration : undefined,
+ probeFormat: scan.probe ? scan.probe.format : undefined,
+ quarantine: terminal, // only hide genuinely-broken files
+ keepPublic: !terminal,
+ });
+ } catch { /* */ }
+ if (terminal) console.warn(`[VOD-Health] Quarantined vod ${vod.id} — ${scan.status} (${(scan.issues || []).slice(0, 3).join(', ')})`);
+ return { id: vod.id, status: scan.status };
+}
+
+async function _scanPass(deep) {
+ const limit = deep ? SCAN_BATCH_IDLE : SCAN_BATCH_LIVE;
+ let vods = [];
+ try { vods = db.getVodsNeedingHealthScan({ staleDays: SCAN_STALE_DAYS, limit }); } catch { return; }
+ for (const vod of vods) {
+ try { await _scanOne(vod, { deep }); }
+ catch (e) { console.warn(`[VOD-Health] scan error for vod ${vod.id}:`, e.message); }
+ }
+}
+
+// Delete a VOD's files everywhere + its DB row. Mirrors the manual delete route.
+function _hardDeleteVod(vod) {
+ try {
+ if (vod.file_path) {
+ try { require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }
+ // Local file + sidecars (.seekable.webm / .seekable.mp4, .master.mkv).
+ for (const p of [vod.file_path, vod.file_path.replace(/\.webm$/, '.seekable.webm'), vod.file_path.replace(/\.mp4$/, '.seekable.mp4'), vod.file_path.replace(/\.webm$/, '.master.mkv'), vod.master_file_path].filter(Boolean)) {
+ try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* */ }
+ }
+ }
+ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);
+ return true;
+ } catch (e) {
+ console.warn(`[VOD-Health] cleanup delete failed for vod ${vod.id}:`, e.message);
+ return false;
+ }
+}
+
+async function _cleanupPass(deep) {
+ let vods = [];
+ try { vods = db.getQuarantinedVodsForCleanup({ graceDays: QUARANTINE_GRACE_DAYS, limit: CLEANUP_BATCH }); } catch { return; }
+ for (const vod of vods) {
+ // One last recovery attempt before deleting — the master may have survived
+ // even if the served file didn't. If it recovers, un-quarantine and keep it.
+ if (deep && vod.file_path && vod.health_status !== 'missing_file') {
+ try {
+ const r = await scanner.recoverFromMaster(vod);
+ if (r.recovered) {
+ const fresh = db.getVodById(vod.id) || vod;
+ const rescan = await scanner.scanVod(fresh, { decode: true, repairDuration: true, quarantineBad: false });
+ if (!['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)) {
+ db.run("UPDATE vods SET quarantined_at = NULL, health_status = 'ok' WHERE id = ?", [vod.id]);
+ console.log(`[VOD-Health] Cleanup recovered vod ${vod.id} from master — un-quarantined`);
+ continue;
+ }
+ }
+ } catch { /* fall through to delete */ }
+ }
+ if (_hardDeleteVod(vod)) {
+ console.log(`[VOD-Health] Cleaned up unrecoverable vod ${vod.id} (quarantined ${vod.health_status}, ${vod.quarantined_at})`);
+ }
+ }
+}
+
+// Filesystem sweep for orphaned .master.mkv files: reclaim masters whose VOD is
+// gone or finished-and-healthy. PROTECTS masters that are still needed — any
+// recording, and any broken/quarantined VOD whose master is a recovery source —
+// plus a 30-min mtime grace so a just-finished finalize is never raced.
+function _masterSweep() {
+ let dir, files;
+ try {
+ dir = path.resolve(config.vod.path);
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.master.mkv'));
+ } catch { return; }
+ if (!files.length) return;
+
+ const protectedNames = new Set();
+ try {
+ const rows = db.all(`SELECT file_path, master_file_path FROM vods
+ WHERE COALESCE(is_recording,0)=1
+ OR quarantined_at IS NOT NULL
+ OR health_status IN ('corrupt','zero_byte','needs_review')`);
+ for (const v of rows) {
+ if (v.master_file_path) protectedNames.add(path.basename(v.master_file_path));
+ if (v.file_path) protectedNames.add(path.basename(v.file_path.replace(/\.webm$/, '.master.mkv')));
+ }
+ } catch { /* on error, protect nothing extra — the mtime guard still applies */ }
+ // Belt-and-suspenders: never touch a master an active recording holds right now.
+ try {
+ const rec = require('./recorder');
+ for (const [, r] of (rec.activeRecordings || new Map())) if (r && r.masterFilePath) protectedNames.add(path.basename(r.masterFilePath));
+ } catch { /* */ }
+
+ const now = Date.now();
+ let n = 0, freed = 0;
+ for (const f of files) {
+ if (protectedNames.has(f)) continue;
+ const fp = path.join(dir, f);
+ try {
+ const st = fs.statSync(fp);
+ if (now - st.mtimeMs < 30 * 60 * 1000) continue; // too fresh — finalize may still need it
+ fs.unlinkSync(fp);
+ freed += st.size; n++;
+ if (n >= 25) break; // cap per sweep
+ } catch { /* */ }
+ }
+ if (n) console.log(`[VOD-Health] Master sweep: removed ${n} orphaned master(s), freed ${(freed / 1024 / 1024).toFixed(0)}MB`);
+}
+
+async function _tick() {
+ if (_busy) return;
+ _busy = true;
+ try {
+ const deep = !_anyRecording(); // full decode + recovery only while idle
+ await _scanPass(deep);
+ if (deep) { await _cleanupPass(deep); _masterSweep(); }
+ } catch (e) {
+ console.warn('[VOD-Health] tick error:', e.message);
+ } finally {
+ _busy = false;
+ }
+}
+
+function start() {
+ if (_running) return;
+ _running = true;
+ // First pass shortly after boot (idle window), then on the interval.
+ const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);
+ if (first.unref) first.unref();
+ _timer = setInterval(() => { _tick().catch(() => {}); }, TICK_MS);
+ if (_timer.unref) _timer.unref();
+ console.log('[VOD] Health job started (scan + master-recovery + quarantine cleanup)');
+}
+
+function stop() {
+ _running = false;
+ if (_timer) { clearInterval(_timer); _timer = null; }
+}
+
+module.exports = { start, stop, _tick };
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
+/**
+ * OpenVibe.Media — VOD health scanner
+ *
+ * Ported from the predecessor: probe / decode / remux / master-recovery
+ * primitives used by the background health job.
+ */
+'use strict';
+
+const { spawn } = require('child_process');
+const fs = require('fs');
+const db = require('../db/database');
+
+function probeMediaInfo(filePath) {
+ return new Promise((resolve) => {
+ const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
+ let out = '';
+ proc.stdout.on('data', d => out += d.toString());
+ proc.on('close', () => {
+ try {
+ const info = JSON.parse(out);
+ const duration = parseFloat(info.format?.duration || '0');
+ resolve({
+ ok: true,
+ duration: Number.isFinite(duration) ? duration : 0,
+ format: info.format || {},
+ streams: info.streams || [],
+ });
+ } catch {
+ resolve({ ok: false, duration: 0, format: null, streams: [] });
+ }
+ });
+ proc.on('error', () => resolve({ ok: false, duration: 0, format: null, streams: [] }));
+ setTimeout(() => { try { proc.kill(); } catch {} }, 10000);
+ });
+}
+
+function decodeVodFile(filePath) {
+ return new Promise((resolve) => {
+ const proc = spawn('ffmpeg', ['-v', 'warning', '-xerror', '-i', filePath, '-map', '0:v:0', '-f', 'null', '-']);
+ let stderr = '';
+ proc.stderr.on('data', d => stderr += d.toString());
+ proc.on('close', (code) => {
+ const warnings = stderr.split('\n').filter(line => /error|warn|corrupt|invalid|timestamp|RTP|concealing|Non-monotonous DTS/i.test(line));
+ resolve({ code, stderr, warnings, ok: code === 0 });
+ });
+ proc.on('error', () => resolve({ code: -1, stderr: '', warnings: [], ok: false }));
+ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);
+ });
+}
+
+function remuxForSeeking(filePath) {
+ return new Promise((resolve) => {
+ // Match the tmp container to the source so an H.264 .mp4 VOD isn't
+ // force-written into a VP8-only WebM (which just fails). For .mp4,
+ // faststart makes it instantly seekable.
+ const isMp4 = filePath.toLowerCase().endsWith('.mp4');
+ const tmpPath = isMp4 ? `${filePath}.remux.tmp.mp4` : `${filePath}.remux.tmp.webm`;
+ const args = isMp4
+ ? ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', '-movflags', '+faststart', tmpPath]
+ : ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', tmpPath];
+ const proc = spawn('ffmpeg', args);
+ proc.on('close', (code) => {
+ if (code === 0 && fs.existsSync(tmpPath)) {
+ try {
+ fs.renameSync(tmpPath, filePath);
+ resolve({ ok: true });
+ } catch (err) {
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
+ resolve({ ok: false, error: err.message });
+ }
+ } else {
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
+ resolve({ ok: false, error: `ffmpeg remux failed code ${code}` });
+ }
+ });
+ proc.on('error', (err) => {
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
+ resolve({ ok: false, error: err.message });
+ });
+ setTimeout(() => { try { proc.kill(); } catch {} }, 30000);
+ });
+}
+
+// Re-encode a served .webm from the lossless .master.mkv archive. Used by the
+// health job to REPAIR a broken VOD before resorting to quarantine.
+function _rebuildWebmFromMaster(masterPath, webmPath) {
+ return new Promise((resolve) => {
+ const tmp = webmPath + '.recover.webm';
+ const args = ['-y', '-i', masterPath,
+ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',
+ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',
+ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];
+ let ff;
+ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }
+ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);
+ ff.on('close', (code) => {
+ clearTimeout(to);
+ try {
+ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {
+ fs.renameSync(tmp, webmPath);
+ return resolve(true);
+ }
+ } catch { /* fall through */ }
+ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }
+ resolve(false);
+ });
+ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });
+ });
+}
+
+// Attempt to recover a broken VOD from its master archive. Rebuilds the webm,
+// remuxes it for seeking, re-probes, and (if longer) repairs the stored
+// duration. Returns { recovered: bool, duration }.
+async function recoverFromMaster(vod) {
+ // Only WebM recordings have a separate .master.mkv to rebuild from. An MP4
+ // has no master (it's itself a lossless copy); guard the extension so the
+ // regex can't fall through to the VOD's own path and overwrite the mp4.
+ const masterPath = vod.master_file_path
+ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);
+ if (!masterPath || !fs.existsSync(masterPath) || !vod.file_path) return { recovered: false, duration: 0 };
+ let masterDur = 0;
+ try { const mi = await probeMediaInfo(masterPath); masterDur = mi.ok ? mi.duration : 0; } catch { /* */ }
+ if (!(masterDur > 1)) return { recovered: false, duration: 0 };
+ const ok = await _rebuildWebmFromMaster(masterPath, vod.file_path);
+ if (!ok) return { recovered: false, duration: 0 };
+ try { await remuxForSeeking(vod.file_path); } catch { /* */ }
+ let dur = masterDur;
+ try { const mi2 = await probeMediaInfo(vod.file_path); if (mi2.ok && mi2.duration > 0) dur = mi2.duration; } catch { /* */ }
+ try {
+ const stat = fs.statSync(vod.file_path);
+ db.repairVodDuration(vod.id, Math.round(dur), stat.size);
+ db.updateVodHealth(vod.id, { status: 'ok', issues: ['recovered_from_master'], probeDuration: dur });
+ } catch { /* */ }
+ return { recovered: true, duration: dur };
+}
+
+async function scanVod(vod, options = {}) {
+ const filePath = vod.file_path;
+ const result = {
+ vodId: vod.id,
+ filePath,
+ status: 'unknown',
+ issues: [],
+ probe: null,
+ decode: null,
+ remux: null,
+ durationMatch: false,
+ };
+
+ if (!filePath || !fs.existsSync(filePath)) {
+ // Offloaded to object storage (B2/R2) — the upload was size-verified,
+ // so skip local file checks instead of flagging it missing.
+ try {
+ if (require('./vod-storage').isRemote(vod)) {
+ result.status = 'offloaded';
+ return result;
+ }
+ } catch { /* fall through to missing_file */ }
+ result.status = 'missing_file';
+ result.issues.push('missing_file');
+ return result;
+ }
+
+ let stat;
+ try {
+ stat = fs.statSync(filePath);
+ if (stat.size <= 0) {
+ result.status = 'zero_byte';
+ result.issues.push('zero_byte');
+ return result;
+ }
+ } catch (err) {
+ result.status = 'missing_file';
+ result.issues.push('stat_error');
+ return result;
+ }
+
+ const probeInfo = await probeMediaInfo(filePath);
+ result.probe = probeInfo;
+ if (!probeInfo.ok) {
+ result.status = 'needs_review';
+ result.issues.push('probe_failed');
+ } else {
+ if (!probeInfo.duration || probeInfo.duration <= 0) {
+ result.status = 'needs_review';
+ result.issues.push('invalid_duration');
+ }
+ if (vod.duration_seconds <= 0 && probeInfo.duration > 0) {
+ result.durationMatch = false;
+ result.issues.push('duration_repair_available');
+ } else if (Math.abs((vod.duration_seconds || 0) - probeInfo.duration) > 2) {
+ result.durationMatch = false;
+ result.issues.push('duration_mismatch');
+ } else {
+ result.durationMatch = true;
+ }
+ }
+
+ if (options.decode && probeInfo.ok && probeInfo.duration > 0) {
+ const decodeResult = await decodeVodFile(filePath);
+ result.decode = decodeResult;
+ if (!decodeResult.ok) {
+ result.issues.push('decode_failed');
+ }
+ if (decodeResult.warnings.length > 0) {
+ result.issues.push(...decodeResult.warnings.map(w => w.trim()).filter(Boolean));
+ }
+ }
+
+ if (options.remux && probeInfo.ok) {
+ result.remux = await remuxForSeeking(filePath);
+ if (!result.remux.ok) {
+ result.issues.push('remux_failed');
+ }
+ }
+
+ if (result.issues.length === 0) {
+ result.status = 'ok';
+ } else if (result.status === 'unknown') {
+ result.status = result.issues.includes('decode_failed') || result.issues.some(i => /invalid|corrupt|failed/.test(i))
+ ? 'corrupt'
+ : 'needs_review';
+ }
+
+ if (options.repairDuration && probeInfo.ok && probeInfo.duration > 0 && (vod.duration_seconds || 0) <= 0) {
+ result.repair = db.repairVodDuration(vod.id, Math.round(probeInfo.duration), stat.size);
+ result.issues.push('duration_repaired');
+ result.status = 'duration_repaired';
+ }
+
+ if (options.quarantineBad && ['corrupt', 'missing_file', 'zero_byte'].includes(result.status)) {
+ db.updateVodHealth(vod.id, {
+ status: result.status,
+ issues: result.issues,
+ probeDuration: probeInfo.duration,
+ probeFormat: probeInfo.format,
+ quarantine: true,
+ });
+ }
+
+ return result;
+}
+
+module.exports = {
+ probeMediaInfo,
+ decodeVodFile,
+ remuxForSeeking,
+ recoverFromMaster,
+ scanVod,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,315 @@
@@ -0,0 +1,315 @@
+/**
+ * OpenVibe.Media — shared ffmpeg/ffprobe helpers
+ *
+ * Extracted from the predecessor's VOD routes: seekable remux (WebM cues /
+ * MP4 faststart), DVR sidecar remux for live recordings, probes, and the
+ * browser-chunk segment concat used by the chunked upload flow.
+ */
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const { spawn } = require('child_process');
+
+/**
+ * Remux a WebM/MP4 file with ffmpeg to add proper seek metadata.
+ * WebM files from MediaRecorder lack Cues and often have Inf duration,
+ * which prevents browser-side seeking. A fast copy-remux fixes this.
+ * MP4: rewrite the fragmented recording into a plain, faststart (moov-at-front)
+ * MP4 so finished-VOD playback seeks instantly. Replaces the original in-place.
+ */
+function remuxForSeeking(filePath) {
+ return new Promise((resolve) => {
+ const ext = path.extname(filePath).toLowerCase();
+ if (ext !== '.webm' && ext !== '.mp4') return resolve(false); // WebM + MP4 only
+
+ const tmpPath = filePath + '.remux' + ext;
+ const outArgs = ext === '.mp4'
+ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']
+ : ['-c', 'copy', '-fflags', '+genpts'];
+ const proc = spawn('ffmpeg', [
+ '-y', '-i', filePath,
+ ...outArgs,
+ tmpPath,
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
+
+ let stderr = '';
+ proc.stderr.on('data', d => stderr += d);
+
+ proc.on('close', (code) => {
+ if (code === 0 && fs.existsSync(tmpPath)) {
+ try {
+ fs.renameSync(tmpPath, filePath);
+ console.log(`[VOD] Remuxed for seeking: ${path.basename(filePath)}`);
+ resolve(true);
+ } catch (err) {
+ console.warn(`[VOD] Remux rename failed:`, err.message);
+ try { fs.unlinkSync(tmpPath); } catch {}
+ resolve(false);
+ }
+ } else {
+ console.warn(`[VOD] Remux failed (code ${code}): ${stderr.slice(-200)}`);
+ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
+ resolve(false);
+ }
+ });
+
+ proc.on('error', () => resolve(false));
+
+ // Timeout: 60s should be plenty for copy-mode remux
+ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);
+ });
+}
+
+/** Probe a media file's start_time (seconds), or 0 if probing fails. */
+function probeStartTime(filePath) {
+ return new Promise((resolve) => {
+ const probe = spawn('ffprobe', [
+ '-v', 'quiet', '-print_format', 'json',
+ '-show_entries', 'format=start_time',
+ filePath,
+ ]);
+ let out = '';
+ probe.stdout.on('data', d => out += d);
+ probe.on('close', () => {
+ try {
+ const info = JSON.parse(out);
+ const startTime = parseFloat(info.format?.start_time || '0');
+ resolve(Number.isFinite(startTime) && startTime > 0 ? startTime : 0);
+ } catch { resolve(0); }
+ });
+ probe.on('error', () => resolve(0));
+ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 5000);
+ });
+}
+
+function probeVodDuration(filePath) {
+ return new Promise((resolve) => {
+ const probe = spawn('ffprobe', [
+ '-v', 'quiet', '-print_format', 'json',
+ '-show_format', filePath,
+ ]);
+ let out = '';
+ probe.stdout.on('data', d => out += d);
+ probe.on('close', () => {
+ try {
+ const info = JSON.parse(out);
+ const duration = Math.round(parseFloat(info.format?.duration || '0'));
+ resolve(duration > 0 ? duration : 0);
+ } catch { resolve(0); }
+ });
+ probe.on('error', () => resolve(0));
+ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 10000);
+ });
+}
+
+function probeVodInfo(filePath) {
+ return new Promise((resolve) => {
+ const probe = spawn('ffprobe', [
+ '-v', 'quiet', '-print_format', 'json',
+ '-show_format', '-show_streams',
+ filePath,
+ ]);
+ let out = '';
+ probe.stdout.on('data', d => out += d);
+ probe.on('close', () => {
+ try {
+ const info = JSON.parse(out);
+ const duration = Math.round(parseFloat(info.format?.duration || '0'));
+ resolve({
+ duration: duration > 0 ? duration : 0,
+ format: info.format || null,
+ streams: info.streams || [],
+ });
+ } catch { resolve({ duration: 0, format: null, streams: [] }); }
+ });
+ probe.on('error', () => resolve({ duration: 0, format: null, streams: [] }));
+ setTimeout(() => { try { probe.kill(); } catch {} resolve({ duration: 0, format: null, streams: [] }); }, 10000);
+ });
+}
+
+function getFileSizeSafe(filePath) {
+ try { return filePath && fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; } catch { return 0; }
+}
+
+function cleanupTempFile(filePath) {
+ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* ignore */ }
+}
+
+// ── Live DVR sidecar ─────────────────────────────────────────
+
+const _liveRemuxInProgress = new Set();
+
+function seekableSidecarPath(filePath) {
+ if (!filePath) return null;
+ if (filePath.endsWith('.webm')) return filePath.replace(/\.webm$/, '.seekable.webm');
+ if (filePath.endsWith('.mp4')) return filePath.replace(/\.mp4$/, '.seekable.mp4');
+ return null;
+}
+
+/**
+ * Produce a fully-indexed seekable snapshot of the growing recording so DVR
+ * viewers can seek anywhere without the whole file. WebM: copy-remux to write
+ * cues. MP4: copy-remux with +faststart so a complete moov sits at the front.
+ * Writes to <file>.seekable.<ext> WITHOUT touching the growing original.
+ */
+function remuxForLiveSeeking(filePath) {
+ const seekablePath = seekableSidecarPath(filePath);
+ if (!seekablePath) return Promise.resolve(false);
+ if (_liveRemuxInProgress.has(filePath)) return Promise.resolve(false);
+ _liveRemuxInProgress.add(filePath);
+
+ const isMp4 = filePath.endsWith('.mp4');
+ const tmpPath = filePath + (isMp4 ? '.live-remux.tmp.mp4' : '.live-remux.tmp.webm');
+ const outArgs = isMp4
+ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']
+ : ['-c', 'copy', '-fflags', '+genpts'];
+
+ return new Promise((resolve) => {
+ const proc = spawn('ffmpeg', [
+ '-y', '-i', filePath,
+ ...outArgs,
+ tmpPath,
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
+
+ let stderr = '';
+ proc.stderr.on('data', d => stderr += d);
+
+ proc.on('close', (code) => {
+ _liveRemuxInProgress.delete(filePath);
+ if (code === 0 && fs.existsSync(tmpPath)) {
+ try {
+ fs.renameSync(tmpPath, seekablePath);
+ resolve(true);
+ } catch (err) {
+ console.warn(`[VOD] Live remux rename failed:`, err.message);
+ try { fs.unlinkSync(tmpPath); } catch {}
+ resolve(false);
+ }
+ } else {
+ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
+ resolve(false);
+ }
+ });
+
+ proc.on('error', () => {
+ _liveRemuxInProgress.delete(filePath);
+ resolve(false);
+ });
+
+ setTimeout(() => {
+ _liveRemuxInProgress.delete(filePath);
+ try { proc.kill(); } catch {}
+ }, 30000);
+ });
+}
+
+/**
+ * Clean up the .seekable.* sidecar after finalization. Guard: the sidecar path
+ * must differ from the VOD itself (a bad regex fall-through would otherwise
+ * delete the recording); seekableSidecarPath returns null for unknown exts.
+ */
+function cleanupSeekableFile(filePath) {
+ const seekablePath = seekableSidecarPath(filePath);
+ if (!seekablePath || seekablePath === filePath) return;
+ try { if (fs.existsSync(seekablePath)) fs.unlinkSync(seekablePath); } catch {}
+}
+
+// ── Browser-chunk segments (chunked upload flow) ─────────────
+
+function makeSegmentPath(filePath, segmentId) {
+ const base = filePath.replace(/\.webm$/, '');
+ return `${base}.seg-${segmentId}-${Date.now()}.webm`;
+}
+
+function getPendingSegmentFiles(filePath) {
+ const dir = path.dirname(filePath);
+ const base = path.basename(filePath).replace(/\.webm$/, '');
+ try {
+ return fs.readdirSync(dir)
+ .filter((name) => name.startsWith(`${base}.seg-`) && name.endsWith('.webm'))
+ .map((name) => path.join(dir, name))
+ .sort((a, b) => {
+ const am = /\.seg-(\d+)-(\d+)\.webm$/.exec(a);
+ const bm = /\.seg-(\d+)-(\d+)\.webm$/.exec(b);
+ const aSeg = parseInt(am?.[1] || '0', 10);
+ const bSeg = parseInt(bm?.[1] || '0', 10);
+ if (aSeg !== bSeg) return aSeg - bSeg;
+ const aTs = parseInt(am?.[2] || '0', 10);
+ const bTs = parseInt(bm?.[2] || '0', 10);
+ return aTs - bTs;
+ });
+ } catch {
+ return [];
+ }
+}
+
+function concatWebmFiles(basePath, appendPath) {
+ if (!appendPath || !fs.existsSync(appendPath)) return Promise.resolve(true);
+ if (!basePath || !fs.existsSync(basePath)) {
+ fs.renameSync(appendPath, basePath);
+ return Promise.resolve(true);
+ }
+
+ const listPath = `${basePath}.concat.${Date.now()}.txt`;
+ const tmpPath = `${basePath}.concat.tmp.webm`;
+ const escapedBase = basePath.replace(/'/g, `'\\''`);
+ const escapedAppend = appendPath.replace(/'/g, `'\\''`);
+ fs.writeFileSync(listPath, `file '${escapedBase}'\nfile '${escapedAppend}'\n`, 'utf8');
+
+ const runConcat = (args) => new Promise((resolve) => {
+ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+ let stderr = '';
+ proc.stderr.on('data', (d) => stderr += d);
+ proc.on('close', (code) => resolve({ code, stderr }));
+ proc.on('error', () => resolve({ code: -1, stderr: 'spawn error' }));
+ setTimeout(() => { try { proc.kill(); } catch {} }, 120000);
+ });
+
+ return (async () => {
+ try {
+ let result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-fflags', '+genpts', tmpPath]);
+ if (result.code !== 0 || !fs.existsSync(tmpPath)) {
+ result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c:v', 'libvpx-vp9', '-crf', '32', '-b:v', '0', '-c:a', 'libopus', '-b:a', '128k', tmpPath]);
+ }
+ if (result.code === 0 && fs.existsSync(tmpPath)) {
+ fs.renameSync(tmpPath, basePath);
+ cleanupTempFile(appendPath);
+ cleanupTempFile(listPath);
+ return true;
+ }
+ cleanupTempFile(tmpPath);
+ cleanupTempFile(listPath);
+ return false;
+ } catch {
+ cleanupTempFile(tmpPath);
+ cleanupTempFile(listPath);
+ return false;
+ }
+ })();
+}
+
+async function mergePendingSegments(filePath) {
+ for (const segmentPath of getPendingSegmentFiles(filePath)) {
+ const ok = await concatWebmFiles(filePath, segmentPath);
+ if (!ok) {
+ console.warn(`[VOD] Failed to merge segment into ${path.basename(filePath)}: ${path.basename(segmentPath)}`);
+ }
+ }
+}
+
+module.exports = {
+ remuxForSeeking,
+ remuxForLiveSeeking,
+ seekableSidecarPath,
+ cleanupSeekableFile,
+ probeStartTime,
+ probeVodDuration,
+ probeVodInfo,
+ getFileSizeSafe,
+ cleanupTempFile,
+ makeSegmentPath,
+ getPendingSegmentFiles,
+ concatWebmFiles,
+ mergePendingSegments,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,569 @@
@@ -0,0 +1,569 @@
+/**
+ * OpenVibe.Media — Stream Recorder (RTMP pull + RTP ingest)
+ *
+ * Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps
+ * own their stream state and point ingest at a VOD they created):
+ *
+ * RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL, lossless
+ * stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
+ * CPU, live-seekable, kill-tolerant, and IS its own lossless master).
+ * RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
+ * from the pool (RTP_PORT_MIN..MAX on 127.0.0.1), writes an SDP and
+ * starts ffmpeg listening. Codec passthrough (no decode → no re-encode):
+ * H.264 → fragmented .mp4 (video copy, audio→AAC); VP8/VP9 → .webm
+ * (video + Opus copied). Anything exotic falls back to a libvpx
+ * re-encode (with an H.264 lossless master when applicable).
+ *
+ * Do NOT hardcode .webm — the served extension follows the copied container.
+ *
+ * A periodic remux writes a .seekable sidecar every 60s so DVR viewers can
+ * seek into the growing recording (see media-tools.remuxForLiveSeeking).
+ */
+'use strict';
+
+const { spawn } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const db = require('../db/database');
+const config = require('../config');
+const tools = require('./media-tools');
+
+// Grace period for ffmpeg to flush the trailer/cues after SIGINT before we
+// force-kill. Generous on purpose — a long recording's trailer flush must not
+// be cut short (that truncates the VOD). Cleared the instant ffmpeg exits.
+const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;
+
+// Disk guardian thresholds (free bytes on the VOD volume).
+const DISK_WARN_BYTES = (parseFloat(process.env.VOD_DISK_WARN_GB) || 15) * 1024 * 1024 * 1024;
+const DISK_CRIT_BYTES = (parseFloat(process.env.VOD_DISK_CRIT_GB) || 5) * 1024 * 1024 * 1024;
+
+function _isControlledFfmpegError(line, expectedShutdown) {
+ if (!line || !expectedShutdown) return false;
+ const normalized = line.toLowerCase();
+ return /demux.*timeout|timeout|broken pipe|connection.*reset|closed|end of file|sigterm|sigint|error while reading/i.test(normalized);
+}
+
+function _isFfmpegCorruptionLine(line) {
+ if (!line) return false;
+ const normalized = line.toLowerCase();
+ return /error while decoding|concealing|non[- ]monotonically increasing dts|missing picture in access unit|invalid .* header|invalid .* nal unit|could not find codec parameters|moov atom|invalid packet/i.test(normalized);
+}
+
+function _trackFfmpegDiagnostics(line, recording) {
+ if (!recording || !_isFfmpegCorruptionLine(line)) return;
+ recording.ffmpegCorruptionWarnings = (recording.ffmpegCorruptionWarnings || 0) + 1;
+ if (recording.ffmpegCorruptionWarnings >= 5) {
+ recording._ffmpegCorrupted = true;
+ }
+}
+
+// ── RTP port pool (UDP 12000-12199 on 127.0.0.1 per CONTRACTS) ──
+
+const _usedPorts = new Set();
+
+/** Allocate an even base port with base+1 free (RTP + RTCP). */
+function allocatePortPair() {
+ let start = config.rtp.portMin;
+ if (start % 2 !== 0) start += 1;
+ for (let p = start; p + 1 <= config.rtp.portMax; p += 2) {
+ if (!_usedPorts.has(p) && !_usedPorts.has(p + 1)) {
+ _usedPorts.add(p);
+ _usedPorts.add(p + 1);
+ return p;
+ }
+ }
+ return null;
+}
+
+function releasePortPair(basePort) {
+ if (!basePort) return;
+ _usedPorts.delete(basePort);
+ _usedPorts.delete(basePort + 1);
+}
+
+// ── SDP for ffmpeg receiving RTP (PlainRtpTransport senders) ──
+
+function _formatFmtpParameters(params) {
+ return Object.entries(params || {}).map(([k, v]) => `${k}=${v}`).join(';');
+}
+
+/**
+ * Build an SDP string for FFmpeg to receive RTP.
+ * video/audio descriptors: { payloadType, codec, clockRate, channels?, parameters?, ssrc?, rtcpFeedback? }
+ */
+function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtcpPort) {
+ const lines = [
+ 'v=0',
+ 'o=- 0 0 IN IP4 127.0.0.1',
+ 's=OpenVibe.Media VOD Recording',
+ 'c=IN IP4 127.0.0.1',
+ 't=0 0',
+ ];
+
+ const vPT = video.payloadType;
+ const vCodecName = String(video.codec || 'VP8').split('/').pop();
+ const vFeedback = Array.isArray(video.rtcpFeedback) ? video.rtcpFeedback : [];
+ const videoProtocol = vFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
+ lines.push(`m=video ${videoPort} ${videoProtocol} ${vPT}`);
+ lines.push(`a=rtpmap:${vPT} ${vCodecName}/${video.clockRate || 90000}`);
+ if (videoRtcpPort) lines.push(`a=rtcp:${videoRtcpPort} IN IP4 127.0.0.1`);
+ if (video.ssrc) lines.push(`a=ssrc:${video.ssrc} cname:record-video`);
+ if (video.parameters) {
+ const fmtp = _formatFmtpParameters(video.parameters);
+ if (fmtp) lines.push(`a=fmtp:${vPT} ${fmtp}`);
+ }
+ for (const fb of vFeedback) {
+ if (!fb || !fb.type) continue;
+ lines.push(`a=rtcp-fb:${vPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
+ }
+ lines.push('a=recvonly');
+
+ if (audio && audioPort) {
+ const aPT = audio.payloadType;
+ const aCodecName = String(audio.codec || 'opus').split('/').pop();
+ const channels = audio.channels || 2;
+ const aFeedback = Array.isArray(audio.rtcpFeedback) ? audio.rtcpFeedback : [];
+ const audioProtocol = aFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
+ lines.push(`m=audio ${audioPort} ${audioProtocol} ${aPT}`);
+ lines.push(`a=rtpmap:${aPT} ${aCodecName}/${audio.clockRate || 48000}/${channels}`);
+ if (audioRtcpPort) lines.push(`a=rtcp:${audioRtcpPort} IN IP4 127.0.0.1`);
+ if (audio.ssrc) lines.push(`a=ssrc:${audio.ssrc} cname:record-audio`);
+ if (audio.parameters) {
+ const fmtp = _formatFmtpParameters(audio.parameters);
+ if (fmtp) lines.push(`a=fmtp:${aPT} ${fmtp}`);
+ }
+ for (const fb of aFeedback) {
+ if (!fb || !fb.type) continue;
+ lines.push(`a=rtcp-fb:${aPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
+ }
+ lines.push('a=recvonly');
+ }
+ lines.push('');
+ return lines.join('\r\n');
+}
+
+class StreamRecorder {
+ constructor() {
+ /** @type {Map<number, object>} vodId → recording state */
+ this.activeRecordings = new Map();
+ this._diskState = 'ok'; // 'ok' | 'warning' | 'critical'
+
+ const vodDir = path.resolve(config.vod.path);
+ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });
+ }
+
+ isRecording(vodId) {
+ return this.activeRecordings.has(Number(vodId));
+ }
+
+ activeCount() {
+ return this.activeRecordings.size;
+ }
+
+ _cleanupFailedVod(vodId, filePath) {
+ if (!filePath || !fs.existsSync(filePath)) {
+ try {
+ db.run('DELETE FROM vods WHERE id = ?', [vodId]);
+ console.log(`[VOD] Deleted stale failed VOD ${vodId}`);
+ } catch (err) {
+ console.warn(`[VOD] Failed to delete stale VOD ${vodId}:`, err.message);
+ }
+ return;
+ }
+ try {
+ db.run(
+ 'UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = datetime(\'now\'), is_public = 0 WHERE id = ?',
+ ['corrupt', JSON.stringify(['failed_recording_start']), vodId]
+ );
+ console.log(`[VOD] Marked failed VOD ${vodId} as corrupt`);
+ } catch (err) {
+ console.warn(`[VOD] Failed to mark VOD ${vodId} as corrupt:`, err.message);
+ }
+ }
+
+ _guardCanRecord(vod) {
+ if (!vod) return { ok: false, error: 'VOD not found' };
+ if (this.activeRecordings.has(vod.id)) return { ok: false, error: 'Already recording this VOD' };
+ // Disk guardian: when the VOD volume is critically low, refuse to start
+ // rather than risk filling the disk (which corrupts every active recording).
+ if (this._diskState === 'critical') {
+ return { ok: false, error: 'Disk critically low — recording refused' };
+ }
+ return { ok: true };
+ }
+
+ _registerCommon(vod, proc, filePath, masterPath, extra = {}) {
+ const recording = {
+ vodId: vod.id,
+ appId: vod.app_id,
+ process: proc,
+ filePath,
+ masterFilePath: masterPath || null,
+ startTime: Date.now(),
+ remuxTimer: null,
+ _expectedShutdown: false,
+ ffmpegCorruptionWarnings: 0,
+ _ffmpegCorrupted: false,
+ ...extra,
+ };
+
+ // Periodic live-seeking remux: .seekable sidecar every 60s so DVR viewers
+ // can seek into the growing recording; first pass at 30s for early DVR.
+ recording.remuxTimer = setInterval(() => this._periodicRemux(vod.id), 60000);
+ setTimeout(() => {
+ if (this.activeRecordings.has(vod.id)) this._periodicRemux(vod.id);
+ }, 30000);
+
+ this.activeRecordings.set(vod.id, recording);
+ db.run('UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?', [filePath, vod.id]);
+ if (masterPath) db.run('UPDATE vods SET master_file_path = ? WHERE id = ?', [masterPath, vod.id]);
+ return recording;
+ }
+
+ _wireProcess(recording, label) {
+ const { process: proc, vodId } = recording;
+
+ proc.stderr?.on('data', (data) => {
+ const line = data.toString();
+ const rec = this.activeRecordings.get(vodId);
+ _trackFfmpegDiagnostics(line, rec);
+ if (line.includes('Error') || line.includes('error')) {
+ if (_isControlledFfmpegError(line, rec?._expectedShutdown)) return;
+ console.error(`[VOD] FFmpeg error (${label}, vod ${vodId}):`, line.trim());
+ }
+ });
+
+ proc.on('exit', (code, signal) => {
+ console.log(`[VOD] FFmpeg exited for vod ${vodId} (${label}, code: ${code}, signal: ${signal})`);
+ const rec = this.activeRecordings.get(vodId);
+ const finalizeOpts = {
+ startTimeMs: rec?.startTime,
+ ffmpegCorrupted: rec?._ffmpegCorrupted || false,
+ };
+ this._teardown(vodId);
+ // Let finalizeVod handle remux, probe, thumbnail, webhook.
+ // Short delay to ensure the file is fully flushed to disk.
+ setTimeout(() => {
+ require('./finalize').finalizeVod(vodId, finalizeOpts).catch(err => {
+ console.error(`[VOD] Finalization failed for vod ${vodId}:`, err.message);
+ });
+ }, 2000);
+ });
+
+ proc.on('error', (err) => {
+ console.error(`[VOD] FFmpeg spawn error (${label}, vod ${vodId}):`, err.message);
+ const rec = this.activeRecordings.get(vodId);
+ const filePath = rec?.filePath;
+ this._teardown(vodId);
+ require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {
+ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
+ });
+ if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);
+ });
+ }
+
+ _teardown(vodId) {
+ const rec = this.activeRecordings.get(vodId);
+ if (!rec) return;
+ if (rec._killTimer) { clearTimeout(rec._killTimer); rec._killTimer = null; }
+ if (rec.remuxTimer) { clearInterval(rec.remuxTimer); rec.remuxTimer = null; }
+ if (rec.sdpPath) { try { fs.unlinkSync(rec.sdpPath); } catch { /* */ } }
+ if (rec.rtpVideoPort) releasePortPair(rec.rtpVideoPort);
+ if (rec.rtpAudioPort) releasePortPair(rec.rtpAudioPort);
+ this.activeRecordings.delete(vodId);
+ }
+
+ /**
+ * Run periodic live-seeking remux and update DB duration/file size.
+ */
+ _periodicRemux(vodId) {
+ const rec = this.activeRecordings.get(vodId);
+ if (!rec || !rec.filePath || !fs.existsSync(rec.filePath)) return;
+
+ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);
+ try {
+ const stat = fs.statSync(rec.filePath);
+ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?',
+ [elapsed, stat.size, rec.vodId]);
+ } catch {}
+
+ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
+ }
+
+ // ── RTMP ingest ──────────────────────────────────────────
+
+ /**
+ * Pull an RTMP URL into a fragmented MP4 via lossless stream-copy.
+ * Exactly the predecessor's RTMP path — no re-encode, always real-time,
+ * live-seekable, kill-tolerant, its own lossless master.
+ */
+ startRtmp(vod, rtmpUrl) {
+ const guard = this._guardCanRecord(vod);
+ if (!guard.ok) return guard;
+ if (!/^rtmps?:\/\//i.test(String(rtmpUrl || ''))) {
+ return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
+ }
+
+ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}.mp4`;
+ const filePath = path.resolve(config.vod.path, filename);
+
+ const ffmpegArgs = [
+ '-y',
+ '-rw_timeout', '15000000',
+ '-i', rtmpUrl,
+ // ── Lossless stream-copy → fragmented MP4 ──
+ // No re-encode (H.264/AAC pass straight through from the RTMP feed), so
+ // this ALWAYS keeps real-time regardless of source resolution/bitrate.
+ // Fragmented (frag_keyframe/empty_moov/default_base_moof) so the growing
+ // file is seekable live for DVR and survives an abrupt kill without a
+ // moov-atom rewrite.
+ '-c', 'copy',
+ '-fflags', '+genpts',
+ '-max_muxing_queue_size', '1024',
+ '-movflags', '+frag_keyframe+empty_moov+default_base_moof',
+ '-f', 'mp4',
+ filePath,
+ ];
+
+ let proc;
+ try {
+ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (err) {
+ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };
+ }
+
+ const recording = this._registerCommon(vod, proc, filePath, null, { protocol: 'rtmp' });
+ this._wireProcess(recording, 'rtmp');
+
+ console.log(`[VOD] Recording started: vod ${vod.id} → ${filename} (rtmp pull)`);
+ return { ok: true, filePath };
+ }
+
+ // ── RTP ingest ───────────────────────────────────────────
+
+ /**
+ * Allocate ports, write SDP, start ffmpeg listening for RTP.
+ * @param {object} vod vods row
+ * @param {object} video { payloadType, codec, clockRate, ssrc?, parameters?, rtcpFeedback? }
+ * @param {object} [audio] same shape (codec e.g. 'opus')
+ * @returns {ok, videoPort, audioPort} — caller points PlainRtpTransports at 127.0.0.1
+ */
+ startRtp(vod, video, audio) {
+ const guard = this._guardCanRecord(vod);
+ if (!guard.ok) return guard;
+ if (!video || video.payloadType == null || !video.codec) {
+ return { ok: false, error: 'video { payloadType, codec, clockRate } required' };
+ }
+
+ const videoPort = allocatePortPair();
+ if (videoPort == null) return { ok: false, error: 'RTP port pool exhausted' };
+ const videoRtcpPort = videoPort + 1;
+ let audioPort = null;
+ let audioRtcpPort = null;
+ if (audio && audio.payloadType != null) {
+ audioPort = allocatePortPair();
+ if (audioPort == null) {
+ releasePortPair(videoPort);
+ return { ok: false, error: 'RTP port pool exhausted' };
+ }
+ audioRtcpPort = audioPort + 1;
+ }
+
+ const sdpContent = buildRtpSdp(video, audioPort ? audio : null, videoPort, videoRtcpPort, audioPort, audioRtcpPort);
+ const sdpPath = path.join(os.tmpdir(), `openvibe-media-vod-${vod.id}-${Date.now()}.sdp`);
+ try {
+ fs.writeFileSync(sdpPath, sdpContent, 'utf8');
+ } catch (err) {
+ releasePortPair(videoPort);
+ releasePortPair(audioPort);
+ return { ok: false, error: `SDP write failed: ${err.message}` };
+ }
+
+ const ffmpegArgs = [
+ '-y',
+ '-use_wallclock_as_timestamps', '1',
+ '-protocol_whitelist', 'file,rtp,udp',
+ '-thread_queue_size', '2048',
+ '-analyzeduration', '10000000',
+ '-probesize', '5000000',
+ '-avoid_negative_ts', 'make_zero',
+ // Don't honor any rotation display-matrix the source may (mis)send
+ // mid-stream — that flipped VODs 90° partway through.
+ '-noautorotate',
+ '-i', sdpPath,
+ '-fflags', '+genpts+discardcorrupt+nobuffer+igndts',
+ '-err_detect', 'ignore_err',
+ ];
+
+ // ── Codec-passthrough recording (no decode → no re-encode) ──────────
+ // Copying the bitstream straight through (exactly like the proven RTMP
+ // path) removes the decode entirely: zero re-encode CPU, no rotation/
+ // shear/melt (there is no decode to desync), no separate .master.mkv
+ // (the copied file IS lossless), kill-tolerant fragmented output.
+ // Mapping: H.264 → fragmented .mp4 (video copy, audio → AAC for universal
+ // browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +
+ // Opus copied, both native to WebM). Anything exotic falls back to the
+ // legacy libvpx re-encode (+ lossless master when H.264/Opus).
+ const vCodec = String(video.codec || '').toLowerCase();
+ const isH264 = vCodec.includes('h264');
+ const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');
+ const passthrough = isH264 || isVpx;
+
+ const ext = isH264 ? '.mp4' : '.webm';
+ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;
+ const filePath = path.resolve(config.vod.path, filename);
+ let masterPath = null;
+
+ const normalizeVod = process.env.VOD_NO_NORMALIZE !== '1';
+ const VOD_SCALE_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1';
+
+ if (passthrough) {
+ ffmpegArgs.push('-map', '0:v:0', '-c:v', 'copy');
+ if (audioPort) {
+ ffmpegArgs.push('-map', '0:a?');
+ if (isH264) {
+ ffmpegArgs.push('-c:a', 'aac', '-b:a', '160k'); // Opus → AAC (cheap, audio-only)
+ } else {
+ ffmpegArgs.push('-c:a', 'copy'); // Opus is native to WebM
+ }
+ } else {
+ ffmpegArgs.push('-an');
+ }
+ if (isH264) {
+ // Fragmented MP4: seekable while still growing (live DVR) and it
+ // survives an abrupt kill without a moov rewrite — same rationale
+ // as the RTMP path, which is why neither needs a lossless master.
+ ffmpegArgs.push('-max_muxing_queue_size', '1024',
+ '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', filePath);
+ } else {
+ ffmpegArgs.push('-f', 'webm', filePath);
+ }
+ } else {
+ // ── Legacy fallback: decode + re-encode to VP8/WebM, with a lossless
+ // stream-copy .master.mkv alongside (recovery fallback) unless the
+ // disk is under pressure. H.264/VP8/VP9 never reach this branch —
+ // they passthrough above — so this only handles exotic codecs.
+ masterPath = this._diskState === 'ok' ? filePath.replace(/\.webm$/, '.master.mkv') : null;
+ const libvpxVideo = [
+ ...(normalizeVod ? ['-vf', VOD_SCALE_VF] : []),
+ '-c:v', 'libvpx',
+ '-b:v', '2000k',
+ '-crf', '18',
+ '-deadline', 'realtime',
+ '-cpu-used', '4',
+ // Keyframe every 2s → seekable WebM
+ '-force_key_frames', 'expr:gte(t,n_forced*2)',
+ '-g', '240',
+ ];
+ if (masterPath) {
+ ffmpegArgs.push('-map', '0', '-c:v', 'copy', '-c:a', 'copy', '-f', 'matroska', masterPath, '-map', '0', ...libvpxVideo);
+ } else {
+ ffmpegArgs.push(...libvpxVideo);
+ }
+ if (audioPort) {
+ ffmpegArgs.push('-c:a', 'libopus', '-b:a', '128k', '-application', 'audio');
+ } else {
+ ffmpegArgs.push('-an');
+ }
+ ffmpegArgs.push('-f', 'webm', filePath);
+ }
+
+ let proc;
+ try {
+ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (err) {
+ try { fs.unlinkSync(sdpPath); } catch { /* */ }
+ releasePortPair(videoPort);
+ releasePortPair(audioPort);
+ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };
+ }
+
+ const recording = this._registerCommon(vod, proc, filePath, masterPath, {
+ protocol: 'rtp',
+ sdpPath,
+ rtpVideoPort: videoPort,
+ rtpAudioPort: audioPort,
+ });
+ this._wireProcess(recording, 'rtp');
+
+ const recMode = passthrough ? `passthrough ${isH264 ? 'H.264→mp4' : 'VP8/9→webm'} copy` : 'libvpx re-encode';
+ console.log(`[VOD] RTP recording started: vod ${vod.id} → ${filename} [${recMode}] (video:${videoPort}${audioPort ? ` audio:${audioPort}` : ''})`);
+ return { ok: true, videoPort, audioPort, filePath };
+ }
+
+ /**
+ * Gracefully stop recording a VOD.
+ * FFmpeg SIGINT triggers trailer write → exit handler → finalizeVod.
+ */
+ stopRecording(vodId) {
+ vodId = Number(vodId);
+ const recording = this.activeRecordings.get(vodId);
+ if (!recording) return false;
+
+ console.log(`[VOD] Stopping recording for vod ${vodId}`);
+ recording._expectedShutdown = true;
+
+ if (recording.remuxTimer) {
+ clearInterval(recording.remuxTimer);
+ recording.remuxTimer = null;
+ }
+
+ if (!recording.process) {
+ this._teardown(vodId);
+ this._cleanupFailedVod(vodId, recording.filePath);
+ return true;
+ }
+
+ try {
+ // SIGINT lets FFmpeg write cues/trailer for seekability
+ recording.process.kill('SIGINT');
+ } catch {
+ try { recording.process.kill('SIGTERM'); } catch { /* ignore */ }
+ }
+
+ // Safety net: force-kill only if FFmpeg is REALLY stuck. Force-killing too
+ // early truncates the file and produces short/unseekable VODs.
+ recording._killTimer = setTimeout(() => {
+ try {
+ if (recording.process && !recording.process.killed) {
+ console.warn(`[VOD] FFmpeg didn't exit within grace for vod ${vodId} — force-killing (VOD may be truncated)`);
+ recording.process.kill('SIGKILL');
+ }
+ } catch { /* ignore */ }
+ }, STOP_GRACE_MS);
+ return true;
+ }
+
+ /**
+ * Disk guardian: sample free space on the VOD volume. Under 'critical' no
+ * new recordings start (existing ones keep running). Called on an interval.
+ */
+ checkDisk() {
+ let free = 0;
+ try { free = require('./vod-storage').diskUsage(config.vod.path).available || 0; } catch { return; }
+ if (!free) return;
+ const prev = this._diskState;
+ const state = free < DISK_CRIT_BYTES ? 'critical' : free < DISK_WARN_BYTES ? 'warning' : 'ok';
+ this._diskState = state;
+ if (state !== prev) {
+ const gb = (free / 1024 / 1024 / 1024).toFixed(1);
+ if (state === 'ok') console.log(`[VOD] Disk recovered — ${gb}GB free (recordings back to normal)`);
+ else console.warn(`[VOD] Disk ${state.toUpperCase()} — only ${gb}GB free on the VOD volume`);
+ }
+ if (state === 'ok') return;
+
+ // Under pressure: nudge the storage sweep to offload cold VODs.
+ try { const vs = require('./vod-storage'); if (typeof vs.runSweep === 'function') vs.runSweep().catch(() => {}); } catch { /* */ }
+ }
+
+ /** Stop all active recordings (for graceful shutdown). */
+ stopAll() {
+ for (const [vodId] of this.activeRecordings) {
+ this.stopRecording(vodId);
+ }
+ }
+}
+
+module.exports = new StreamRecorder();
+module.exports.buildRtpSdp = buildRtpSdp;
new file mode 100644
new file mode 100644
@@ -0,0 +1,336 @@
@@ -0,0 +1,336 @@
+/**
+ * OpenVibe.Media — VOD API routes (mounted at /api/v1/:app/vods)
+ *
+ * POST / create VOD → { id }
+ * POST /:id/ingest/rtmp { rtmp_url } → 202 (ffmpeg pulls it)
+ * POST /:id/ingest/rtp/start { video, audio } → { videoPort, audioPort }
+ * POST /:id/ingest/rtp/stop finalize RTP recording → 200
+ * POST /:id/chunks multipart chunk append (user JWT ok)
+ * POST /:id/complete finalize chunked upload
+ * POST /:id/finalize close recording, kick off thumbnail+probe
+ * GET /:id VOD meta
+ * GET / list (?limit&offset&user_id&stream_id)
+ * PUT /:id update title/description/visibility
+ * DELETE /:id delete everywhere (local + B2 + R2)
+ */
+'use strict';
+
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const db = require('../db/database');
+const config = require('../config');
+const recorder = require('./recorder');
+const tools = require('./media-tools');
+const { finalizeVod, vodPublic } = require('./finalize');
+const { tenantAuth, tenantCors } = require('../auth');
+
+const router = express.Router({ mergeParams: true });
+router.use(tenantCors);
+
+// ── Chunk upload storage (browser MediaRecorder chunks) ──────
+const VOD_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };
+
+/** Strip codec params from MIME types like "video/webm;codecs=vp9,opus" → "video/webm" */
+function baseMediaType(mime) {
+ return (mime || '').split(';')[0].trim().toLowerCase();
+}
+
+const chunkStorage = multer.diskStorage({
+ destination: (req, file, cb) => {
+ const vodDir = path.resolve(config.vod.path);
+ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });
+ cb(null, vodDir);
+ },
+ filename: (req, file, cb) => {
+ const ext = VOD_MIME_TO_EXT[baseMediaType(file.mimetype)] || '.webm';
+ cb(null, `chunk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
+ },
+});
+const chunkUpload = multer({
+ storage: chunkStorage,
+ limits: { fileSize: config.vod.maxSizeMb * 1024 * 1024 },
+ fileFilter: (req, file, cb) => {
+ const base = baseMediaType(file.mimetype);
+ if (VOD_MIME_TO_EXT[base] || base === 'application/octet-stream' || base === '' || base.startsWith('video/')) cb(null, true);
+ else cb(new Error('Only video chunks are allowed'));
+ },
+});
+
+/**
+ * Active chunked uploads: vodId → { filePath, startTime, chunkCount,
+ * currentSegmentId, currentSegmentPath }. Rebuilt from the DB row if the
+ * server restarted mid-upload.
+ */
+const activeChunkUploads = new Map();
+
+function _getVodScoped(req, res) {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }
+ const vod = db.getVodById(id, req.appId);
+ if (!vod) { res.status(404).json({ error: 'VOD not found' }); return null; }
+ return vod;
+}
+
+// ── Create VOD ───────────────────────────────────────────────
+router.post('/', tenantAuth(), (req, res) => {
+ try {
+ const { title, stream_id, stream_key, user_id, meta, visibility, clips_only } = req.body || {};
+ const result = db.createVod({
+ app_id: req.appId,
+ stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,
+ stream_key: stream_key || null,
+ user_id: user_id != null ? user_id : null,
+ title: (title || 'Recording').toString().slice(0, 300),
+ meta,
+ });
+ const id = result.lastInsertRowid;
+ if (clips_only) db.run('UPDATE vods SET clips_only = 1 WHERE id = ?', [id]);
+ if (visibility) db.setVodVisibility(id, visibility);
+ res.status(201).json({ id });
+ } catch (err) {
+ console.error('[VOD] Create error:', err.message);
+ res.status(500).json({ error: 'Failed to create VOD' });
+ }
+});
+
+// ── RTMP ingest ──────────────────────────────────────────────
+router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+ const result = recorder.startRtmp(vod, req.body?.rtmp_url);
+ if (!result.ok) return res.status(409).json({ error: result.error });
+ res.status(202).json({ id: vod.id, status: 'recording' });
+ } catch (err) {
+ console.error('[VOD] RTMP ingest error:', err.message);
+ res.status(500).json({ error: 'Failed to start RTMP ingest' });
+ }
+});
+
+// ── RTP ingest ───────────────────────────────────────────────
+router.post('/:id/ingest/rtp/start', tenantAuth(), (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+ const { video, audio } = req.body || {};
+ const result = recorder.startRtp(vod, video, audio);
+ if (!result.ok) return res.status(409).json({ error: result.error });
+ res.json({ videoPort: result.videoPort, audioPort: result.audioPort || null });
+ } catch (err) {
+ console.error('[VOD] RTP start error:', err.message);
+ res.status(500).json({ error: 'Failed to start RTP ingest' });
+ }
+});
+
+router.post('/:id/ingest/rtp/stop', tenantAuth(), (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+ const stopped = recorder.stopRecording(vod.id);
+ if (!stopped && !vod.is_recording) return res.status(409).json({ error: 'VOD is not recording' });
+ // Finalization runs from the ffmpeg exit handler.
+ res.json({ id: vod.id, status: 'finalizing' });
+ } catch (err) {
+ console.error('[VOD] RTP stop error:', err.message);
+ res.status(500).json({ error: 'Failed to stop RTP ingest' });
+ }
+});
+
+// ── Chunked upload (browser MediaRecorder; user JWT ok) ──────
+router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('chunk'), async (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
+ if (!req.file) return res.status(400).json({ error: 'No chunk data' });
+ const segmentId = Math.max(1, parseInt(req.body?.segmentId || req.query.segmentId || '1', 10) || 1);
+
+ let rec = activeChunkUploads.get(vod.id);
+ if (!rec && vod.file_path && fs.existsSync(vod.file_path)) {
+ // Server restarted mid-upload — resume against the existing file.
+ rec = {
+ filePath: vod.file_path,
+ startTime: new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime(),
+ chunkCount: 0,
+ currentSegmentId: segmentId,
+ currentSegmentPath: vod.file_path,
+ };
+ activeChunkUploads.set(vod.id, rec);
+ }
+
+ if (!rec) {
+ // First chunk — establish the VOD file (contains the WebM header).
+ const ext = VOD_MIME_TO_EXT[baseMediaType(req.file.mimetype)] || '.webm';
+ const filename = `vod-${req.appId}-${vod.id}-${Date.now()}${ext}`;
+ const filePath = path.resolve(config.vod.path, filename);
+ fs.copyFileSync(req.file.path, filePath);
+ tools.cleanupTempFile(req.file.path);
+
+ db.run('UPDATE vods SET file_path = ?, file_size = ?, is_recording = 1 WHERE id = ?',
+ [filePath, fs.statSync(filePath).size, vod.id]);
+
+ rec = { filePath, startTime: Date.now(), chunkCount: 1, currentSegmentId: segmentId, currentSegmentPath: filePath };
+ activeChunkUploads.set(vod.id, rec);
+
+ console.log(`[VOD] Chunked recording started: vod ${vod.id} (${req.appId})`);
+ return res.json({ vodId: vod.id, chunkIndex: 0, status: 'created' });
+ }
+
+ if (rec.currentSegmentId !== segmentId) {
+ // New MediaRecorder segment (fresh WebM header) — buffer separately,
+ // concat into the base file (ffmpeg concat demuxer) between segments.
+ if (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath) {
+ await tools.concatWebmFiles(rec.filePath, rec.currentSegmentPath);
+ }
+ rec.currentSegmentId = segmentId;
+ rec.currentSegmentPath = tools.makeSegmentPath(rec.filePath, segmentId);
+ fs.copyFileSync(req.file.path, rec.currentSegmentPath);
+ tools.cleanupTempFile(req.file.path);
+ rec.chunkCount++;
+ } else {
+ // Append chunk to the current file/segment
+ const targetPath = rec.currentSegmentPath || rec.filePath;
+ const chunkData = fs.readFileSync(req.file.path);
+ fs.appendFileSync(targetPath, chunkData);
+ tools.cleanupTempFile(req.file.path);
+ rec.chunkCount++;
+ }
+
+ // Update file size and duration estimate in DB
+ const size = tools.getFileSizeSafe(rec.filePath)
+ + (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath ? tools.getFileSizeSafe(rec.currentSegmentPath) : 0);
+ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);
+ db.run('UPDATE vods SET file_size = ?, duration_seconds = ? WHERE id = ?', [size, elapsed, vod.id]);
+
+ // Seekable sidecar for live DVR (every 2 chunks ≈ ~60s)
+ if (rec.chunkCount >= 2 && rec.chunkCount % 2 === 0) {
+ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
+ }
+
+ res.json({ vodId: vod.id, chunkIndex: rec.chunkCount, status: 'appended' });
+ } catch (err) {
+ console.error('[VOD] Chunk upload error:', err.message);
+ if (req.file) tools.cleanupTempFile(req.file.path);
+ res.status(500).json({ error: 'Failed to save chunk' });
+ }
+});
+
+async function _finalizeHandler(req, res) {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+
+ // Live ffmpeg recording → stop gracefully; its exit handler finalizes.
+ if (recorder.isRecording(vod.id)) {
+ recorder.stopRecording(vod.id);
+ return res.status(202).json({ id: vod.id, status: 'finalizing' });
+ }
+
+ const chunkRec = activeChunkUploads.get(vod.id);
+ activeChunkUploads.delete(vod.id);
+ const result = await finalizeVod(vod.id, {
+ startTimeMs: chunkRec?.startTime,
+ segmentPath: chunkRec && chunkRec.currentSegmentPath !== chunkRec.filePath ? chunkRec.currentSegmentPath : null,
+ });
+ if (!result) return res.status(409).json({ error: 'Nothing to finalize (finalization already in progress or VOD discarded)' });
+ res.json({ vod: vodPublic(result) });
+ } catch (err) {
+ console.error('[VOD] Finalize error:', err.message);
+ res.status(500).json({ error: 'Failed to finalize VOD' });
+ }
+}
+
+router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
+router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
+
+// ── List ─────────────────────────────────────────────────────
+router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const user_id = req.query.user_id != null ? req.query.user_id : null;
+ const stream_id = req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null;
+ const vods = db.listVods(req.appId, { limit, offset, user_id, stream_id });
+ const total = db.countVods(req.appId, { user_id, stream_id });
+ res.json({ vods: vods.map(vodPublic), total, limit, offset });
+ } catch (err) {
+ console.error('[VOD] List error:', err.message);
+ res.status(500).json({ error: 'Failed to list VODs' });
+ }
+});
+
+// ── Get VOD meta ─────────────────────────────────────────────
+router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+
+ // Self-heal a missing duration on a finished local VOD.
+ if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {
+ const duration = await tools.probeVodDuration(vod.file_path);
+ if (duration > 0) {
+ const fileSize = tools.getFileSizeSafe(vod.file_path);
+ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?', [duration, fileSize, vod.id]);
+ vod.duration_seconds = duration;
+ vod.file_size = fileSize;
+ }
+ }
+
+ res.json({ vod: vodPublic(vod) });
+ } catch (err) {
+ console.error('[VOD] Get error:', err.message);
+ res.status(500).json({ error: 'Failed to get VOD' });
+ }
+});
+
+// ── Update ───────────────────────────────────────────────────
+router.put('/:id', tenantAuth(), (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+ const { title, description, visibility } = req.body || {};
+ const updates = [];
+ const params = [];
+ if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }
+ if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }
+ if (updates.length) {
+ params.push(vod.id);
+ db.run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
+ }
+ if (visibility !== undefined) db.setVodVisibility(vod.id, visibility);
+ res.json({ vod: vodPublic(db.getVodById(vod.id, req.appId)) });
+ } catch (err) {
+ console.error('[VOD] Update error:', err.message);
+ res.status(500).json({ error: 'Failed to update VOD' });
+ }
+});
+
+// ── Delete ───────────────────────────────────────────────────
+router.delete('/:id', tenantAuth(), (req, res) => {
+ try {
+ const vod = _getVodScoped(req, res);
+ if (!vod) return;
+
+ if (recorder.isRecording(vod.id)) recorder.stopRecording(vod.id);
+ activeChunkUploads.delete(vod.id);
+
+ // Delete media everywhere (local + B2 + R2)
+ if (vod.file_path) {
+ require('./vod-storage').deleteVodObjects(vod).catch((err) => {
+ console.warn(`[VOD] Object cleanup failed for VOD ${vod.id}:`, err.message);
+ });
+ tools.cleanupSeekableFile(vod.file_path);
+ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }
+ }
+
+ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);
+ res.json({ message: 'VOD deleted' });
+ } catch (err) {
+ console.error('[VOD] Delete error:', err.message);
+ res.status(500).json({ error: 'Failed to delete VOD' });
+ }
+});
+
+router.activeChunkUploads = activeChunkUploads;
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,851 @@
@@ -0,0 +1,851 @@
+/**
+ * OpenVibe.Media — VOD Storage Engine (local / Backblaze B2 / Cloudflare R2)
+ *
+ * Ported from the predecessor's object-storage tier manager:
+ *
+ * local — ./data/vods recordings + recent VODs (fast, limited)
+ * b2 — Backblaze B2 bucket canonical cold tier: unpopular VODs are
+ * uploaded here and deleted locally
+ * r2 — Cloudflare R2 bucket popularity cache: high-traffic VODs get a
+ * copy here (free egress) and play from R2
+ *
+ * Invariants:
+ * - vods.storage_provider ∈ ('local','b2','r2') = where playback comes from
+ * - every offloaded VOD has a canonical B2 object at vods/<basename>
+ * - provider 'r2' implies the B2 canonical object also exists; demoting
+ * from R2 just deletes the R2 copy and flips back to 'b2'
+ *
+ * Playback for b2/r2 is a 302 redirect to a presigned GET URL (range
+ * requests are handled by the object store; R2 egress is free, B2 gives
+ * 3x stored volume per day free).
+ *
+ * The periodic sweep:
+ * 1. offloads unpopular local VODs to B2 (age + views + last access);
+ * under disk pressure it drains oldest/least-watched VODs down to a
+ * low-water mark regardless of the popularity thresholds
+ * 2. promotes popular VODs to R2
+ * 3. demotes stale R2 VODs back to B2-only
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+const config = require('../config');
+const db = require('../db/database');
+
+let S3; // @aws-sdk/client-s3 (lazy)
+let LibStorage; // @aws-sdk/lib-storage (lazy)
+let Presigner; // @aws-sdk/s3-request-presigner (lazy)
+function loadSdk() {
+ if (!S3) {
+ S3 = require('@aws-sdk/client-s3');
+ LibStorage = require('@aws-sdk/lib-storage');
+ Presigner = require('@aws-sdk/s3-request-presigner');
+ }
+}
+
+// ── Provider configuration (env) ─────────────────────────────
+const PROVIDER_ENV = {
+ b2: {
+ endpoint: process.env.MEDIA_B2_ENDPOINT || '',
+ region: process.env.MEDIA_B2_REGION || 'us-west-004',
+ bucket: process.env.MEDIA_B2_BUCKET || '',
+ accessKeyId: process.env.MEDIA_B2_KEY_ID || '',
+ secretAccessKey: process.env.MEDIA_B2_APP_KEY || '',
+ forcePathStyle: true,
+ },
+ r2: {
+ endpoint: process.env.MEDIA_R2_ENDPOINT || '',
+ region: process.env.MEDIA_R2_REGION || 'auto',
+ bucket: process.env.MEDIA_R2_BUCKET || '',
+ accessKeyId: process.env.MEDIA_R2_ACCESS_KEY_ID || '',
+ secretAccessKey: process.env.MEDIA_R2_SECRET_ACCESS_KEY || '',
+ forcePathStyle: false,
+ },
+};
+
+const REMOTE_PROVIDERS = ['b2', 'r2'];
+const KEY_PREFIX = 'vods/';
+
+// ── Settings (media_settings, JSON values under storage_tier.*) ──
+const DEFAULTS = {
+ enabled: true,
+ // B2 cold-offload eligibility (a VOD must meet ALL of these)
+ minAgeDays: 7,
+ maxViewsForCold: 5,
+ minLastAccessDays: 3,
+ sweepIntervalMs: 15 * 60 * 1000,
+ // Disk pressure: above this % the sweep drains aggressively…
+ hotDiskPressurePct: 70,
+ // …until local disk is back under this %
+ localLowWaterPct: 60,
+ maxPerSweep: 40,
+ // R2 popularity tier
+ r2Enabled: true,
+ r2MinViews: 20,
+ r2RecentAccessDays: 3,
+ r2MaxIdleDays: 14,
+ r2MaxPerSweep: 5,
+};
+
+let sweepTimer = null;
+let sweepRunning = false;
+const clients = {}; // providerName → S3Client
+const providerHealthy = {}; // providerName → bool
+
+function getSetting(key) {
+ try {
+ const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);
+ if (row) return JSON.parse(row.value);
+ } catch { /* use default */ }
+ return DEFAULTS[key];
+}
+
+function getSettings() {
+ const s = {};
+ for (const key of Object.keys(DEFAULTS)) s[key] = getSetting(key);
+ return s;
+}
+
+function setSetting(key, value) {
+ if (!(key in DEFAULTS)) return;
+ const dbKey = `storage_tier.${key}`;
+ const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);
+ if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);
+ else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);
+}
+
+// ── Provider clients ─────────────────────────────────────────
+
+function providerConfigured(name) {
+ const p = PROVIDER_ENV[name];
+ return !!(p && p.endpoint && p.bucket && p.accessKeyId && p.secretAccessKey);
+}
+
+function clientFor(name) {
+ if (!providerConfigured(name)) return null;
+ if (!clients[name]) {
+ loadSdk();
+ const p = PROVIDER_ENV[name];
+ clients[name] = new S3.S3Client({
+ region: p.region,
+ endpoint: p.endpoint,
+ forcePathStyle: p.forcePathStyle,
+ credentials: { accessKeyId: p.accessKeyId, secretAccessKey: p.secretAccessKey },
+ });
+ }
+ return clients[name];
+}
+
+function keyForVod(vod) {
+ if (vod.storage_key) return vod.storage_key;
+ return KEY_PREFIX + path.basename(vod.file_path || '');
+}
+
+function localPathForVod(vod) {
+ return path.join(path.resolve(config.vod.path), path.basename(vod.file_path || ''));
+}
+
+function providerOf(vod) {
+ const p = String(vod?.storage_provider || 'local').toLowerCase();
+ return REMOTE_PROVIDERS.includes(p) ? p : 'local';
+}
+
+function isRemote(vod) {
+ return providerOf(vod) !== 'local';
+}
+
+// ── S3 primitives ────────────────────────────────────────────
+
+async function headObject(provider, key) {
+ const client = clientFor(provider);
+ if (!client) return null;
+ try {
+ const res = await client.send(new S3.HeadObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
+ return { size: Number(res.ContentLength || 0), etag: res.ETag || null };
+ } catch (err) {
+ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NotFound' || err?.name === 'NoSuchKey') return null;
+ throw err;
+ }
+}
+
+async function uploadFile(provider, key, filePath, contentType = 'video/webm') {
+ const client = clientFor(provider);
+ if (!client) throw new Error(`Provider ${provider} not configured`);
+ loadSdk();
+ const upload = new LibStorage.Upload({
+ client,
+ params: {
+ Bucket: PROVIDER_ENV[provider].bucket,
+ Key: key,
+ Body: fs.createReadStream(filePath),
+ ContentType: contentType,
+ },
+ partSize: 64 * 1024 * 1024,
+ queueSize: 3,
+ leavePartsOnError: false,
+ });
+ await upload.done();
+ // Verify size before anything destructive happens
+ const localSize = fs.statSync(filePath).size;
+ const head = await headObject(provider, key);
+ if (!head || head.size !== localSize) {
+ throw new Error(`Upload verification failed for ${provider}:${key} (local ${localSize}, remote ${head ? head.size : 'missing'})`);
+ }
+ return { size: localSize };
+}
+
+async function copyBetweenProviders(srcProvider, dstProvider, key) {
+ const src = clientFor(srcProvider);
+ const dst = clientFor(dstProvider);
+ if (!src || !dst) throw new Error(`Provider not configured for copy ${srcProvider}→${dstProvider}`);
+ loadSdk();
+ const obj = await src.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[srcProvider].bucket, Key: key }));
+ const upload = new LibStorage.Upload({
+ client: dst,
+ params: {
+ Bucket: PROVIDER_ENV[dstProvider].bucket,
+ Key: key,
+ Body: obj.Body,
+ ContentType: obj.ContentType || 'video/webm',
+ },
+ partSize: 64 * 1024 * 1024,
+ queueSize: 3,
+ leavePartsOnError: false,
+ });
+ await upload.done();
+ const expected = Number(obj.ContentLength || 0);
+ const head = await headObject(dstProvider, key);
+ if (!head || (expected && head.size !== expected)) {
+ throw new Error(`Copy verification failed for ${key} → ${dstProvider}`);
+ }
+ return { size: head.size };
+}
+
+async function deleteObject(provider, key) {
+ const client = clientFor(provider);
+ if (!client) return;
+ try {
+ await client.send(new S3.DeleteObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
+ } catch (err) {
+ console.warn(`[VodStorage] Delete ${provider}:${key} failed:`, err.message);
+ }
+}
+
+/**
+ * Best-effort removal of a paste's legacy B2 screenshot object(s). Legacy paste
+ * screenshots live in B2 under `community.attachments/objects/<date>/…paste-
+ * screenshot-<pasteId>.<ext>` (date-partitioned, key not stored in the DB), so we
+ * list the prefix and delete anything matching the paste id. Silent no-op if B2 is off.
+ */
+async function deleteLegacyPasteScreenshot(pasteId) {
+ const client = clientFor('b2');
+ if (!client || !pasteId) return;
+ const marker = `paste-screenshot-${pasteId}.`;
+ try {
+ let token;
+ do {
+ const r = await client.send(new S3.ListObjectsV2Command({
+ Bucket: PROVIDER_ENV.b2.bucket, Prefix: 'community.attachments/', ContinuationToken: token, MaxKeys: 1000,
+ }));
+ for (const o of (r.Contents || [])) {
+ if (o.Key.split('/').pop().includes(marker)) await deleteObject('b2', o.Key);
+ }
+ token = r.IsTruncated ? r.NextContinuationToken : null;
+ } while (token);
+ } catch (err) {
+ console.warn(`[VodStorage] Legacy paste screenshot cleanup failed for paste ${pasteId}:`, err.message);
+ }
+}
+
+async function presignGet(provider, key, expiresInSeconds = 900) {
+ const client = clientFor(provider);
+ if (!client) return null;
+ loadSdk();
+ // Force the response MIME so the browser plays the media even when the stored
+ // object metadata is generic (legacy clips were uploaded as octet-stream).
+ const ext = path.extname(key || '').toLowerCase();
+ const mime = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext];
+ return Presigner.getSignedUrl(client, new S3.GetObjectCommand({
+ Bucket: PROVIDER_ENV[provider].bucket,
+ Key: key,
+ ...(mime ? { ResponseContentType: mime } : {}),
+ }), { expiresIn: expiresInSeconds });
+}
+
+// ── Playback resolution ──────────────────────────────────────
+
+/**
+ * Returns { kind: 'file', path } for local VODs or
+ * { kind: 'redirect', url, provider } for offloaded ones.
+ * Falls back through r2 → b2 → local if an object is missing.
+ */
+async function resolvePlayback(vod) {
+ const provider = providerOf(vod);
+ const key = keyForVod(vod);
+
+ if (provider !== 'local') {
+ const order = provider === 'r2' ? ['r2', 'b2'] : ['b2', 'r2'];
+ for (const p of order) {
+ if (!providerConfigured(p) || providerHealthy[p] === false) continue;
+ try {
+ const url = await presignGet(p, key);
+ if (url) return { kind: 'redirect', url, provider: p };
+ } catch (err) {
+ console.warn(`[VodStorage] Presign failed for ${p}:${key}:`, err.message);
+ }
+ }
+ }
+
+ const local = localPathForVod(vod);
+ if (fs.existsSync(local)) return { kind: 'file', path: local };
+ return null;
+}
+
+/**
+ * A source ffmpeg/ffprobe can consume: local path, or a presigned https URL.
+ */
+async function resolveMediaSource(vod) {
+ const local = localPathForVod(vod);
+ if (fs.existsSync(local)) return { kind: 'file', value: local };
+ if (isRemote(vod)) {
+ const plan = await resolvePlayback(vod);
+ if (plan?.kind === 'redirect') return { kind: 'url', value: plan.url };
+ }
+ return null;
+}
+
+// ── Tier moves ───────────────────────────────────────────────
+
+function cleanupSidecar(localPath) {
+ for (const seekable of [localPath.replace(/\.webm$/, '.seekable.webm'), localPath.replace(/\.mp4$/, '.seekable.mp4')]) {
+ if (seekable !== localPath && fs.existsSync(seekable)) {
+ try { fs.unlinkSync(seekable); } catch { /* ignore */ }
+ }
+ }
+}
+
+/** Offload a local VOD to B2 (cold). */
+async function moveToCold(vodId) {
+ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
+ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
+ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };
+ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };
+
+ const key = keyForVod(vod);
+ const local = localPathForVod(vod);
+
+ try {
+ if (!fs.existsSync(local)) {
+ // Not local — maybe already offloaded
+ if (await headObject('b2', key)) {
+ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);
+ return { ok: true, already: true };
+ }
+ return { ok: false, error: 'Source file missing' };
+ }
+
+ const existing = await headObject('b2', key);
+ const localSize = fs.statSync(local).size;
+ if (!existing || existing.size !== localSize) {
+ await uploadFile('b2', key, local);
+ }
+
+ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);
+ try { fs.unlinkSync(local); } catch (err) {
+ console.error(`[VodStorage] Uploaded but failed to remove local file for VOD ${vodId}:`, err.message);
+ }
+ cleanupSidecar(local);
+
+ console.log(`[VodStorage] VOD ${vodId} offloaded to B2: ${key} (${(localSize / 1048576).toFixed(1)} MB)`);
+ return { ok: true, bytes: localSize };
+ } catch (err) {
+ console.error(`[VodStorage] Offload of VOD ${vodId} failed:`, err.message);
+ return { ok: false, error: err.message };
+ }
+}
+
+/** Restore an offloaded VOD to local disk. */
+async function moveToHot(vodId) {
+ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
+ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
+
+ const key = keyForVod(vod);
+ const local = localPathForVod(vod);
+
+ if (fs.existsSync(local)) {
+ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);
+ return { ok: true, already: true };
+ }
+
+ const srcProvider = ['b2', 'r2'].find(p => providerConfigured(p));
+ if (!srcProvider) return { ok: false, error: 'No remote provider configured' };
+
+ try {
+ loadSdk();
+ let provider = providerOf(vod) === 'r2' ? 'r2' : 'b2';
+ let head = await headObject(provider, key);
+ if (!head) {
+ provider = provider === 'r2' ? 'b2' : 'r2';
+ head = providerConfigured(provider) ? await headObject(provider, key) : null;
+ }
+ if (!head) return { ok: false, error: 'Object missing from remote storage' };
+
+ const client = clientFor(provider);
+ const obj = await client.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
+ const tmp = local + '.download';
+ await new Promise((resolve, reject) => {
+ const out = fs.createWriteStream(tmp);
+ obj.Body.pipe(out);
+ obj.Body.on('error', reject);
+ out.on('error', reject);
+ out.on('finish', resolve);
+ });
+ if (fs.statSync(tmp).size !== head.size) {
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
+ return { ok: false, error: 'Download verification failed' };
+ }
+ fs.renameSync(tmp, local);
+
+ // Restoring to local keeps the B2 canonical copy; drop any R2 copy.
+ if (providerOf(vod) === 'r2' && providerConfigured('r2')) await deleteObject('r2', key);
+ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);
+ console.log(`[VodStorage] VOD ${vodId} restored to local (${(head.size / 1048576).toFixed(1)} MB)`);
+ return { ok: true, bytes: head.size };
+ } catch (err) {
+ console.error(`[VodStorage] Restore of VOD ${vodId} failed:`, err.message);
+ return { ok: false, error: err.message };
+ }
+}
+
+/** Promote a popular VOD to R2 (free egress). Keeps the B2 canonical copy. */
+async function promoteToR2(vodId) {
+ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
+ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
+ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };
+ if (!providerConfigured('r2') || providerHealthy.r2 === false) return { ok: false, error: 'R2 not available' };
+ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };
+
+ const key = keyForVod(vod);
+ const local = localPathForVod(vod);
+
+ try {
+ // Ensure canonical B2 copy first
+ let b2Head = await headObject('b2', key);
+ if (!b2Head) {
+ if (!fs.existsSync(local)) return { ok: false, error: 'No source available for promotion' };
+ await uploadFile('b2', key, local);
+ b2Head = await headObject('b2', key);
+ }
+
+ // Put the R2 copy
+ const r2Head = await headObject('r2', key);
+ if (!r2Head || r2Head.size !== b2Head.size) {
+ if (fs.existsSync(local)) await uploadFile('r2', key, local);
+ else await copyBetweenProviders('b2', 'r2', key);
+ }
+
+ db.run("UPDATE vods SET storage_provider = 'r2', storage_key = ? WHERE id = ?", [key, vodId]);
+
+ // Popular VODs live in R2+B2; free the local copy
+ let freed = 0;
+ if (fs.existsSync(local)) {
+ freed = fs.statSync(local).size;
+ try { fs.unlinkSync(local); cleanupSidecar(local); } catch { freed = 0; }
+ }
+
+ console.log(`[VodStorage] VOD ${vodId} promoted to R2: ${key}`);
+ return { ok: true, bytes: freed };
+ } catch (err) {
+ console.error(`[VodStorage] R2 promotion of VOD ${vodId} failed:`, err.message);
+ return { ok: false, error: err.message };
+ }
+}
+
+/** Demote a stale R2 VOD back to B2-only. */
+async function demoteFromR2(vodId) {
+ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
+ if (!vod) return { ok: false, error: 'VOD not found' };
+ if (providerOf(vod) !== 'r2') return { ok: true, already: true };
+
+ const key = keyForVod(vod);
+ try {
+ // Canonical must exist in B2 before we delete the R2 copy
+ const b2Head = await headObject('b2', key);
+ if (!b2Head) {
+ const copied = await copyBetweenProviders('r2', 'b2', key).catch(() => null);
+ if (!copied) return { ok: false, error: 'No B2 canonical and copy-back failed' };
+ }
+ await deleteObject('r2', key);
+ db.run("UPDATE vods SET storage_provider = 'b2' WHERE id = ?", [vodId]);
+ console.log(`[VodStorage] VOD ${vodId} demoted from R2 to B2`);
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+}
+
+/** Delete a VOD's (or clip's — same columns) media everywhere (local + B2 + R2). */
+async function deleteVodObjects(vod) {
+ if (!vod?.file_path) return;
+ const local = localPathForVod(vod);
+ if (fs.existsSync(local)) {
+ try { fs.unlinkSync(local); } catch { /* ignore */ }
+ }
+ // Clips live in the clips dir — also try the record's own absolute path.
+ if (vod.file_path !== local && fs.existsSync(vod.file_path)) {
+ try { fs.unlinkSync(vod.file_path); } catch { /* ignore */ }
+ }
+ cleanupSidecar(local);
+ const key = keyForVod(vod);
+ for (const p of REMOTE_PROVIDERS) {
+ if (providerConfigured(p)) await deleteObject(p, key);
+ }
+}
+
+// ── Disk usage ───────────────────────────────────────────────
+
+function diskUsage(targetPath) {
+ try {
+ const resolved = path.resolve(targetPath);
+ const output = execSync(`df -B1 "${resolved}" 2>/dev/null | tail -1`, { encoding: 'utf8' });
+ const parts = output.trim().split(/\s+/);
+ if (parts.length >= 6) {
+ return {
+ total: parseInt(parts[1], 10) || 0,
+ used: parseInt(parts[2], 10) || 0,
+ available: parseInt(parts[3], 10) || 0,
+ usePct: parseFloat(parts[4]) || 0,
+ mount: parts[5] || '/',
+ };
+ }
+ } catch { /* ignore */ }
+ return { total: 0, used: 0, available: 0, usePct: 0, mount: '/' };
+}
+
+function dirStats(dirPath) {
+ let bytes = 0, files = 0;
+ try {
+ const resolved = path.resolve(dirPath);
+ if (!fs.existsSync(resolved)) return { bytes: 0, files: 0 };
+ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
+ if (entry.isFile()) {
+ try { bytes += fs.statSync(path.join(resolved, entry.name)).size; files++; } catch { /* ignore */ }
+ }
+ }
+ } catch { /* ignore */ }
+ return { bytes, files };
+}
+
+// ── Sweep ────────────────────────────────────────────────────
+
+async function runSweep() {
+ if (sweepRunning) return { skipped: true, reason: 'already running' };
+ sweepRunning = true;
+
+ try {
+ const settings = getSettings();
+ if (!settings.enabled) return { skipped: true, reason: 'disabled' };
+ if (!providerConfigured('b2')) return { skipped: true, reason: 'B2 not configured' };
+ if (providerHealthy.b2 === false) return { skipped: true, reason: 'B2 unhealthy' };
+
+ let migrated = 0, bytesFreed = 0, promoted = 0, demoted = 0;
+ const errors = [];
+
+ const disk = diskUsage(config.vod.path);
+ const underPressure = disk.usePct >= settings.hotDiskPressurePct;
+
+ // 1) Cold offload to B2
+ let candidates;
+ if (underPressure) {
+ console.log(`[VodStorage] Disk at ${disk.usePct}% (pressure ≥ ${settings.hotDiskPressurePct}%) — draining to ${settings.localLowWaterPct}%`);
+ candidates = db.all(`
+ SELECT id, file_path, file_size FROM vods
+ WHERE COALESCE(storage_provider, 'local') = 'local'
+ AND COALESCE(is_recording, 0) = 0
+ AND created_at <= datetime('now', '-1 day')
+ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC
+ LIMIT 100
+ `);
+ } else {
+ candidates = db.all(`
+ SELECT id, file_path, file_size FROM vods
+ WHERE COALESCE(storage_provider, 'local') = 'local'
+ AND COALESCE(is_recording, 0) = 0
+ AND created_at <= datetime('now', ?)
+ AND COALESCE(view_count, 0) <= ?
+ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))
+ ORDER BY view_count ASC, file_size DESC
+ LIMIT ?
+ `, [`-${settings.minAgeDays} days`, settings.maxViewsForCold, `-${settings.minLastAccessDays} days`, settings.maxPerSweep]);
+ }
+
+ for (const vod of candidates) {
+ if (underPressure && diskUsage(config.vod.path).usePct <= settings.localLowWaterPct) break;
+ const result = await moveToCold(vod.id);
+ if (result.ok && !result.already) { migrated++; bytesFreed += result.bytes || 0; }
+ else if (!result.ok) errors.push({ id: vod.id, error: result.error });
+ }
+
+ // 2) R2 promotion for popular VODs
+ if (settings.r2Enabled && providerConfigured('r2') && providerHealthy.r2 !== false) {
+ const popular = db.all(`
+ SELECT id FROM vods
+ WHERE COALESCE(storage_provider, 'local') IN ('local', 'b2')
+ AND COALESCE(is_recording, 0) = 0
+ AND COALESCE(view_count, 0) >= ?
+ AND last_accessed_at IS NOT NULL
+ AND last_accessed_at >= datetime('now', ?)
+ LIMIT ?
+ `, [settings.r2MinViews, `-${settings.r2RecentAccessDays} days`, settings.r2MaxPerSweep]);
+ for (const vod of popular) {
+ const result = await promoteToR2(vod.id);
+ if (result.ok && !result.already) { promoted++; bytesFreed += result.bytes || 0; }
+ else if (!result.ok) errors.push({ id: vod.id, error: result.error });
+ }
+
+ // 3) R2 demotion for stale VODs
+ const stale = db.all(`
+ SELECT id FROM vods
+ WHERE storage_provider = 'r2'
+ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))
+ LIMIT 10
+ `, [`-${settings.r2MaxIdleDays} days`]);
+ for (const vod of stale) {
+ const result = await demoteFromR2(vod.id);
+ if (result.ok && !result.already) demoted++;
+ }
+ }
+
+ const summary = {
+ checked: candidates.length,
+ migrated,
+ promoted,
+ demoted,
+ bytesFreed,
+ underPressure,
+ errors: errors.length ? errors : undefined,
+ timestamp: new Date().toISOString(),
+ };
+ if (migrated || promoted || demoted) {
+ console.log(`[VodStorage] Sweep: ${migrated} → B2, ${promoted} → R2, ${demoted} R2→B2, ${(bytesFreed / 1048576).toFixed(1)} MB freed locally`);
+ }
+ return summary;
+ } catch (err) {
+ console.error('[VodStorage] Sweep error:', err.message);
+ return { error: err.message };
+ } finally {
+ sweepRunning = false;
+ }
+}
+
+// ── Health + lifecycle ───────────────────────────────────────
+
+async function checkProviders() {
+ if (!providerConfigured('b2') && !providerConfigured('r2')) {
+ providerHealthy.b2 = false;
+ providerHealthy.r2 = false;
+ console.log('[VodStorage] No object-storage providers configured — local-only mode');
+ return { ...providerHealthy };
+ }
+ loadSdk();
+ for (const name of REMOTE_PROVIDERS) {
+ if (!providerConfigured(name)) { providerHealthy[name] = false; continue; }
+ try {
+ await clientFor(name).send(new S3.HeadBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));
+ providerHealthy[name] = true;
+ } catch (err) {
+ // R2 bucket may not exist yet — try to create it
+ if (name === 'r2') {
+ try {
+ await clientFor(name).send(new S3.CreateBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));
+ providerHealthy[name] = true;
+ console.log('[VodStorage] Created R2 bucket:', PROVIDER_ENV[name].bucket);
+ continue;
+ } catch (createErr) {
+ console.warn('[VodStorage] R2 bucket create failed:', createErr.message);
+ }
+ }
+ providerHealthy[name] = false;
+ console.warn(`[VodStorage] Provider ${name} unavailable:`, err.message);
+ }
+ }
+ console.log(`[VodStorage] Providers — b2: ${providerHealthy.b2 ? 'ok' : 'unavailable'}, r2: ${providerHealthy.r2 ? 'ok' : 'unavailable'}`);
+ return { ...providerHealthy };
+}
+
+/**
+ * One-time migration from the legacy hot/cold columns: rows imported with
+ * storage_tier='cold' were uploaded to B2 at vods/<basename> by the old rclone
+ * mount — verify and flip them to provider 'b2'.
+ */
+async function migrateLegacy() {
+ if (!providerConfigured('b2')) return;
+ const legacy = db.all(`
+ SELECT id, file_path FROM vods
+ WHERE storage_tier = 'cold' AND COALESCE(storage_provider, 'local') = 'local'
+ `);
+ if (!legacy.length) return;
+ let flipped = 0, restoredLocal = 0;
+ for (const vod of legacy) {
+ const key = KEY_PREFIX + path.basename(vod.file_path || '');
+ try {
+ const head = await headObject('b2', key);
+ if (head) {
+ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vod.id]);
+ flipped++;
+ } else if (fs.existsSync(localPathForVod(vod))) {
+ restoredLocal++; // still local, sweep will re-offload
+ } else {
+ console.warn(`[VodStorage] Legacy cold VOD ${vod.id} missing from B2 and local disk`);
+ }
+ } catch (err) {
+ console.warn(`[VodStorage] Legacy migration check failed for VOD ${vod.id}:`, err.message);
+ }
+ }
+ console.log(`[VodStorage] Legacy migration: ${flipped} cold VOD(s) mapped to B2${restoredLocal ? `, ${restoredLocal} still local` : ''}`);
+}
+
+function start() {
+ stop();
+ const settings = getSettings();
+ if (!settings.enabled) {
+ console.log('[VodStorage] Disabled — not starting sweep timer');
+ return;
+ }
+ console.log(`[VodStorage] Starting sweep timer (every ${(settings.sweepIntervalMs / 60000).toFixed(0)} min)`);
+ setTimeout(() => {
+ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));
+ sweepTimer = setInterval(() => {
+ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));
+ }, settings.sweepIntervalMs);
+ if (sweepTimer.unref) sweepTimer.unref();
+ }, 30_000).unref?.();
+}
+
+function stop() {
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
+}
+
+// ── Status ───────────────────────────────────────────────────
+
+function getStatus() {
+ const settings = getSettings();
+ const localDisk = diskUsage(config.vod.path);
+ const localStats = dirStats(config.vod.path);
+
+ const counts = db.get(`
+ SELECT
+ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,
+ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,
+ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count,
+ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) as localBytes,
+ SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) as b2Bytes,
+ SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) as r2Bytes
+ FROM vods
+ `) || {};
+
+ let clipCounts = {};
+ try {
+ clipCounts = db.get(`
+ SELECT
+ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,
+ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,
+ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count
+ FROM clips
+ `) || {};
+ } catch { clipCounts = {}; }
+
+ return {
+ settings,
+ engine: 'local+b2+r2',
+ providers: {
+ b2: { configured: providerConfigured('b2'), healthy: providerHealthy.b2 !== false, bucket: PROVIDER_ENV.b2.bucket },
+ r2: { configured: providerConfigured('r2'), healthy: providerHealthy.r2 !== false, bucket: PROVIDER_ENV.r2.bucket },
+ },
+ local: { disk: localDisk, vods: { bytes: localStats.bytes, files: localStats.files } },
+ tiers: {
+ local: { count: counts.localCount || 0, bytes: counts.localBytes || 0 },
+ b2: { count: counts.b2Count || 0, bytes: counts.b2Bytes || 0 },
+ r2: { count: counts.r2Count || 0, bytes: counts.r2Bytes || 0 },
+ },
+ clipTiers: {
+ local: { count: clipCounts.localCount || 0 },
+ b2: { count: clipCounts.b2Count || 0 },
+ r2: { count: clipCounts.r2Count || 0 },
+ },
+ sweepRunning,
+ };
+}
+
+module.exports = {
+ DEFAULTS,
+ providerOf,
+ isRemote,
+ keyForVod,
+ localPathForVod,
+ resolvePlayback,
+ resolveMediaSource,
+ moveToCold,
+ moveToHot,
+ promoteToR2,
+ demoteFromR2,
+ deleteVodObjects,
+ deleteObject,
+ deleteLegacyPasteScreenshot,
+ presignGet,
+ runSweep,
+ checkProviders,
+ migrateLegacy,
+ start,
+ stop,
+ getStatus,
+ getSettings,
+ setSetting,
+ diskUsage,
+ dirStats,
+};
+
+// ── CLI: node server/vod/vod-storage.js <check|migrate-legacy|drain [pct]> ──
+if (require.main === module) {
+ (async () => {
+ const cmd = process.argv[2];
+ if (cmd === 'check') {
+ await checkProviders();
+ console.log(JSON.stringify(getStatus().tiers, null, 2));
+ process.exit(0);
+ }
+ if (cmd === 'migrate-legacy') {
+ await checkProviders();
+ await migrateLegacy();
+ process.exit(0);
+ }
+ if (cmd === 'drain') {
+ await checkProviders();
+ const target = Number(process.argv[3] || getSetting('localLowWaterPct'));
+ console.log(`[VodStorage] CLI drain to ${target}% disk usage`);
+ for (let round = 0; round < 50; round++) {
+ const disk = diskUsage(config.vod.path);
+ console.log(`[VodStorage] Disk at ${disk.usePct}%`);
+ if (disk.usePct <= target) break;
+ const eligible = db.all(`
+ SELECT id FROM vods
+ WHERE COALESCE(storage_provider, 'local') = 'local'
+ AND COALESCE(is_recording, 0) = 0
+ AND created_at <= datetime('now', '-1 day')
+ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC
+ LIMIT 10
+ `);
+ if (!eligible.length) { console.log('[VodStorage] Nothing left to drain'); break; }
+ for (const vod of eligible) {
+ const r = await moveToCold(vod.id);
+ if (!r.ok) console.warn(` VOD ${vod.id}: ${r.error}`);
+ }
+ }
+ process.exit(0);
+ }
+ console.log('Usage: node server/vod/vod-storage.js <check|migrate-legacy|drain [targetPct]>');
+ process.exit(1);
+ })().catch(err => { console.error(err); process.exit(1); });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+/**
+ * OpenVibe.Media — Outbound webhooks
+ *
+ * POSTs { event, app_id, data } to the app's configured webhook_url with
+ * `X-OVMedia-Signature: sha256=<hmac>` — HMAC-SHA256 of the raw body using the
+ * app's webhook_secret (per CONTRACTS.md). Fire-and-forget with small retries.
+ *
+ * Events: vod.ready | vod.failed | clip.ready | clip.failed
+ */
+'use strict';
+
+const crypto = require('crypto');
+const db = require('./db/database');
+
+const MAX_ATTEMPTS = 3;
+const RETRY_BASE_MS = 3000;
+const TIMEOUT_MS = 10000;
+
+function sign(secret, rawBody) {
+ return 'sha256=' + crypto.createHmac('sha256', String(secret || '')).update(rawBody).digest('hex');
+}
+
+async function _post(url, rawBody, signature) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-OVMedia-Signature': signature,
+ },
+ body: rawBody,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+}
+
+/**
+ * Send an event to an app's webhook. Silently no-ops when the app has no
+ * webhook_url. Never throws.
+ * @param {string|object} appOrId app_id or apps row
+ * @param {string} event e.g. 'vod.ready'
+ * @param {object} data event payload
+ */
+async function sendWebhook(appOrId, event, data) {
+ let app = appOrId;
+ if (typeof appOrId === 'string') {
+ try { app = db.getApp(appOrId); } catch { app = null; }
+ }
+ if (!app || !app.webhook_url) return false;
+
+ const rawBody = JSON.stringify({ event, app_id: app.app_id, data });
+ const signature = sign(app.webhook_secret, rawBody);
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ await _post(app.webhook_url, rawBody, signature);
+ return true;
+ } catch (err) {
+ if (attempt === MAX_ATTEMPTS) {
+ console.warn(`[Webhooks] ${event} → ${app.app_id} failed after ${MAX_ATTEMPTS} attempts: ${err.message}`);
+ return false;
+ }
+ await new Promise(r => setTimeout(r, RETRY_BASE_MS * attempt));
+ }
+ }
+ return false;
+}
+
+module.exports = { sendWebhook, sign };
new file mode 100644
new file mode 100644
@@ -0,0 +1,676 @@
@@ -0,0 +1,676 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Account Switcher
+// Google-style multi-account management with anonymous mode.
+// Persists sessions in localStorage, coordinates with server
+// for token swapping. Integrates with OpenVibeNavbar dropdown.
+// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const STORAGE_KEY = 'openvibe_accounts';
+ const TOKEN_KEY = 'ov_token';
+ const ACTIVE_KEY = 'openvibe_active_account';
+ const ANON_KEY = 'openvibe_anon_token';
+ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
+ const MAX_ACCOUNTS = 10;
+
+ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
+ let _panelEl = null;
+
+ function isAnonId(id) {
+ return String(id) === 'anon' || String(id).startsWith('anon_');
+ }
+
+ /** Store of session tokens per anon identity */
+ function getAnonTokens() {
+ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }
+ }
+ function saveAnonTokens(tokens) {
+ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));
+ }
+ function setAnonToken(anonId, token) {
+ const tokens = getAnonTokens();
+ tokens[anonId] = token;
+ saveAnonTokens(tokens);
+ }
+ function getAnonToken(anonId) {
+ return getAnonTokens()[anonId] || null;
+ }
+ function removeAnonToken(anonId) {
+ const tokens = getAnonTokens();
+ delete tokens[anonId];
+ saveAnonTokens(tokens);
+ }
+
+ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on
+ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is
+ // shared across the tool satellites.
+ function onToolsDomain() {
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');
+ }
+
+ function clearAuthCookie() {
+ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
+ if (onToolsDomain()) {
+ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';
+ }
+ }
+
+ function setAuthCookie(token) {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;
+ } else {
+ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;
+ }
+ }
+
+ function normalizeAccount(user, token) {
+ const isAnon = !!user?.is_anon || isAnonId(user?.id);
+ const anonNum = user.anon_number || null;
+ // Each anon gets a unique ID based on its anon_number
+ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;
+ return {
+ id,
+ username: user.username,
+ display_name: user.display_name || user.username,
+ avatar_url: user.avatar_url || null,
+ email: user.email || null,
+ is_anon: isAnon,
+ anon_number: anonNum,
+ token,
+ added_at: Date.now(),
+ };
+ }
+
+ function getAnonAccount(id) {
+ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;
+ return getAccounts().find(a => a && a.is_anon) || null;
+ }
+
+ function getAnonAccounts() {
+ return getAccounts().filter(a => a && a.is_anon);
+ }
+
+ // ─── Account Store ─────────────────────────────────────────
+ function getAccounts() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
+ }
+
+ function saveAccounts(accounts) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));
+ }
+
+ function getActiveId() {
+ return localStorage.getItem(ACTIVE_KEY);
+ }
+
+ function setActiveId(id) {
+ localStorage.setItem(ACTIVE_KEY, id);
+ }
+
+ function addAccount(user, token) {
+ const accounts = getAccounts();
+ const entry = normalizeAccount(user, token);
+ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));
+ if (existing !== -1) {
+ accounts[existing] = entry;
+ } else {
+ if (accounts.length >= MAX_ACCOUNTS) {
+ accounts.shift(); // Remove oldest
+ }
+ accounts.push(entry);
+ }
+ saveAccounts(accounts);
+ return accounts;
+ }
+
+ function removeAccount(id) {
+ const targetId = String(id);
+ const accounts = getAccounts().filter(a => String(a.id) !== targetId);
+ saveAccounts(accounts);
+ // Clean up anon token if removing an anon identity
+ if (isAnonId(targetId)) removeAnonToken(targetId);
+ if (getActiveId() === targetId) {
+ const fallback = accounts.find(a => !a.is_anon) || accounts[0];
+ if (fallback) switchTo(fallback.id);
+ else logout();
+ }
+ return accounts;
+ }
+
+ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */
+ function getCookieToken() {
+ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
+ return m ? decodeURIComponent(m[1]) : null;
+ }
+
+ // ─── API ───────────────────────────────────────────────────
+ async function apiFetch(path, opts = {}) {
+ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
+ const headers = { 'Content-Type': 'application/json', ...opts.headers };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ }
+
+ async function switchTo(accountId) {
+ if (isAnonId(accountId)) return switchToAnon(accountId);
+
+ const accounts = getAccounts();
+ const target = accounts.find(a => String(a.id) === String(accountId));
+ if (target?.is_anon) return switchToAnon(accountId);
+ if (!target || !target.token) {
+ // Token expired — need re-login
+ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
+ return;
+ }
+
+ // Validate token is still good
+ try {
+ const headers = { Authorization: `Bearer ${target.token}` };
+ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });
+ if (!res.ok) throw new Error('Token invalid');
+ } catch {
+ // Token expired
+ removeAccount(target.id);
+ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
+ return;
+ }
+
+ localStorage.setItem(TOKEN_KEY, target.token);
+ setActiveId(target.id);
+ setAuthCookie(target.token);
+
+ if (_config.onSwitch) _config.onSwitch(target);
+ else window.location.reload();
+ }
+
+ /**
+ * Switch to an anonymous identity. If anonId is provided, switch to that
+ * specific anon. Otherwise use the most recently active or create a new one.
+ */
+ async function switchToAnon(anonId) {
+ async function fetchAnonUser(token) {
+ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });
+ if (!res.ok) throw new Error('Anon session invalid');
+ const data = await res.json();
+ return data.user || null;
+ }
+
+ let anonToken = null;
+ let anonUser = null;
+
+ // If switching to a specific anon identity, look up its token
+ if (anonId && anonId !== 'anon') {
+ anonToken = getAnonToken(anonId);
+ }
+
+ // Fallback to legacy single-anon token
+ if (!anonToken) {
+ anonToken = localStorage.getItem(ANON_KEY);
+ }
+
+ // Try to validate the existing token
+ if (anonToken) {
+ try {
+ anonUser = await fetchAnonUser(anonToken);
+ } catch {
+ // Token invalid — clear it
+ if (anonId) removeAnonToken(anonId);
+ localStorage.removeItem(ANON_KEY);
+ anonToken = null;
+ }
+ }
+
+ // Create new anon session if we don't have a valid one
+ if (!anonToken) {
+ try {
+ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ credentials: 'include',
+ }).then(async (res) => {
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ });
+ anonToken = data.token;
+ anonUser = data.user || null;
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);
+ return;
+ }
+ }
+
+ if (!anonUser) {
+ try {
+ anonUser = await fetchAnonUser(anonToken);
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);
+ return;
+ }
+ }
+
+ // Store the anon account and its token
+ if (anonUser) {
+ const entry = addAccount(anonUser, anonToken);
+ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
+ setAnonToken(realId, anonToken);
+ // Also keep legacy key for backward compat
+ localStorage.setItem(ANON_KEY, anonToken);
+
+ localStorage.removeItem(TOKEN_KEY);
+ setActiveId(realId);
+ clearAuthCookie();
+
+ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });
+ else window.location.reload();
+ }
+ }
+
+ /**
+ * Create a brand-new anonymous identity (even if one already exists).
+ * Useful for the "New Anon Identity" button.
+ */
+ async function createNewAnon() {
+ try {
+ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ force_new: true }),
+ credentials: 'include',
+ }).then(async (res) => {
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ });
+ const anonToken = data.token;
+ const anonUser = data.user;
+ if (!anonUser) throw new Error('No user returned');
+
+ const entry = addAccount(anonUser, anonToken);
+ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
+ setAnonToken(realId, anonToken);
+
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.setItem(ANON_KEY, anonToken);
+ setActiveId(realId);
+ clearAuthCookie();
+
+ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });
+ else window.location.reload();
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);
+ }
+ }
+
+ function logout() {
+ const activeId = getActiveId();
+ if (activeId) removeAccount(activeId);
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(ACTIVE_KEY);
+ clearAuthCookie();
+ if (_config.onSwitch) _config.onSwitch(null);
+ else window.location.reload();
+ }
+
+ function logoutAll() {
+ localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(ACTIVE_KEY);
+ localStorage.removeItem(ANON_KEY);
+ localStorage.removeItem(ANON_TOKENS_KEY);
+ clearAuthCookie();
+ if (_config.onSwitch) _config.onSwitch(null);
+ else window.location.reload();
+ }
+
+ // ─── Switcher Panel UI ─────────────────────────────────────
+ function injectStyles() {
+ if (document.getElementById('openvibe-switcher-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-switcher-styles';
+ s.textContent = `
+ .openvibe-switcher-overlay {
+ position: fixed; inset: 0; z-index: 20000;
+ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
+ display: flex; align-items: center; justify-content: center;
+ animation: openvibe-sw-fadein .2s ease;
+ }
+ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }
+
+ .openvibe-switcher-panel {
+ width: 380px; max-height: 520px;
+ background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 16px;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.6);
+ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);
+ }
+ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+
+ .openvibe-switcher-header {
+ text-align: center; padding: 20px 16px 12px;
+ border-bottom: 1px solid var(--border, #333340);
+ }
+ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }
+ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }
+
+ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }
+
+ .openvibe-switcher-item {
+ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
+ border-radius: 10px; cursor: pointer; transition: background .15s;
+ position: relative;
+ }
+ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
+ .openvibe-switcher-item.active::after {
+ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
+ color: var(--accent, #8b5cf6); font-weight: 700; font-size: 16px;
+ }
+ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
+ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
+ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
+ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }
+ .openvibe-switcher-item .remove {
+ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);
+ cursor: pointer; padding: 4px 6px; border-radius: 4px;
+ transition: opacity .15s, background .15s;
+ }
+ .openvibe-switcher-item:hover .remove { opacity: .6; }
+ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }
+
+ .openvibe-switcher-anon {
+ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
+ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;
+ border: 1px dashed var(--border, #333340);
+ transition: all .15s; color: var(--text-muted, #707080);
+ }
+ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }
+ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }
+
+ .openvibe-switcher-actions {
+ padding: 8px 12px; border-top: 1px solid var(--border, #333340);
+ display: flex; gap: 8px; justify-content: center;
+ }
+ .openvibe-switcher-actions button {
+ flex: 1; padding: 8px; border: none; border-radius: 8px;
+ font-size: 12px; font-weight: 600; cursor: pointer;
+ transition: all .15s;
+ }
+ .openvibe-switcher-actions .btn-add {
+ background: var(--accent, #8b5cf6); color: #fff;
+ }
+ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }
+ .openvibe-switcher-actions .btn-signout {
+ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);
+ }
+ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }
+ `;
+ document.head.appendChild(s);
+ }
+
+ function showPanel() {
+ if (_panelEl) return;
+ injectStyles();
+
+ const accounts = getAccounts();
+ const activeId = getActiveId();
+ const isAnonActive = isAnonId(activeId);
+ const loggedAccounts = accounts.filter(a => !a.is_anon);
+ const anonAccounts = accounts.filter(a => a.is_anon);
+
+ const overlay = document.createElement('div');
+ overlay.className = 'openvibe-switcher-overlay';
+ overlay.addEventListener('click', e => {
+ if (e.target === overlay) closePanel();
+ });
+
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-switcher-panel';
+
+ panel.innerHTML = `
+ <div class="openvibe-switcher-header">
+ <h3>\uD83D\uDD25 Switch Account</h3>
+ <p>Choose an account or browse anonymously</p>
+ </div>
+ <div class="openvibe-switcher-list">
+ ${loggedAccounts.map(a => `
+ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
+ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
+ <div class="info">
+ <div class="name">${a.display_name || a.username}</div>
+ <div class="sub">${a.email || `@${a.username}`}</div>
+ </div>
+ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
+ </div>
+ `).join('')}
+
+ ${anonAccounts.length > 0 ? `
+ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>
+ ${anonAccounts.map(a => `
+ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
+ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>
+ <div class="info">
+ <div class="name">Anonymous #${a.anon_number || '?'}</div>
+ <div class="sub">anon_${a.anon_number || '?'}</div>
+ </div>
+ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}
+ </div>
+ `).join('')}
+ ` : ''}
+ </div>
+ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">
+ <span class="anon-icon">+</span>
+ <div class="info">
+ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>
+ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>
+ </div>
+ </div>
+ <div class="openvibe-switcher-actions">
+ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>
+ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>
+ </div>
+ `;
+
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+ _panelEl = overlay;
+
+ // Wire events
+ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ if (e.target.closest('.remove')) return;
+ switchTo(el.dataset.id);
+ closePanel();
+ });
+ });
+
+ panel.querySelectorAll('[data-remove-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ e.stopPropagation();
+ removeAccount(el.dataset.removeId);
+ closePanel();
+ showPanel(); // Re-render
+ });
+ });
+
+ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {
+ if (anonAccounts.length > 0) {
+ createNewAnon();
+ } else {
+ switchToAnon();
+ }
+ closePanel();
+ });
+
+ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {
+ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ });
+
+ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {
+ logoutAll();
+ closePanel();
+ });
+
+ // Esc to close
+ const escHandler = e => { if (e.key === 'Escape') closePanel(); };
+ document.addEventListener('keydown', escHandler);
+ overlay._escHandler = escHandler;
+ }
+
+ function closePanel() {
+ if (!_panelEl) return;
+ document.removeEventListener('keydown', _panelEl._escHandler);
+ _panelEl.remove();
+ _panelEl = null;
+ }
+
+ // ─── Token Refresh ───────────────────────────────────────────
+ // Decode JWT payload without verification (client-side can't verify RS256).
+ // Used only to check `exp` claim for proactive refresh.
+ function decodeJwtPayload(token) {
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(payload));
+ } catch { return null; }
+ }
+
+ let _refreshTimer = null;
+ let _refreshInProgress = false;
+
+ /**
+ * Silently refresh the JWT if it expires within the next 2 hours,
+ * or if it has already expired (7-day grace period on the server).
+ * Updates localStorage, cookie, and account list.
+ */
+ async function silentRefresh() {
+ if (_refreshInProgress) return;
+ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
+ if (!token) return;
+ const payload = decodeJwtPayload(token);
+ if (!payload || !payload.exp) return;
+
+ const expiresAt = payload.exp * 1000;
+ const now = Date.now();
+ const twoHours = 2 * 60 * 60 * 1000;
+
+ // Only refresh if token expires within 2 hours or is already expired
+ if (expiresAt - now > twoHours) return;
+
+ _refreshInProgress = true;
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ });
+ if (!res.ok) {
+ // If refresh fails with 401, token is truly dead — don't retry
+ if (res.status === 401) {
+ console.warn('[OpenVibeAuth] Token refresh failed — session expired');
+ }
+ return;
+ }
+ const data = await res.json();
+ if (data.token) {
+ // Update localStorage
+ localStorage.setItem(TOKEN_KEY, data.token);
+ // Update cookie
+ setAuthCookie(data.token);
+ // Update the stored account entry
+ const activeId = getActiveId();
+ if (activeId && !isAnonId(activeId)) {
+ const accounts = getAccounts();
+ const idx = accounts.findIndex(a => String(a.id) === String(activeId));
+ if (idx !== -1) {
+ accounts[idx].token = data.token;
+ saveAccounts(accounts);
+ }
+ }
+ console.log('[OpenVibeAuth] Token refreshed silently');
+ }
+ } catch (err) {
+ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);
+ } finally {
+ _refreshInProgress = false;
+ }
+ }
+
+ /**
+ * Start a periodic timer that checks token expiry and refreshes when needed.
+ * Checks every 15 minutes. Also runs an immediate check.
+ */
+ function startRefreshTimer() {
+ if (_refreshTimer) return;
+ silentRefresh(); // Immediate check
+ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min
+ }
+
+ function stopRefreshTimer() {
+ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
+ }
+
+ // ─── Listen for navbar events ──────────────────────────────
+ document.addEventListener('openvibe-switch-account', e => {
+ switchTo(e.detail.accountId);
+ });
+
+ // ─── Public API ────────────────────────────────────────────
+ const OpenVibeAccountSwitcher = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ // Start silent token refresh for logged-in users
+ const activeId = getActiveId();
+ if (activeId && !isAnonId(activeId)) {
+ startRefreshTimer();
+ }
+ },
+
+ /** Call after login success to store this account. */
+ addAccount,
+ removeAccount,
+ getAccounts,
+ getActiveId,
+ switchTo,
+ switchToAnon,
+ createNewAnon,
+ getAnonAccounts,
+ logout,
+ logoutAll,
+ silentRefresh,
+ startRefreshTimer,
+ stopRefreshTimer,
+
+ /** Show the full-screen account switcher panel. */
+ showPanel,
+ closePanel,
+
+ /** Quick check: is user on an anonymous session? */
+ isAnonymous() {
+ const id = getActiveId();
+ return isAnonId(id);
+ },
+
+ /** Get the current active account object. */
+ getActive() {
+ const id = getActiveId();
+ if (!id) return null;
+ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;
+ return getAccounts().find(a => String(a.id) === String(id)) || null;
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;
+ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1066 @@
@@ -0,0 +1,1066 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Unified Analytics Module
+// Shared analytics tracking for all OpenVibe services.
+// Each service stores events in its own SQLite DB using the
+// same schema. The admin panel aggregates via internal API.
+// ═══════════════════════════════════════════════════════════════
+
+// ── Bot Detection ────────────────────────────────────────────
+
+const BOT_USER_AGENTS = [
+ /googlebot/i, /bingbot/i, /slurp/i, /duckduckbot/i, /baiduspider/i,
+ /yandexbot/i, /facebot/i, /ia_archiver/i, /semrushbot/i, /ahrefsbot/i,
+ /mj12bot/i, /dotbot/i, /petalbot/i, /rogerbot/i, /screaming frog/i,
+ /seznambot/i, /sogou/i, /exabot/i, /archive\.org_bot/i, /crawler/i,
+ /spider/i, /python-requests/i, /python-urllib/i, /httpx/i,
+ /go-http-client/i, /java\//i, /libwww-perl/i, /wget/i, /curl/i,
+ /headlesschrome/i, /phantomjs/i, /scrapy/i, /node-fetch/i,
+ /axios/i, /postman/i, /insomnia/i, /lighthouse/i, /pagespeed/i,
+ /gptbot/i, /chatgpt-user/i, /claudebot/i, /anthropic/i,
+ /bytespider/i, /amazonbot/i, /applebot/i, /twitterbot/i,
+ /facebookexternalhit/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i,
+ /discordbot/i, /slackbot/i, /uptimerobot/i, /pingdom/i,
+ /statuscake/i, /monitoring/i, /health.?check/i,
+];
+
+const SUSPICIOUS_PATTERNS = {
+ // Hits per minute thresholds
+ highRequestRate: 60,
+ // Pages per session in under a minute
+ rapidPageViews: 20,
+ // No JS execution (missing beacon)
+ noJsExecution: true,
+ // Accessing well-known bot traps
+ honeypotPaths: ['/wp-login.php', '/xmlrpc.php', '/wp-admin', '/.env', '/admin.php', '/phpmyadmin'],
+};
+
+/**
+ * Classify whether a request is from a bot.
+ * Returns: { isBot: boolean, botType: string|null, confidence: number }
+ */
+function classifyRequest(req) {
+ const ua = req.headers['user-agent'] || '';
+ const ip = req.ip || req.connection?.remoteAddress || '';
+
+ // 1. Known bot user agents
+ for (const pattern of BOT_USER_AGENTS) {
+ if (pattern.test(ua)) {
+ return { isBot: true, botType: 'known_crawler', confidence: 0.95 };
+ }
+ }
+
+ // 2. Missing or empty user agent
+ if (!ua || ua.length < 10) {
+ return { isBot: true, botType: 'no_useragent', confidence: 0.85 };
+ }
+
+ // 3. Honeypot paths
+ const path = req.path || req.url || '';
+ if (SUSPICIOUS_PATTERNS.honeypotPaths.some(p => path.toLowerCase().startsWith(p))) {
+ return { isBot: true, botType: 'honeypot_hit', confidence: 0.90 };
+ }
+
+ // 4. Looks human (default)
+ return { isBot: false, botType: null, confidence: 0.1 };
+}
+
+
+// ── Schema ───────────────────────────────────────────────────
+
+const ANALYTICS_SCHEMA = `
+ -- Page views / API hits
+ CREATE TABLE IF NOT EXISTS analytics_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ service TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT 'pageview',
+ path TEXT,
+ method TEXT DEFAULT 'GET',
+ status_code INTEGER,
+ response_time_ms INTEGER,
+ user_id INTEGER,
+ session_id TEXT,
+ ip TEXT,
+ country TEXT,
+ city TEXT,
+ user_agent TEXT,
+ referer TEXT,
+ is_bot INTEGER DEFAULT 0,
+ bot_type TEXT,
+ device_type TEXT,
+ browser TEXT,
+ os TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ -- Aggregated hourly stats (materialized by cron/timer)
+ CREATE TABLE IF NOT EXISTS analytics_hourly (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ service TEXT NOT NULL,
+ hour TEXT NOT NULL,
+ pageviews INTEGER DEFAULT 0,
+ api_calls INTEGER DEFAULT 0,
+ unique_visitors INTEGER DEFAULT 0,
+ unique_users INTEGER DEFAULT 0,
+ bot_hits INTEGER DEFAULT 0,
+ avg_response_ms INTEGER DEFAULT 0,
+ error_count INTEGER DEFAULT 0,
+ bandwidth_bytes INTEGER DEFAULT 0,
+ top_paths TEXT,
+ top_referers TEXT,
+ UNIQUE(service, hour)
+ );
+
+ -- Daily aggregates
+ CREATE TABLE IF NOT EXISTS analytics_daily (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ service TEXT NOT NULL,
+ date TEXT NOT NULL,
+ pageviews INTEGER DEFAULT 0,
+ api_calls INTEGER DEFAULT 0,
+ unique_visitors INTEGER DEFAULT 0,
+ unique_users INTEGER DEFAULT 0,
+ new_users INTEGER DEFAULT 0,
+ bot_hits INTEGER DEFAULT 0,
+ avg_response_ms INTEGER DEFAULT 0,
+ error_count INTEGER DEFAULT 0,
+ top_paths TEXT,
+ top_referers TEXT,
+ top_countries TEXT,
+ device_breakdown TEXT,
+ browser_breakdown TEXT,
+ UNIQUE(service, date)
+ );
+
+ -- Rate tracking for bot detection
+ CREATE TABLE IF NOT EXISTS analytics_rate_tracking (
+ ip TEXT NOT NULL,
+ window_start INTEGER NOT NULL,
+ hit_count INTEGER DEFAULT 1,
+ PRIMARY KEY (ip, window_start)
+ );
+
+ -- Indexes
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_ip ON analytics_events(ip, created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_events_user ON analytics_events(user_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);
+ CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);
+`;
+
+
+// ── Device/Browser Parsing (lightweight) ─────────────────────
+
+function parseUserAgent(ua) {
+ if (!ua) return { device: 'unknown', browser: 'unknown', os: 'unknown' };
+
+ // Device
+ let device = 'desktop';
+ if (/mobile|android|iphone|ipod/i.test(ua)) device = 'mobile';
+ else if (/ipad|tablet/i.test(ua)) device = 'tablet';
+
+ // Browser
+ let browser = 'other';
+ if (/edg\//i.test(ua)) browser = 'edge';
+ else if (/opr\/|opera/i.test(ua)) browser = 'opera';
+ else if (/firefox\//i.test(ua)) browser = 'firefox';
+ else if (/chrome\//i.test(ua) && !/chromium/i.test(ua)) browser = 'chrome';
+ else if (/safari\//i.test(ua) && !/chrome/i.test(ua)) browser = 'safari';
+ else if (/trident|msie/i.test(ua)) browser = 'ie';
+
+ // OS
+ let os = 'other';
+ if (/windows/i.test(ua)) os = 'windows';
+ else if (/macintosh|mac os/i.test(ua)) os = 'macos';
+ else if (/linux/i.test(ua) && !/android/i.test(ua)) os = 'linux';
+ else if (/android/i.test(ua)) os = 'android';
+ else if (/iphone|ipad|ipod/i.test(ua)) os = 'ios';
+
+ return { device, browser, os };
+}
+
+
+// ── Analytics Tracker Class ──────────────────────────────────
+
+class AnalyticsTracker {
+ /**
+ * @param {object} db - better-sqlite3 database instance
+ * @param {string} service - service name (e.g., 'live', 'openvibe-network', 'tools')
+ */
+ constructor(db, service) {
+ this.db = db;
+ this.service = service;
+ this._buffer = [];
+ this._flushInterval = null;
+ this._aggregateInterval = null;
+
+ // Initialize schema
+ this.db.exec(ANALYTICS_SCHEMA);
+
+ // Prepared statements
+ this._insertEvent = this.db.prepare(`
+ INSERT INTO analytics_events
+ (service, event_type, path, method, status_code, response_time_ms,
+ user_id, session_id, ip, country, city, user_agent, referer,
+ is_bot, bot_type, device_type, browser, os)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ this._insertMany = this.db.transaction((events) => {
+ for (const e of events) {
+ this._insertEvent.run(
+ e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,
+ e.user_id, e.session_id, e.ip, e.country, e.city, e.user_agent, e.referer,
+ e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os
+ );
+ }
+ });
+
+ this._updateRate = this.db.prepare(`
+ INSERT INTO analytics_rate_tracking (ip, window_start, hit_count)
+ VALUES (?, ?, 1)
+ ON CONFLICT(ip, window_start) DO UPDATE SET hit_count = hit_count + 1
+ `);
+
+ this._getRate = this.db.prepare(
+ 'SELECT SUM(hit_count) as total FROM analytics_rate_tracking WHERE ip = ? AND window_start >= ?'
+ );
+
+ this._cleanRates = this.db.prepare(
+ 'DELETE FROM analytics_rate_tracking WHERE window_start < ?'
+ );
+
+ // Start flush timer (write buffered events every 5s)
+ this._flushInterval = setInterval(() => this.flush(), 5000);
+
+ // Hourly aggregation timer
+ this._aggregateInterval = setInterval(() => this.aggregate(), 60 * 60 * 1000);
+
+ // Initial aggregation on startup (catch up)
+ setTimeout(() => this.aggregate(), 10_000);
+
+ // Clean old rate tracking every 10 min
+ setInterval(() => {
+ const cutoff = Math.floor(Date.now() / 1000) - 300;
+ try { this._cleanRates.run(cutoff); } catch {}
+ }, 10 * 60 * 1000);
+ }
+
+ /**
+ * Express middleware for automatic request tracking.
+ * Tracks page views and API calls with response times.
+ */
+ middleware() {
+ return (req, res, next) => {
+ // Skip health checks and static assets
+ const path = req.path || '';
+ if (path === '/api/health' || path === '/health' ||
+ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path)) {
+ return next();
+ }
+
+ const startTime = Date.now();
+
+ // Hook into response finish
+ const originalEnd = res.end;
+ res.end = (...args) => {
+ res.end = originalEnd;
+ res.end(...args);
+
+ const responseTime = Date.now() - startTime;
+ const botCheck = classifyRequest(req);
+ const parsed = parseUserAgent(req.headers['user-agent'] || '');
+
+ const isApi = path.startsWith('/api/') || path.startsWith('/internal/') || path.startsWith('/oauth/');
+ const eventType = isApi ? 'api_call' : 'pageview';
+
+ // Rate-based bot detection
+ const ip = req.ip || '';
+ const windowStart = Math.floor(Date.now() / 60000); // 1-minute windows
+ try {
+ this._updateRate.run(ip, windowStart);
+ if (!botCheck.isBot) {
+ const rateData = this._getRate.get(ip, windowStart - 1);
+ if (rateData && rateData.total > SUSPICIOUS_PATTERNS.highRequestRate) {
+ botCheck.isBot = true;
+ botCheck.botType = 'rate_limit';
+ botCheck.confidence = 0.80;
+ }
+ }
+ } catch {}
+
+ // Get session ID from cookie or generate from IP+UA
+ const sessionId = req.cookies?.openvibe_session ||
+ req.cookies?.ov_token?.slice(-16) ||
+ null;
+
+ this._buffer.push({
+ service: this.service,
+ event_type: eventType,
+ path: path.substring(0, 500),
+ method: req.method,
+ status_code: res.statusCode,
+ response_time_ms: responseTime,
+ user_id: req.user?.id || req.user?.sub || null,
+ session_id: sessionId,
+ ip: ip,
+ country: req.headers['cf-ipcountry'] || null,
+ city: null,
+ user_agent: (req.headers['user-agent'] || '').substring(0, 500),
+ referer: (req.headers['referer'] || '').substring(0, 500),
+ is_bot: botCheck.isBot,
+ bot_type: botCheck.botType,
+ device_type: parsed.device,
+ browser: parsed.browser,
+ os: parsed.os,
+ });
+
+ // Auto-flush if buffer gets large
+ if (this._buffer.length >= 100) {
+ this.flush();
+ }
+ };
+
+ next();
+ };
+ }
+
+ /**
+ * Track a custom event (not from HTTP request).
+ */
+ trackEvent(eventType, data = {}) {
+ this._buffer.push({
+ service: this.service,
+ event_type: eventType,
+ path: data.path || null,
+ method: data.method || null,
+ status_code: data.status_code || null,
+ response_time_ms: data.response_time_ms || null,
+ user_id: data.user_id || null,
+ session_id: data.session_id || null,
+ ip: data.ip || null,
+ country: data.country || null,
+ city: data.city || null,
+ user_agent: data.user_agent || null,
+ referer: data.referer || null,
+ is_bot: data.is_bot || false,
+ bot_type: data.bot_type || null,
+ device_type: data.device_type || null,
+ browser: data.browser || null,
+ os: data.os || null,
+ });
+ }
+
+ /**
+ * Flush buffered events to database.
+ */
+ flush() {
+ if (this._buffer.length === 0) return;
+ const events = this._buffer.splice(0);
+ try {
+ this._insertMany(events);
+ } catch (err) {
+ console.error(`[Analytics:${this.service}] Flush error:`, err.message);
+ }
+ }
+
+ /**
+ * Run hourly + daily aggregation.
+ */
+ aggregate() {
+ try {
+ this._aggregateHourly();
+ this._aggregateDaily();
+ // Prune raw events older than 90 days
+ this.db.prepare("DELETE FROM analytics_events WHERE created_at < datetime('now', '-90 days')").run();
+ } catch (err) {
+ console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);
+ }
+ }
+
+ _aggregateHourly() {
+ // Aggregate last 2 hours (to catch stragglers)
+ const hours = [];
+ const now = new Date();
+ for (let i = 0; i < 2; i++) {
+ const d = new Date(now.getTime() - i * 3600000);
+ hours.push(d.toISOString().slice(0, 13) + ':00:00');
+ }
+
+ const upsert = this.db.prepare(`
+ INSERT OR REPLACE INTO analytics_hourly
+ (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ for (const hour of hours) {
+ const nextHour = new Date(new Date(hour).getTime() + 3600000).toISOString().slice(0, 19).replace('T', ' ');
+ const hourStart = hour.replace('T', ' ');
+
+ const stats = this.db.prepare(`
+ SELECT
+ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
+ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
+ COUNT(DISTINCT ip) AS unique_visitors,
+ COUNT(DISTINCT user_id) AS unique_users,
+ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at < ?
+ `).get(this.service, hourStart, nextHour);
+
+ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
+
+ const topPaths = this.db.prepare(`
+ SELECT path, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0
+ GROUP BY path ORDER BY cnt DESC LIMIT 10
+ `).all(this.service, hourStart, nextHour);
+
+ const topReferers = this.db.prepare(`
+ SELECT referer, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
+ GROUP BY referer ORDER BY cnt DESC LIMIT 10
+ `).all(this.service, hourStart, nextHour);
+
+ upsert.run(
+ this.service, hourStart,
+ stats.pageviews, stats.api_calls, stats.unique_visitors, stats.unique_users,
+ stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,
+ JSON.stringify(topPaths), JSON.stringify(topReferers)
+ );
+ }
+ }
+
+ _aggregateDaily() {
+ // Aggregate last 2 days
+ const days = [];
+ const now = new Date();
+ for (let i = 0; i < 2; i++) {
+ const d = new Date(now.getTime() - i * 86400000);
+ days.push(d.toISOString().slice(0, 10));
+ }
+
+ const upsert = this.db.prepare(`
+ INSERT OR REPLACE INTO analytics_daily
+ (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,
+ avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ for (const date of days) {
+ const dayStart = date + ' 00:00:00';
+ const dayEnd = date + ' 23:59:59';
+
+ const stats = this.db.prepare(`
+ SELECT
+ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
+ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
+ COUNT(DISTINCT ip) AS unique_visitors,
+ COUNT(DISTINCT user_id) AS unique_users,
+ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ?
+ `).get(this.service, dayStart, dayEnd);
+
+ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
+
+ const topPaths = this.db.prepare(`
+ SELECT path, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
+ GROUP BY path ORDER BY cnt DESC LIMIT 15
+ `).all(this.service, dayStart, dayEnd);
+
+ const topReferers = this.db.prepare(`
+ SELECT referer, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
+ GROUP BY referer ORDER BY cnt DESC LIMIT 10
+ `).all(this.service, dayStart, dayEnd);
+
+ const topCountries = this.db.prepare(`
+ SELECT country, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0
+ GROUP BY country ORDER BY cnt DESC LIMIT 10
+ `).all(this.service, dayStart, dayEnd);
+
+ const devices = this.db.prepare(`
+ SELECT device_type, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
+ GROUP BY device_type ORDER BY cnt DESC
+ `).all(this.service, dayStart, dayEnd);
+
+ const browsers = this.db.prepare(`
+ SELECT browser, COUNT(*) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
+ GROUP BY browser ORDER BY cnt DESC
+ `).all(this.service, dayStart, dayEnd);
+
+ // New users (first seen this day)
+ const newUsers = this.db.prepare(`
+ SELECT COUNT(DISTINCT user_id) as cnt FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND created_at <= ? AND user_id IS NOT NULL
+ AND user_id NOT IN (
+ SELECT DISTINCT user_id FROM analytics_events
+ WHERE service = ? AND created_at < ? AND user_id IS NOT NULL
+ )
+ `).get(this.service, dayStart, dayEnd, this.service, dayStart);
+
+ upsert.run(
+ this.service, date, stats.pageviews, stats.api_calls,
+ stats.unique_visitors, stats.unique_users,
+ newUsers?.cnt || 0, stats.bot_hits,
+ stats.avg_response_ms || 0, stats.error_count,
+ JSON.stringify(topPaths), JSON.stringify(topReferers),
+ JSON.stringify(topCountries), JSON.stringify(devices),
+ JSON.stringify(browsers)
+ );
+ }
+ }
+
+ /**
+ * Get analytics data for the admin dashboard.
+ * @param {object} options
+ * @param {number} options.days - Number of days to look back (default 30)
+ * @param {number} options.hours - If set, overrides days with an hour-based range (e.g. 1, 12, 24, 72)
+ * @param {string} options.service - Service name override
+ */
+ getStats(options = {}) {
+ const { days = 30, hours, service } = options;
+ const svc = service || this.service;
+
+ // Compute cutoff timestamp for raw event queries
+ const rangeMs = hours ? hours * 3600000 : days * 86400000;
+ const rangeCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 19).replace('T', ' ');
+ const isSubDay = hours && hours < 24;
+ const effectiveDays = hours ? hours / 24 : days;
+
+ // Daily trend (use daily aggregates for multi-day, raw events for sub-day)
+ let daily = [];
+ if (!isSubDay) {
+ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
+ daily = this.db.prepare(`
+ SELECT * FROM analytics_daily
+ WHERE service = ? AND date >= ?
+ ORDER BY date ASC
+ `).all(svc, dateCutoff);
+ }
+
+ // Hourly trend (always useful — scope to the requested range)
+ const hourlyLimit = hours ? Math.min(hours, 168) : 24;
+ const hourlyCutoff = new Date(Date.now() - hourlyLimit * 3600000).toISOString().slice(0, 19).replace('T', ' ');
+ const hourly = this.db.prepare(`
+ SELECT * FROM analytics_hourly
+ WHERE service = ? AND hour >= ?
+ ORDER BY hour ASC
+ `).all(svc, hourlyCutoff);
+
+ // For sub-day ranges, generate fine-grained time buckets from raw events
+ let timeBuckets = [];
+ if (isSubDay) {
+ // 5-minute buckets for ≤1h, 15-min for ≤12h, 30-min for ≤24h
+ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
+ timeBuckets = this.db.prepare(`
+ SELECT
+ strftime('%Y-%m-%d %H:', created_at) ||
+ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
+ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,
+ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,
+ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS unique_visitors,
+ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ GROUP BY bucket
+ ORDER BY bucket ASC
+ `).all(svc, rangeCutoff);
+ }
+
+ // Summary totals — for sub-day ranges, compute from raw events
+ let summary;
+ if (hours && hours < 48) {
+ summary = this.db.prepare(`
+ SELECT
+ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,
+ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,
+ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS total_unique_visitors,
+ COUNT(DISTINCT user_id) FILTER (WHERE is_bot = 0 AND user_id IS NOT NULL) AS total_unique_users,
+ COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ `).get(svc, rangeCutoff);
+ } else {
+ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
+ summary = this.db.prepare(`
+ SELECT
+ SUM(pageviews) as total_pageviews,
+ SUM(api_calls) as total_api_calls,
+ SUM(unique_visitors) as total_unique_visitors,
+ SUM(unique_users) as total_unique_users,
+ SUM(bot_hits) as total_bot_hits,
+ SUM(error_count) as total_errors,
+ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
+ FROM analytics_daily
+ WHERE service = ? AND date >= ?
+ `).get(svc, dateCutoff);
+ }
+
+ // Real-time (last 5 min)
+ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
+ const realtime = this.db.prepare(`
+ SELECT
+ COUNT(*) as requests,
+ COUNT(DISTINCT ip) as visitors,
+ COUNT(*) FILTER (WHERE is_bot = 1) as bots
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ `).get(svc, fiveMinAgo);
+
+ // Top pages (human only)
+ const topPages = this.db.prepare(`
+ SELECT path, COUNT(*) as hits, COUNT(DISTINCT ip) as visitors
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY path ORDER BY hits DESC LIMIT 20
+ `).all(svc, rangeCutoff);
+
+ // Top referers
+ const topReferers = this.db.prepare(`
+ SELECT referer, COUNT(*) as hits
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ AND referer IS NOT NULL AND referer != '' AND is_bot = 0
+ GROUP BY referer ORDER BY hits DESC LIMIT 15
+ `).all(svc, rangeCutoff);
+
+ // Bot breakdown
+ const botBreakdown = this.db.prepare(`
+ SELECT bot_type, COUNT(*) as hits
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 1
+ GROUP BY bot_type ORDER BY hits DESC LIMIT 15
+ `).all(svc, rangeCutoff);
+
+ // Status code distribution
+ const statusCodes = this.db.prepare(`
+ SELECT
+ CASE
+ WHEN status_code >= 200 AND status_code < 300 THEN '2xx'
+ WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
+ WHEN status_code >= 400 AND status_code < 500 THEN '4xx'
+ WHEN status_code >= 500 THEN '5xx'
+ ELSE 'other'
+ END as group_code,
+ COUNT(*) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ GROUP BY group_code ORDER BY cnt DESC
+ `).all(svc, rangeCutoff);
+
+ // Device/browser/OS breakdown
+ const deviceBreakdown = this.db.prepare(`
+ SELECT device_type, COUNT(*) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY device_type ORDER BY cnt DESC
+ `).all(svc, rangeCutoff);
+
+ const browserBreakdown = this.db.prepare(`
+ SELECT browser, COUNT(*) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY browser ORDER BY cnt DESC
+ `).all(svc, rangeCutoff);
+
+ const osBreakdown = this.db.prepare(`
+ SELECT os, COUNT(*) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY os ORDER BY cnt DESC
+ `).all(svc, rangeCutoff);
+
+ // Country breakdown
+ const countryBreakdown = this.db.prepare(`
+ SELECT country, COUNT(*) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ?
+ AND country IS NOT NULL AND is_bot = 0
+ GROUP BY country ORDER BY cnt DESC LIMIT 20
+ `).all(svc, rangeCutoff);
+
+ // ── Enhanced Metrics ─────────────────────────────────
+
+ // Response time percentiles (p50, p90, p95, p99)
+ let responsePercentiles = {};
+ try {
+ const rtRows = this.db.prepare(`
+ SELECT response_time_ms FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
+ ORDER BY response_time_ms ASC
+ `).all(svc, rangeCutoff);
+ if (rtRows.length > 0) {
+ const vals = rtRows.map(r => r.response_time_ms);
+ const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];
+ responsePercentiles = { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };
+ }
+ } catch {}
+
+ // Bandwidth estimation (response_time_ms * rough factor isn't great,
+ // but we can count requests and estimate from content-length if tracked;
+ // for now, count total requests * avg page weight heuristic)
+ let bandwidth = {};
+ try {
+ const bwData = this.db.prepare(`
+ SELECT
+ COUNT(*) as total_requests,
+ COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
+ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,
+ SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ `).get(svc, rangeCutoff);
+ if (bwData) {
+ bandwidth = {
+ total_requests: bwData.total_requests || 0,
+ page_requests: bwData.page_requests || 0,
+ api_requests: bwData.api_requests || 0,
+ estimated_bytes: bwData.estimated_bytes || 0,
+ };
+ }
+ } catch {}
+
+ // Authenticated vs anonymous sessions
+ let authBreakdown = {};
+ try {
+ authBreakdown = this.db.prepare(`
+ SELECT
+ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS authenticated,
+ COUNT(*) FILTER (WHERE user_id IS NULL) AS anonymous,
+ COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) AS unique_authenticated_users,
+ COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ `).get(svc, rangeCutoff) || {};
+ } catch {}
+
+ // Error rate over time (hourly for multi-day, bucketed for sub-day)
+ let errorTrend = [];
+ try {
+ if (isSubDay) {
+ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
+ errorTrend = this.db.prepare(`
+ SELECT
+ strftime('%Y-%m-%d %H:', created_at) ||
+ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
+ COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY bucket ORDER BY bucket ASC
+ `).all(svc, rangeCutoff);
+ } else {
+ errorTrend = this.db.prepare(`
+ SELECT hour as bucket,
+ (pageviews + api_calls) as total,
+ error_count as errors
+ FROM analytics_hourly
+ WHERE service = ? AND hour >= ?
+ ORDER BY hour ASC
+ `).all(svc, hourlyCutoff);
+ }
+ } catch {}
+
+ // Peak hours (which hours of day get most traffic)
+ let peakHours = [];
+ try {
+ peakHours = this.db.prepare(`
+ SELECT
+ CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
+ COUNT(*) AS hits,
+ COUNT(DISTINCT ip) AS visitors,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY hour_of_day
+ ORDER BY hour_of_day ASC
+ `).all(svc, rangeCutoff);
+ } catch {}
+
+ // Top API endpoints (separate from top pages)
+ let topApiEndpoints = [];
+ try {
+ topApiEndpoints = this.db.prepare(`
+ SELECT path, method, COUNT(*) as hits,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
+ COUNT(*) FILTER (WHERE status_code >= 400) AS errors
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0
+ GROUP BY path, method ORDER BY hits DESC LIMIT 20
+ `).all(svc, rangeCutoff);
+ } catch {}
+
+ // Unique sessions count
+ let sessionCount = 0;
+ try {
+ const sc = this.db.prepare(`
+ SELECT COUNT(DISTINCT session_id) as cnt
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0
+ `).get(svc, rangeCutoff);
+ sessionCount = sc?.cnt || 0;
+ } catch {}
+
+ // New vs returning visitors (first-time IPs in this period vs seen before)
+ let visitorTypes = {};
+ try {
+ visitorTypes = this.db.prepare(`
+ SELECT
+ COUNT(DISTINCT ip) FILTER (
+ WHERE ip NOT IN (
+ SELECT DISTINCT ip FROM analytics_events
+ WHERE service = ? AND created_at < ? AND is_bot = 0
+ )
+ ) AS new_visitors,
+ COUNT(DISTINCT ip) FILTER (
+ WHERE ip IN (
+ SELECT DISTINCT ip FROM analytics_events
+ WHERE service = ? AND created_at < ? AND is_bot = 0
+ )
+ ) AS returning_visitors
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ `).get(svc, rangeCutoff, svc, rangeCutoff, svc, rangeCutoff) || {};
+ } catch {}
+
+ // Slowest endpoints
+ let slowestEndpoints = [];
+ try {
+ slowestEndpoints = this.db.prepare(`
+ SELECT path, method,
+ COUNT(*) as hits,
+ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
+ MAX(response_time_ms) AS max_ms
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
+ GROUP BY path, method
+ HAVING hits >= 3
+ ORDER BY avg_ms DESC LIMIT 10
+ `).all(svc, rangeCutoff);
+ } catch {}
+
+ // Authenticated vs anonymous time trend
+ let authTrend = [];
+ try {
+ if (isSubDay) {
+ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
+ authTrend = this.db.prepare(`
+ SELECT
+ strftime('%Y-%m-%d %H:', created_at) ||
+ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
+ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
+ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
+ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
+ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY bucket ORDER BY bucket ASC
+ `).all(svc, rangeCutoff);
+ } else {
+ authTrend = this.db.prepare(`
+ SELECT
+ strftime('%Y-%m-%d', created_at) AS bucket,
+ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
+ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
+ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
+ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
+ FROM analytics_events
+ WHERE service = ? AND created_at >= ? AND is_bot = 0
+ GROUP BY bucket ORDER BY bucket ASC
+ `).all(svc, rangeCutoff);
+ }
+ } catch {}
+
+ return {
+ service: svc,
+ period_days: effectiveDays,
+ period_hours: hours || null,
+ summary: summary || {},
+ realtime: realtime || {},
+ daily,
+ hourly,
+ timeBuckets,
+ topPages,
+ topReferers,
+ botBreakdown,
+ statusCodes,
+ deviceBreakdown,
+ browserBreakdown,
+ osBreakdown,
+ countryBreakdown,
+ // Enhanced metrics
+ responsePercentiles,
+ bandwidth,
+ authBreakdown,
+ errorTrend,
+ peakHours,
+ topApiEndpoints,
+ sessionCount,
+ visitorTypes,
+ slowestEndpoints,
+ authTrend,
+ };
+ }
+
+ /**
+ * Get a cross-service overview (for the admin panel).
+ */
+ getOverview(days = 30) {
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
+
+ const services = this.db.prepare(`
+ SELECT
+ service,
+ SUM(pageviews) as pageviews,
+ SUM(api_calls) as api_calls,
+ SUM(unique_visitors) as unique_visitors,
+ SUM(unique_users) as unique_users,
+ SUM(bot_hits) as bot_hits,
+ SUM(error_count) as errors,
+ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
+ FROM analytics_daily
+ WHERE date >= ?
+ GROUP BY service
+ ORDER BY pageviews DESC
+ `).all(cutoff);
+
+ // Totals across all services
+ const totals = this.db.prepare(`
+ SELECT
+ SUM(pageviews) as pageviews,
+ SUM(api_calls) as api_calls,
+ SUM(unique_visitors) as unique_visitors,
+ SUM(bot_hits) as bot_hits,
+ SUM(error_count) as errors
+ FROM analytics_daily
+ WHERE date >= ?
+ `).get(cutoff);
+
+ // Daily trend (all services combined)
+ const dailyTrend = this.db.prepare(`
+ SELECT date,
+ SUM(pageviews) as pageviews,
+ SUM(api_calls) as api_calls,
+ SUM(unique_visitors) as unique_visitors,
+ SUM(bot_hits) as bot_hits
+ FROM analytics_daily
+ WHERE date >= ?
+ GROUP BY date
+ ORDER BY date ASC
+ `).all(cutoff);
+
+ // Real-time all services
+ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
+ const realtime = this.db.prepare(`
+ SELECT
+ service,
+ COUNT(*) as requests,
+ COUNT(DISTINCT ip) as visitors,
+ COUNT(*) FILTER (WHERE is_bot = 1) as bots
+ FROM analytics_events
+ WHERE created_at >= ?
+ GROUP BY service
+ `).all(fiveMinAgo);
+
+ return {
+ period_days: days,
+ totals: totals || {},
+ services,
+ dailyTrend,
+ realtime,
+ };
+ }
+
+ /**
+ * Get bot analysis data.
+ */
+ getBotAnalysis(days = 30) {
+ const daysCast = Math.min(Math.max(1, days), 365);
+
+ // Top bot IPs
+ const topBotIPs = this.db.prepare(`
+ SELECT ip, COUNT(*) as hits, bot_type, user_agent,
+ MIN(created_at) as first_seen, MAX(created_at) as last_seen
+ FROM analytics_events
+ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
+ GROUP BY ip ORDER BY hits DESC LIMIT 25
+ `).all(daysCast);
+
+ // Bot vs human ratio over time
+ const botTrend = this.db.prepare(`
+ SELECT date,
+ SUM(pageviews) + SUM(api_calls) as human_hits,
+ SUM(bot_hits) as bot_hits
+ FROM analytics_daily
+ WHERE date >= date('now', '-' || ? || ' days')
+ GROUP BY date ORDER BY date ASC
+ `).all(daysCast);
+
+ // Bot type breakdown
+ const botTypes = this.db.prepare(`
+ SELECT bot_type, COUNT(*) as hits, COUNT(DISTINCT ip) as unique_ips
+ FROM analytics_events
+ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
+ GROUP BY bot_type ORDER BY hits DESC
+ `).all(daysCast);
+
+ // Suspicious behavior patterns
+ const suspiciousIPs = this.db.prepare(`
+ SELECT ip, COUNT(*) as total_hits,
+ COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,
+ COUNT(DISTINCT path) as unique_paths,
+ MIN(created_at) as first_seen,
+ MAX(created_at) as last_seen
+ FROM analytics_events
+ WHERE created_at >= datetime('now', '-7 days') AND is_bot = 0
+ GROUP BY ip
+ HAVING total_hits > 500
+ ORDER BY total_hits DESC LIMIT 20
+ `).all();
+
+ return {
+ topBotIPs,
+ botTrend,
+ botTypes,
+ suspiciousIPs,
+ };
+ }
+
+ /**
+ * Destroy tracker (clean up timers).
+ */
+ destroy() {
+ this.flush();
+ if (this._flushInterval) clearInterval(this._flushInterval);
+ if (this._aggregateInterval) clearInterval(this._aggregateInterval);
+ }
+}
+
+
+module.exports = {
+ AnalyticsTracker,
+ classifyRequest,
+ parseUserAgent,
+ ANALYTICS_SCHEMA,
+ BOT_USER_AGENTS,
+ SUSPICIOUS_PATTERNS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — OAuth2 Auth Client
+// Used by OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media
+// to authenticate users via the central OpenVibe.Network SSO.
+// ═══════════════════════════════════════════════════════════════
+
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { BRAND } = require('./brand');
+
+const AUTH_BASE = BRAND.urls.login;
+
+class OpenVibeAuthClient {
+ /**
+ * @param {Object} opts
+ * @param {string} opts.clientId - OAuth2 client_id (e.g. 'live')
+ * @param {string} opts.clientSecret - OAuth2 client_secret
+ * @param {string} opts.redirectUri - Callback URL (e.g. 'https://openvibe.live/auth/callback')
+ * @param {string} opts.publicKey - RS256 public key for JWT verification (PEM)
+ * @param {string} [opts.authBase] - Override auth server URL (default: openvibe.network)
+ * @param {string} [opts.internalBase] - Internal auth API (default: http://127.0.0.1:4000)
+ */
+ constructor(opts) {
+ this.clientId = opts.clientId;
+ this.clientSecret = opts.clientSecret;
+ this.redirectUri = opts.redirectUri;
+ this.publicKey = opts.publicKey;
+ this.authBase = opts.authBase || AUTH_BASE;
+ this.internalBase = opts.internalBase || 'http://127.0.0.1:4000';
+ }
+
+ /**
+ * Generate an authorization URL for the OAuth2 login redirect.
+ * @param {string} [scope='profile theme'] - Requested scopes
+ * @returns {{ url: string, state: string }}
+ */
+ getAuthorizationUrl(scope = 'profile theme') {
+ const state = crypto.randomBytes(16).toString('hex');
+ const params = new URLSearchParams({
+ client_id: this.clientId,
+ redirect_uri: this.redirectUri,
+ response_type: 'code',
+ scope,
+ state,
+ });
+ return {
+ url: `${this.authBase}/oauth/authorize?${params.toString()}`,
+ state,
+ };
+ }
+
+ /**
+ * Exchange an authorization code for tokens.
+ * @param {string} code - The authorization code from the callback
+ * @returns {Promise<{ access_token: string, refresh_token: string, user: Object }>}
+ */
+ async exchangeCode(code) {
+ const res = await fetch(`${this.authBase}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ grant_type: 'authorization_code',
+ client_id: this.clientId,
+ client_secret: this.clientSecret,
+ redirect_uri: this.redirectUri,
+ code,
+ }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `Token exchange failed: ${res.status}`);
+ }
+ return res.json();
+ }
+
+ /**
+ * Refresh an access token using a refresh token.
+ * @param {string} refreshToken
+ * @returns {Promise<{ access_token: string, refresh_token: string }>}
+ */
+ async refreshAccessToken(refreshToken) {
+ const res = await fetch(`${this.authBase}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ grant_type: 'refresh_token',
+ client_id: this.clientId,
+ client_secret: this.clientSecret,
+ refresh_token: refreshToken,
+ }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `Token refresh failed: ${res.status}`);
+ }
+ return res.json();
+ }
+
+ /**
+ * Verify and decode a JWT access token.
+ * Uses the RS256 public key from the auth server.
+ * @param {string} token
+ * @returns {Object|null} Decoded payload or null if invalid
+ */
+ verifyToken(token) {
+ try {
+ // issuer is the tools/SSO public URL — derive from authBase to support white-label
+ const issuer = this.authBase || this.issuer;
+ return jwt.verify(token, this.publicKey, {
+ algorithms: ['RS256'],
+ issuer,
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Verify a token via the internal API (server-to-server).
+ * Useful as a fallback or for getting fresh user data.
+ * @param {string} token
+ * @param {string} internalKey - X-Internal-Key header value
+ * @returns {Promise<Object|null>}
+ */
+ async verifyTokenInternal(token, internalKey) {
+ try {
+ const res = await fetch(`${this.internalBase}/internal/verify-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Internal-Key': internalKey,
+ },
+ body: JSON.stringify({ token }),
+ });
+ if (!res.ok) return null;
+ return res.json();
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Get fresh user profile data via internal API.
+ * @param {number} userId
+ * @param {string} internalKey
+ * @returns {Promise<Object|null>}
+ */
+ async getUserProfile(userId, internalKey) {
+ try {
+ const res = await fetch(`${this.internalBase}/internal/user/${userId}/sync`, {
+ method: 'POST',
+ headers: { 'X-Internal-Key': internalKey },
+ });
+ if (!res.ok) return null;
+ return res.json();
+ } catch {
+ return null;
+ }
+ }
+}
+
+module.exports = { OpenVibeAuthClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Brand Constants
+// Shared across all OpenVibe services for consistent branding.
+// Values are seeded with OpenVibe defaults and can be
+// overridden at runtime via the URL registry / admin panel.
+// ═══════════════════════════════════════════════════════════════
+
+// ── Default (OpenVibe) values ────────────────────────────
+// These are the canonical defaults for the OpenVibe deployment.
+// White-label installs override via the URL registry.
+
+const BRAND_DEFAULTS = Object.freeze({
+ networkName: 'OpenVibe',
+ networkServiceName: 'OpenVibe.Network',
+ toolsServiceName: 'OpenVibe.Tools',
+ streamerServiceName:'OpenVibe.Live',
+ gamesServiceName: 'OpenVibe.Games',
+ mediaServiceName: 'OpenVibe.Media',
+ tagline: 'One Account. All of OpenVibe.',
+ campTagline: 'Free & Open Live Streaming',
+ toolsSubdomainBase: 'openvibe.tools',
+ discord: 'https://discord.gg/M6MuRUaeJj',
+ github: 'https://github.com/OpenVibe.Live',
+});
+
+function getDefaultBrandUrls(env = process.env) {
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ login: env.LOGIN_URL || env.OV_NETWORK_LOGIN_URL || env.OV_NETWORK_URL || 'https://openvibe.network',
+ tools: env.OV_TOOLS_URL || 'https://openvibe.tools',
+ live: env.OV_LIVE_URL || 'https://openvibe.live',
+ games: env.OV_GAMES_URL || 'https://openvibe.games',
+ media: env.OV_MEDIA_URL || 'https://openvibe.media',
+ community:env.OV_COMMUNITY_URL || 'https://openvibe.community',
+ // Tool satellites (Host-routed under openvibe.tools)
+ maps: env.OV_MAPS_URL || 'https://maps.openvibe.tools',
+ food: env.OV_FOOD_URL || 'https://food.openvibe.tools',
+ img: env.OV_IMG_URL || 'https://img.openvibe.tools',
+ yt: env.OV_YT_URL || 'https://yt.openvibe.tools',
+ audio: env.OV_AUDIO_URL || 'https://audio.openvibe.tools',
+ text: env.OV_TEXT_URL || 'https://text.openvibe.tools',
+ logo: env.OV_LOGO_URL || 'https://logo.openvibe.tools',
+ docs: env.OV_DOCS_URL || 'https://docs.openvibe.tools',
+ net: env.OV_NET_URL || 'https://net.openvibe.tools',
+ dev: env.OV_DEV_URL || 'https://dev.openvibe.tools',
+ pastes: env.OV_PASTES_URL || 'https://pastes.openvibe.tools',
+ // Legacy aliases (older callers) — same targets as above
+ streamer: env.OV_LIVE_URL || 'https://openvibe.live',
+ quest: env.OV_GAMES_URL || 'https://openvibe.games',
+ discord: BRAND_DEFAULTS.discord,
+ github: BRAND_DEFAULTS.github,
+ };
+}
+
+const DEFAULT_URLS = Object.freeze(getDefaultBrandUrls());
+
+function resolveBrandUrls(overrides = {}, env = process.env) {
+ return Object.freeze({
+ ...getDefaultBrandUrls(env),
+ ...overrides,
+ });
+}
+
+/**
+ * Build a brand object from a resolved URL registry map.
+ * Call this server-side when the canonical registry has been loaded.
+ *
+ * @param {Object} registry - resolved registry map from resolveRegistryValues()
+ * @param {Object} [env] - process.env fallback
+ * @returns {Object} brand object with names, urls, colors, services, oauth
+ */
+function buildBrandFromRegistry(registry = {}, env = process.env) {
+ const get = (key) => (registry[key]?.value) || null;
+
+ const networkName = get('NETWORK_NAME') || BRAND_DEFAULTS.networkName;
+ const toolsServiceName = get('TOOLS_SERVICE_NAME') || BRAND_DEFAULTS.toolsServiceName;
+ const streamerServiceName= get('STREAMER_SERVICE_NAME')|| BRAND_DEFAULTS.streamerServiceName;
+ const gamesServiceName = get('GAMES_SERVICE_NAME') || BRAND_DEFAULTS.gamesServiceName;
+ const mediaServiceName = get('MEDIA_SERVICE_NAME') || BRAND_DEFAULTS.mediaServiceName;
+ const defaults = getDefaultBrandUrls(env);
+ const networkUrl = get('OV_NETWORK_URL') || defaults.network;
+ const liveUrl = get('OV_LIVE_URL') || defaults.live;
+ const toolsUrl = get('OV_TOOLS_URL') || defaults.tools;
+ const gamesUrl = get('OV_GAMES_URL') || defaults.games;
+ const mediaUrl = get('OV_MEDIA_URL') || defaults.media;
+
+ const urls = resolveBrandUrls({
+ network: networkUrl,
+ login: get('OV_NETWORK_LOGIN_URL') || networkUrl,
+ live: liveUrl,
+ streamer: liveUrl,
+ tools: toolsUrl,
+ games: gamesUrl,
+ quest: gamesUrl,
+ media: mediaUrl,
+ }, env);
+
+ // Build service list using registry URLs where available
+ const services = [
+ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
+ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
+ { id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
+ { id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
+ { id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
+ ];
+
+ return Object.freeze({
+ name: networkName,
+ toolsName: toolsServiceName,
+ streamerName:streamerServiceName,
+ gamesName: gamesServiceName,
+ mediaName: mediaServiceName,
+ tagline: BRAND_DEFAULTS.tagline,
+ campTagline: BRAND_DEFAULTS.campTagline,
+ toolsSubdomainBase: get('TOOLS_SUBDOMAIN_BASE') || BRAND_DEFAULTS.toolsSubdomainBase,
+ urls: Object.freeze(urls),
+ colors: BRAND.colors,
+ oauth: BRAND.oauth,
+ services: Object.freeze(services),
+ });
+}
+
+const BRAND = Object.freeze({
+ name: BRAND_DEFAULTS.networkName,
+ tagline: BRAND_DEFAULTS.tagline,
+ campTagline: BRAND_DEFAULTS.campTagline,
+
+ urls: resolveBrandUrls(),
+
+ // Palette per CONTRACTS.md
+ colors: Object.freeze({
+ accent: '#8b5cf6',
+ accentLight: '#a78bfa',
+ accentDark: '#6d28d9',
+ secondary: '#22d3ee',
+ bgDark: '#131318',
+ flame: '#a78bfa', // legacy alias for accentLight
+ signalRed: '#e74c3c',
+ success: '#2ecc71',
+ warning: '#f39c12',
+ danger: '#e74c3c',
+ info: '#3498db',
+ }),
+
+ // OAuth2 client IDs for each service (seeded in the Network DB)
+ oauth: Object.freeze({
+ live: 'live',
+ tools: 'tools',
+ games: 'games',
+ media: 'media',
+ }),
+
+ // Primary services in the network (for dashboard display, health checks, etc.)
+ // Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
+ services: Object.freeze([
+ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
+ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
+ { id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
+ { id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
+ { id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
+ ]),
+
+ // Tool sub-brands hosted by OpenVibe.Tools (<Name>.OpenVibe naming)
+ toolBrands: Object.freeze([
+ { id: 'net', name: 'Net.OpenVibe', url: 'https://net.openvibe.tools', description: 'Network & Internet Diagnostics' },
+ { id: 'dev', name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools', description: 'Developer & SEO Tools' },
+ { id: 'paste', name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools',description: 'Code & Text Sharing' },
+ { id: 'maps', name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools', description: 'Camp & Shelter Locator' },
+ { id: 'food', name: 'Food.OpenVibe', url: 'https://food.openvibe.tools', description: 'Food Resource Finder' },
+ { id: 'img', name: 'Img.OpenVibe', url: 'https://img.openvibe.tools', description: 'Image Converter & Tools' },
+ { id: 'yt', name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools', description: 'YouTube Downloader' },
+ { id: 'audio', name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools', description: 'Audio Converter & Effects' },
+ { id: 'text', name: 'Text.OpenVibe', url: 'https://text.openvibe.tools', description: 'Text Generation & Unicode' },
+ { id: 'logo', name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools', description: 'Logo & Title Card Maker' },
+ { id: 'docs', name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools', description: 'PDF & Document Tools' },
+ ]),
+});
+
+module.exports = { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry };
new file mode 100644
new file mode 100644
@@ -0,0 +1,878 @@
@@ -0,0 +1,878 @@
+'use strict';
+
+const RAW_BUILTIN_THEMES = [
+ {
+ name: 'Vibe',
+ slug: 'vibe',
+ mode: 'dark',
+ description: 'The original OpenVibe.Live look — warm amber tones by firelight.',
+ tags: ['default', 'warm', 'cozy'],
+ variables: {
+ '--bg-primary': '#0d0d0f',
+ '--bg-secondary': '#16161a',
+ '--bg-tertiary': '#1e1e24',
+ '--bg-card': '#1a1a20',
+ '--bg-hover': '#242430',
+ '--bg-input': '#12121a',
+ '--text-primary': '#e8e6e3',
+ '--text-secondary': '#9a9a9a',
+ '--text-muted': '#666',
+ '--accent': '#8b5cf6',
+ '--accent-light': '#a78bfa',
+ '--accent-dark': '#6d28d9',
+ '--border': '#2a2a32',
+ '--border-light': '#3a3a44',
+ '--live-red': '#e74c3c',
+ '--success': '#2ecc71',
+ '--warning': '#f39c12',
+ '--danger': '#e74c3c',
+ '--info': '#3498db',
+ '--shadow': '0 2px 12px rgba(0,0,0,0.4)',
+ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.6)',
+ },
+ },
+ {
+ name: 'Midnight',
+ slug: 'midnight',
+ mode: 'dark',
+ description: 'Deep blue-black with cool steel accents.',
+ tags: ['cool', 'minimal', 'blue'],
+ variables: {
+ '--bg-primary': '#0a0e17',
+ '--bg-secondary': '#111827',
+ '--bg-tertiary': '#1a2235',
+ '--bg-card': '#151d2e',
+ '--bg-hover': '#1e2a42',
+ '--bg-input': '#0d1220',
+ '--text-primary': '#e2e8f0',
+ '--text-secondary': '#94a3b8',
+ '--text-muted': '#64748b',
+ '--accent': '#6366f1',
+ '--accent-light': '#818cf8',
+ '--accent-dark': '#4f46e5',
+ '--border': '#1e293b',
+ '--border-light': '#334155',
+ },
+ },
+ {
+ name: 'Forest',
+ slug: 'forest',
+ mode: 'dark',
+ description: 'Deep greens and earthy tones — nature at night.',
+ tags: ['nature', 'green', 'earthy'],
+ variables: {
+ '--bg-primary': '#0a120a',
+ '--bg-secondary': '#121e12',
+ '--bg-tertiary': '#1a2a1a',
+ '--bg-card': '#152015',
+ '--bg-hover': '#1e3420',
+ '--bg-input': '#0d160d',
+ '--text-primary': '#d4e8d4',
+ '--text-secondary': '#8aaa8a',
+ '--text-muted': '#5a7a5a',
+ '--accent': '#4ade80',
+ '--accent-light': '#86efac',
+ '--accent-dark': '#22c55e',
+ '--border': '#1a3a1a',
+ '--border-light': '#2a4a2a',
+ },
+ },
+ {
+ name: 'Neon Tokyo',
+ slug: 'neon-tokyo',
+ mode: 'dark',
+ description: 'Cyberpunk neon pink/purple over dark chrome.',
+ tags: ['cyberpunk', 'neon', 'vibrant'],
+ variables: {
+ '--bg-primary': '#0a0a0f',
+ '--bg-secondary': '#12101a',
+ '--bg-tertiary': '#1a1625',
+ '--bg-card': '#15111f',
+ '--bg-hover': '#221a32',
+ '--bg-input': '#0d0b14',
+ '--text-primary': '#f0e6ff',
+ '--text-secondary': '#b4a0d0',
+ '--text-muted': '#6a5a80',
+ '--accent': '#ff006e',
+ '--accent-light': '#ff4d94',
+ '--accent-dark': '#c9005a',
+ '--border': '#2a1a3a',
+ '--border-light': '#3a2a4a',
+ },
+ },
+ {
+ name: 'Dracula',
+ slug: 'dracula',
+ mode: 'dark',
+ description: 'The beloved Dracula color scheme.',
+ tags: ['popular', 'purple', 'classic'],
+ variables: {
+ '--bg-primary': '#282a36',
+ '--bg-secondary': '#1e1f29',
+ '--bg-tertiary': '#343746',
+ '--bg-card': '#2c2e3c',
+ '--bg-hover': '#3a3d50',
+ '--bg-input': '#21222c',
+ '--text-primary': '#f8f8f2',
+ '--text-secondary': '#bfbfb2',
+ '--text-muted': '#6272a4',
+ '--accent': '#bd93f9',
+ '--accent-light': '#d4b8ff',
+ '--accent-dark': '#9b6ddb',
+ '--border': '#44475a',
+ '--border-light': '#565970',
+ '--success': '#50fa7b',
+ '--danger': '#ff5555',
+ '--warning': '#f1fa8c',
+ '--info': '#8be9fd',
+ '--live-red': '#ff5555',
+ },
+ },
+ {
+ name: 'Monokai',
+ slug: 'monokai',
+ mode: 'dark',
+ description: 'Classic Monokai developer palette.',
+ tags: ['developer', 'classic', 'warm'],
+ variables: {
+ '--bg-primary': '#272822',
+ '--bg-secondary': '#1e1f1c',
+ '--bg-tertiary': '#3e3d32',
+ '--bg-card': '#2d2e27',
+ '--bg-hover': '#49483e',
+ '--bg-input': '#22231e',
+ '--text-primary': '#f8f8f2',
+ '--text-secondary': '#cfcfc2',
+ '--text-muted': '#75715e',
+ '--accent': '#a6e22e',
+ '--accent-light': '#c4ff50',
+ '--accent-dark': '#7dac1c',
+ '--border': '#49483e',
+ '--border-light': '#5b5a50',
+ '--danger': '#f92672',
+ '--warning': '#e6db74',
+ '--info': '#66d9ef',
+ },
+ },
+ {
+ name: 'Ocean Deep',
+ slug: 'ocean-deep',
+ mode: 'dark',
+ description: 'Dark ocean depths with teal highlights.',
+ tags: ['ocean', 'teal', 'calm'],
+ variables: {
+ '--bg-primary': '#0a1215',
+ '--bg-secondary': '#0f1a1f',
+ '--bg-tertiary': '#162228',
+ '--bg-card': '#121c22',
+ '--bg-hover': '#1a2a32',
+ '--bg-input': '#0c1418',
+ '--text-primary': '#d0e8ef',
+ '--text-secondary': '#7aa8ba',
+ '--text-muted': '#4a7080',
+ '--accent': '#00bcd4',
+ '--accent-light': '#4dd0e1',
+ '--accent-dark': '#0097a7',
+ '--border': '#1a3040',
+ '--border-light': '#2a4050',
+ },
+ },
+ {
+ name: 'Sunset',
+ slug: 'sunset',
+ mode: 'dark',
+ description: 'Warm orange-to-pink gradient vibes at dusk.',
+ tags: ['warm', 'gradient', 'vibrant'],
+ variables: {
+ '--bg-primary': '#120a0a',
+ '--bg-secondary': '#1a1010',
+ '--bg-tertiary': '#241616',
+ '--bg-card': '#1e1212',
+ '--bg-hover': '#2e1a1a',
+ '--bg-input': '#140c0c',
+ '--text-primary': '#ffe8e0',
+ '--text-secondary': '#c49080',
+ '--text-muted': '#7a5a50',
+ '--accent': '#ff6b35',
+ '--accent-light': '#ff8f5e',
+ '--accent-dark': '#d04a1a',
+ '--border': '#3a2020',
+ '--border-light': '#4a3030',
+ },
+ },
+ {
+ name: 'Arctic',
+ slug: 'arctic',
+ mode: 'dark',
+ description: 'Frozen blues with crisp white accents.',
+ tags: ['cool', 'blue', 'clean'],
+ variables: {
+ '--bg-primary': '#0c1220',
+ '--bg-secondary': '#101828',
+ '--bg-tertiary': '#182030',
+ '--bg-card': '#141c2a',
+ '--bg-hover': '#1e2840',
+ '--bg-input': '#0e1422',
+ '--text-primary': '#e0eaf5',
+ '--text-secondary': '#8aa0c0',
+ '--text-muted': '#5a708a',
+ '--accent': '#38bdf8',
+ '--accent-light': '#7dd3fc',
+ '--accent-dark': '#0284c7',
+ '--border': '#1e3050',
+ '--border-light': '#2a4060',
+ },
+ },
+ {
+ name: 'Ember',
+ slug: 'ember',
+ mode: 'dark',
+ description: 'Smoldering reds and warm charcoal.',
+ tags: ['red', 'warm', 'intense'],
+ variables: {
+ '--bg-primary': '#120808',
+ '--bg-secondary': '#1a0e0e',
+ '--bg-tertiary': '#241414',
+ '--bg-card': '#1e1010',
+ '--bg-hover': '#2e1818',
+ '--bg-input': '#140a0a',
+ '--text-primary': '#f0dada',
+ '--text-secondary': '#b0808a',
+ '--text-muted': '#6a4a50',
+ '--accent': '#ef4444',
+ '--accent-light': '#f87171',
+ '--accent-dark': '#b91c1c',
+ '--border': '#3a1a20',
+ '--border-light': '#4a2a30',
+ },
+ },
+ {
+ name: 'Vapor',
+ slug: 'vapor',
+ mode: 'dark',
+ description: 'Vaporwave aesthetics — pink, purple, and teal.',
+ tags: ['retro', 'aesthetic', 'neon'],
+ variables: {
+ '--bg-primary': '#0e0a14',
+ '--bg-secondary': '#14101c',
+ '--bg-tertiary': '#1c1628',
+ '--bg-card': '#181220',
+ '--bg-hover': '#241a34',
+ '--bg-input': '#100c16',
+ '--text-primary': '#f0e0ff',
+ '--text-secondary': '#c090e0',
+ '--text-muted': '#7050a0',
+ '--accent': '#e040fb',
+ '--accent-light': '#ea80fc',
+ '--accent-dark': '#aa00d4',
+ '--border': '#2a1a40',
+ '--border-light': '#3a2a50',
+ '--info': '#00e5ff',
+ '--success': '#69f0ae',
+ },
+ },
+ {
+ name: 'Slate',
+ slug: 'slate',
+ mode: 'dark',
+ description: 'Neutral gray with subtle blue undertones.',
+ tags: ['neutral', 'minimal', 'professional'],
+ variables: {
+ '--bg-primary': '#0f1118',
+ '--bg-secondary': '#161820',
+ '--bg-tertiary': '#1e2028',
+ '--bg-card': '#1a1c24',
+ '--bg-hover': '#24262e',
+ '--bg-input': '#12141c',
+ '--text-primary': '#e4e6ea',
+ '--text-secondary': '#a0a4b0',
+ '--text-muted': '#606470',
+ '--accent': '#a78bfa',
+ '--accent-light': '#c4b5fd',
+ '--accent-dark': '#7c3aed',
+ '--border': '#2a2c38',
+ '--border-light': '#3a3c48',
+ },
+ },
+ {
+ name: 'Matrix',
+ slug: 'matrix',
+ mode: 'dark',
+ description: 'Green terminal text on pitch black.',
+ tags: ['hacker', 'retro', 'terminal'],
+ variables: {
+ '--bg-primary': '#000000',
+ '--bg-secondary': '#050a05',
+ '--bg-tertiary': '#0a140a',
+ '--bg-card': '#071007',
+ '--bg-hover': '#0e1e0e',
+ '--bg-input': '#030803',
+ '--text-primary': '#00ff41',
+ '--text-secondary': '#00b030',
+ '--text-muted': '#005a18',
+ '--accent': '#00ff41',
+ '--accent-light': '#66ff8a',
+ '--accent-dark': '#00aa2a',
+ '--border': '#0a2a0a',
+ '--border-light': '#144014',
+ },
+ },
+ {
+ name: 'Nord',
+ slug: 'nord',
+ mode: 'dark',
+ description: 'Arctic, north-bluish color palette inspired by the Nord theme.',
+ tags: ['popular', 'blue', 'soft'],
+ variables: {
+ '--bg-primary': '#2e3440',
+ '--bg-secondary': '#272c36',
+ '--bg-tertiary': '#3b4252',
+ '--bg-card': '#333a48',
+ '--bg-hover': '#434c5e',
+ '--bg-input': '#2a303c',
+ '--text-primary': '#eceff4',
+ '--text-secondary': '#d8dee9',
+ '--text-muted': '#7b88a1',
+ '--accent': '#88c0d0',
+ '--accent-light': '#8fbcbb',
+ '--accent-dark': '#5e81ac',
+ '--border': '#3b4252',
+ '--border-light': '#4c566a',
+ '--success': '#a3be8c',
+ '--danger': '#bf616a',
+ '--warning': '#ebcb8b',
+ '--info': '#81a1c1',
+ },
+ },
+ {
+ name: 'Gruvbox Dark',
+ slug: 'gruvbox-dark',
+ mode: 'dark',
+ description: 'Retro groove colors with warm, high-contrast tones.',
+ tags: ['retro', 'warm', 'developer'],
+ variables: {
+ '--bg-primary': '#1d2021',
+ '--bg-secondary': '#282828',
+ '--bg-tertiary': '#3c3836',
+ '--bg-card': '#32302f',
+ '--bg-hover': '#504945',
+ '--bg-input': '#242424',
+ '--text-primary': '#ebdbb2',
+ '--text-secondary': '#d5c4a1',
+ '--text-muted': '#928374',
+ '--accent': '#fabd2f',
+ '--accent-light': '#fce566',
+ '--accent-dark': '#d79921',
+ '--border': '#3c3836',
+ '--border-light': '#504945',
+ '--success': '#b8bb26',
+ '--danger': '#fb4934',
+ '--warning': '#fe8019',
+ '--info': '#83a598',
+ },
+ },
+ {
+ name: 'Abyss',
+ slug: 'abyss',
+ mode: 'dark',
+ description: 'The deepest dark — nearly pure black with dim accents.',
+ tags: ['minimal', 'ultra-dark', 'oled'],
+ variables: {
+ '--bg-primary': '#000000',
+ '--bg-secondary': '#080808',
+ '--bg-tertiary': '#111111',
+ '--bg-card': '#0a0a0a',
+ '--bg-hover': '#181818',
+ '--bg-input': '#050505',
+ '--text-primary': '#cccccc',
+ '--text-secondary': '#888888',
+ '--text-muted': '#444444',
+ '--accent': '#ffffff',
+ '--accent-light': '#ffffff',
+ '--accent-dark': '#aaaaaa',
+ '--border': '#1a1a1a',
+ '--border-light': '#252525',
+ },
+ },
+ {
+ name: 'Copper',
+ slug: 'copper',
+ mode: 'dark',
+ description: 'Rich copper and bronze on dark walnut.',
+ tags: ['metallic', 'warm', 'premium'],
+ variables: {
+ '--bg-primary': '#110d09',
+ '--bg-secondary': '#1a140e',
+ '--bg-tertiary': '#231c14',
+ '--bg-card': '#1e1710',
+ '--bg-hover': '#2c2218',
+ '--bg-input': '#13100b',
+ '--text-primary': '#e8ddd0',
+ '--text-secondary': '#b49a80',
+ '--text-muted': '#7a6a54',
+ '--accent': '#cd7f32',
+ '--accent-light': '#daa060',
+ '--accent-dark': '#a06020',
+ '--border': '#2a2016',
+ '--border-light': '#3a3020',
+ },
+ },
+ {
+ name: 'Sakura Night',
+ slug: 'sakura-night',
+ mode: 'dark',
+ description: 'Soft pink cherry blossom on dark indigo.',
+ tags: ['japanese', 'pink', 'soft'],
+ variables: {
+ '--bg-primary': '#0e0a14',
+ '--bg-secondary': '#150f1c',
+ '--bg-tertiary': '#1c1526',
+ '--bg-card': '#181020',
+ '--bg-hover': '#221830',
+ '--bg-input': '#100c16',
+ '--text-primary': '#f0e0f0',
+ '--text-secondary': '#c0a0c0',
+ '--text-muted': '#7a5a7a',
+ '--accent': '#f472b6',
+ '--accent-light': '#f9a8d4',
+ '--accent-dark': '#db2777',
+ '--border': '#2a1a30',
+ '--border-light': '#3a2a40',
+ },
+ },
+ {
+ name: 'Hacker',
+ slug: 'hacker',
+ mode: 'dark',
+ description: 'Amber-on-black retro terminal look.',
+ tags: ['retro', 'terminal', 'nostalgia'],
+ variables: {
+ '--bg-primary': '#000000',
+ '--bg-secondary': '#0a0800',
+ '--bg-tertiary': '#141000',
+ '--bg-card': '#0e0c00',
+ '--bg-hover': '#1c1800',
+ '--bg-input': '#060400',
+ '--text-primary': '#ffb000',
+ '--text-secondary': '#cc8d00',
+ '--text-muted': '#664700',
+ '--accent': '#ffb000',
+ '--accent-light': '#ffd050',
+ '--accent-dark': '#cc8d00',
+ '--border': '#1a1400',
+ '--border-light': '#2a2200',
+ },
+ },
+ {
+ name: 'Daylight',
+ slug: 'daylight',
+ mode: 'light',
+ description: 'Clean, bright white with warm accents for daytime use.',
+ tags: ['clean', 'bright', 'default-light'],
+ variables: {
+ '--bg-primary': '#ffffff',
+ '--bg-secondary': '#f7f7f8',
+ '--bg-tertiary': '#eeeef0',
+ '--bg-card': '#ffffff',
+ '--bg-hover': '#f0f0f2',
+ '--bg-input': '#f5f5f7',
+ '--text-primary': '#1a1a2e',
+ '--text-secondary': '#4a4a6a',
+ '--text-muted': '#9a9ab0',
+ '--accent': '#8b5cf6',
+ '--accent-light': '#a78bfa',
+ '--accent-dark': '#6d28d9',
+ '--border': '#e0e0e6',
+ '--border-light': '#d0d0d8',
+ '--shadow': '0 2px 12px rgba(0,0,0,0.08)',
+ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.12)',
+ },
+ },
+ {
+ name: 'Paper',
+ slug: 'paper',
+ mode: 'light',
+ description: 'Warm off-white like aged paper with ink accents.',
+ tags: ['warm', 'soft', 'minimal'],
+ variables: {
+ '--bg-primary': '#faf8f5',
+ '--bg-secondary': '#f0ece6',
+ '--bg-tertiary': '#e8e2d8',
+ '--bg-card': '#faf8f5',
+ '--bg-hover': '#f0ece6',
+ '--bg-input': '#f5f2ed',
+ '--text-primary': '#2c2416',
+ '--text-secondary': '#5a4e3e',
+ '--text-muted': '#9a8e7e',
+ '--accent': '#8b6914',
+ '--accent-light': '#b08a30',
+ '--accent-dark': '#6a5010',
+ '--border': '#ddd6c8',
+ '--border-light': '#ccc4b0',
+ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',
+ },
+ },
+ {
+ name: 'Cloud',
+ slug: 'cloud',
+ mode: 'light',
+ description: 'Airy light blue with sky accents.',
+ tags: ['blue', 'airy', 'fresh'],
+ variables: {
+ '--bg-primary': '#f0f5ff',
+ '--bg-secondary': '#e8eef8',
+ '--bg-tertiary': '#dde5f0',
+ '--bg-card': '#f5f8ff',
+ '--bg-hover': '#e4ecf8',
+ '--bg-input': '#edf2fa',
+ '--text-primary': '#1a2040',
+ '--text-secondary': '#4a5070',
+ '--text-muted': '#8a90aa',
+ '--accent': '#3b82f6',
+ '--accent-light': '#60a5fa',
+ '--accent-dark': '#2563eb',
+ '--border': '#d0d8ea',
+ '--border-light': '#bcc8e0',
+ '--shadow': '0 2px 12px rgba(0,20,60,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(0,20,60,0.10)',
+ },
+ },
+ {
+ name: 'Meadow',
+ slug: 'meadow',
+ mode: 'light',
+ description: 'Fresh spring greens with natural tones.',
+ tags: ['nature', 'green', 'fresh'],
+ variables: {
+ '--bg-primary': '#f2f8f0',
+ '--bg-secondary': '#e8f0e4',
+ '--bg-tertiary': '#dce8d6',
+ '--bg-card': '#f5faf2',
+ '--bg-hover': '#e4f0de',
+ '--bg-input': '#edf5ea',
+ '--text-primary': '#1a2e16',
+ '--text-secondary': '#3a5a32',
+ '--text-muted': '#7a9a6e',
+ '--accent': '#16a34a',
+ '--accent-light': '#22c55e',
+ '--accent-dark': '#15803d',
+ '--border': '#c8dcc0',
+ '--border-light': '#b0ccaa',
+ '--shadow': '0 2px 12px rgba(0,40,0,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(0,40,0,0.10)',
+ },
+ },
+ {
+ name: 'Peach',
+ slug: 'peach',
+ mode: 'light',
+ description: 'Soft peachy pinks with warm coral accents.',
+ tags: ['warm', 'pink', 'soft'],
+ variables: {
+ '--bg-primary': '#fff5f0',
+ '--bg-secondary': '#fbeae2',
+ '--bg-tertiary': '#f5ddd2',
+ '--bg-card': '#fff7f2',
+ '--bg-hover': '#fce8dc',
+ '--bg-input': '#fdf0ea',
+ '--text-primary': '#2e1a14',
+ '--text-secondary': '#6a4438',
+ '--text-muted': '#aa8070',
+ '--accent': '#f97316',
+ '--accent-light': '#fb923c',
+ '--accent-dark': '#ea580c',
+ '--border': '#f0d0c0',
+ '--border-light': '#e4bfab',
+ '--shadow': '0 2px 12px rgba(60,20,0,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(60,20,0,0.10)',
+ },
+ },
+ {
+ name: 'Lavender',
+ slug: 'lavender',
+ mode: 'light',
+ description: 'Gentle purple hues — calming and elegant.',
+ tags: ['purple', 'calm', 'elegant'],
+ variables: {
+ '--bg-primary': '#f8f5ff',
+ '--bg-secondary': '#f0eaf8',
+ '--bg-tertiary': '#e6ddf0',
+ '--bg-card': '#faf7ff',
+ '--bg-hover': '#eee6f8',
+ '--bg-input': '#f4f0fa',
+ '--text-primary': '#1e1430',
+ '--text-secondary': '#4a3a60',
+ '--text-muted': '#9080aa',
+ '--accent': '#8b5cf6',
+ '--accent-light': '#a78bfa',
+ '--accent-dark': '#7c3aed',
+ '--border': '#ddd0ee',
+ '--border-light': '#ccc0dd',
+ '--shadow': '0 2px 12px rgba(40,0,60,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(40,0,60,0.10)',
+ },
+ },
+ {
+ name: 'Gruvbox Light',
+ slug: 'gruvbox-light',
+ mode: 'light',
+ description: 'Light retro groove — warm cream with bold accents.',
+ tags: ['retro', 'warm', 'developer'],
+ variables: {
+ '--bg-primary': '#fbf1c7',
+ '--bg-secondary': '#f2e5bc',
+ '--bg-tertiary': '#ebdbb2',
+ '--bg-card': '#fbf1c7',
+ '--bg-hover': '#f2e5bc',
+ '--bg-input': '#f5ecc4',
+ '--text-primary': '#3c3836',
+ '--text-secondary': '#504945',
+ '--text-muted': '#928374',
+ '--accent': '#d65d0e',
+ '--accent-light': '#fe8019',
+ '--accent-dark': '#af3a03',
+ '--border': '#d5c4a1',
+ '--border-light': '#bdae93',
+ '--success': '#98971a',
+ '--danger': '#cc241d',
+ '--warning': '#d79921',
+ '--info': '#458588',
+ '--shadow': '0 2px 12px rgba(40,30,0,0.08)',
+ '--shadow-lg': '0 8px 32px rgba(40,30,0,0.12)',
+ },
+ },
+ {
+ name: 'Snow',
+ slug: 'snow',
+ mode: 'light',
+ description: 'Pure white with icy blue accents.',
+ tags: ['minimal', 'clean', 'blue'],
+ variables: {
+ '--bg-primary': '#ffffff',
+ '--bg-secondary': '#f8fafc',
+ '--bg-tertiary': '#f1f5f9',
+ '--bg-card': '#ffffff',
+ '--bg-hover': '#f1f5f9',
+ '--bg-input': '#f8fafc',
+ '--text-primary': '#0f172a',
+ '--text-secondary': '#334155',
+ '--text-muted': '#94a3b8',
+ '--accent': '#0ea5e9',
+ '--accent-light': '#38bdf8',
+ '--accent-dark': '#0284c7',
+ '--border': '#e2e8f0',
+ '--border-light': '#cbd5e1',
+ '--shadow': '0 2px 12px rgba(0,0,0,0.05)',
+ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.08)',
+ },
+ },
+ {
+ name: 'Sand',
+ slug: 'sand',
+ mode: 'light',
+ description: 'Desert sand and warm tan tones.',
+ tags: ['warm', 'earthy', 'desert'],
+ variables: {
+ '--bg-primary': '#f8f4ef',
+ '--bg-secondary': '#f0e8de',
+ '--bg-tertiary': '#e8ddd0',
+ '--bg-card': '#faf6f0',
+ '--bg-hover': '#f0e8de',
+ '--bg-input': '#f5f0e8',
+ '--text-primary': '#2e2418',
+ '--text-secondary': '#5a4e3e',
+ '--text-muted': '#9a8e7e',
+ '--accent': '#b8860b',
+ '--accent-light': '#daa520',
+ '--accent-dark': '#8b6508',
+ '--border': '#ddd0be',
+ '--border-light': '#ccc0aa',
+ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',
+ },
+ },
+ {
+ name: 'Rose',
+ slug: 'rose',
+ mode: 'light',
+ description: 'Delicate rose pink with warm blush tones.',
+ tags: ['pink', 'romantic', 'soft'],
+ variables: {
+ '--bg-primary': '#fff5f7',
+ '--bg-secondary': '#fce7ec',
+ '--bg-tertiary': '#f5d5de',
+ '--bg-card': '#fff7f9',
+ '--bg-hover': '#fce0e8',
+ '--bg-input': '#fdeef2',
+ '--text-primary': '#2e1420',
+ '--text-secondary': '#6a3850',
+ '--text-muted': '#aa7090',
+ '--accent': '#e11d48',
+ '--accent-light': '#f43f5e',
+ '--accent-dark': '#be123c',
+ '--border': '#f0c8d4',
+ '--border-light': '#e4b0c0',
+ '--shadow': '0 2px 12px rgba(60,0,20,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(60,0,20,0.10)',
+ },
+ },
+ {
+ name: 'Solarized Dark',
+ slug: 'solarized-dark',
+ mode: 'dark',
+ description: 'Ethan Schoonover\'s precision color scheme — dark variant.',
+ tags: ['popular', 'developer', 'balanced'],
+ variables: {
+ '--bg-primary': '#002b36',
+ '--bg-secondary': '#073642',
+ '--bg-tertiary': '#0a3f4e',
+ '--bg-card': '#053542',
+ '--bg-hover': '#0d4a58',
+ '--bg-input': '#01303d',
+ '--text-primary': '#fdf6e3',
+ '--text-secondary': '#93a1a1',
+ '--text-muted': '#586e75',
+ '--accent': '#b58900',
+ '--accent-light': '#d4a017',
+ '--accent-dark': '#8a6a00',
+ '--border': '#0a4050',
+ '--border-light': '#105060',
+ '--success': '#859900',
+ '--danger': '#dc322f',
+ '--warning': '#cb4b16',
+ '--info': '#268bd2',
+ },
+ },
+ {
+ name: 'Solarized Light',
+ slug: 'solarized-light',
+ mode: 'light',
+ description: 'Ethan Schoonover\'s precision color scheme — light variant.',
+ tags: ['popular', 'developer', 'balanced'],
+ variables: {
+ '--bg-primary': '#fdf6e3',
+ '--bg-secondary': '#eee8d5',
+ '--bg-tertiary': '#e4ddc6',
+ '--bg-card': '#fdf6e3',
+ '--bg-hover': '#eee8d5',
+ '--bg-input': '#f5eedb',
+ '--text-primary': '#002b36',
+ '--text-secondary': '#586e75',
+ '--text-muted': '#93a1a1',
+ '--accent': '#b58900',
+ '--accent-light': '#d4a017',
+ '--accent-dark': '#8a6a00',
+ '--border': '#d6ceb5',
+ '--border-light': '#c8c0a5',
+ '--success': '#859900',
+ '--danger': '#dc322f',
+ '--warning': '#cb4b16',
+ '--info': '#268bd2',
+ '--shadow': '0 2px 12px rgba(0,30,40,0.08)',
+ '--shadow-lg': '0 8px 32px rgba(0,30,40,0.12)',
+ },
+ },
+ {
+ name: 'Catppuccin Mocha',
+ slug: 'catppuccin-mocha',
+ mode: 'dark',
+ description: 'Soothing pastel theme for the high-spirited.',
+ tags: ['popular', 'pastel', 'soft'],
+ variables: {
+ '--bg-primary': '#1e1e2e',
+ '--bg-secondary': '#181825',
+ '--bg-tertiary': '#313244',
+ '--bg-card': '#1e1e2e',
+ '--bg-hover': '#313244',
+ '--bg-input': '#1a1a2c',
+ '--text-primary': '#cdd6f4',
+ '--text-secondary': '#bac2de',
+ '--text-muted': '#6c7086',
+ '--accent': '#cba6f7',
+ '--accent-light': '#dcc0ff',
+ '--accent-dark': '#b07de0',
+ '--border': '#313244',
+ '--border-light': '#45475a',
+ '--success': '#a6e3a1',
+ '--danger': '#f38ba8',
+ '--warning': '#fab387',
+ '--info': '#89b4fa',
+ '--live-red': '#f38ba8',
+ },
+ },
+ {
+ name: 'Catppuccin Latte',
+ slug: 'catppuccin-latte',
+ mode: 'light',
+ description: 'Soothing pastel theme — light variant.',
+ tags: ['popular', 'pastel', 'soft'],
+ variables: {
+ '--bg-primary': '#eff1f5',
+ '--bg-secondary': '#e6e9ef',
+ '--bg-tertiary': '#dce0e8',
+ '--bg-card': '#eff1f5',
+ '--bg-hover': '#dce0e8',
+ '--bg-input': '#e8ebf0',
+ '--text-primary': '#4c4f69',
+ '--text-secondary': '#5c5f77',
+ '--text-muted': '#9ca0b0',
+ '--accent': '#8839ef',
+ '--accent-light': '#a05cff',
+ '--accent-dark': '#7028d4',
+ '--border': '#ccd0da',
+ '--border-light': '#bcc0cc',
+ '--success': '#40a02b',
+ '--danger': '#d20f39',
+ '--warning': '#fe640b',
+ '--info': '#1e66f5',
+ '--live-red': '#d20f39',
+ '--shadow': '0 2px 12px rgba(0,0,0,0.06)',
+ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.10)',
+ },
+ },
+ {
+ name: 'High Contrast',
+ slug: 'high-contrast',
+ mode: 'dark',
+ description: 'Maximum contrast for accessibility — bold yellow on black.',
+ tags: ['accessibility', 'high-contrast', 'bold'],
+ variables: {
+ '--bg-primary': '#000000',
+ '--bg-secondary': '#0a0a0a',
+ '--bg-tertiary': '#1a1a1a',
+ '--bg-card': '#0f0f0f',
+ '--bg-hover': '#222222',
+ '--bg-input': '#0a0a0a',
+ '--text-primary': '#ffffff',
+ '--text-secondary': '#f0f0f0',
+ '--text-muted': '#aaaaaa',
+ '--accent': '#ffd700',
+ '--accent-light': '#ffed4a',
+ '--accent-dark': '#ccac00',
+ '--border': '#444444',
+ '--border-light': '#666666',
+ '--live-red': '#ff0000',
+ '--success': '#00ff00',
+ '--warning': '#ffff00',
+ '--danger': '#ff0000',
+ '--info': '#00ffff',
+ },
+ },
+];
+
+const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({
+ id: theme.slug,
+ ...theme,
+ tags: Array.isArray(theme.tags) ? [...theme.tags] : [],
+ variables: { ...(theme.variables || {}) },
+}));
+
+const DEFAULT_THEME = BUILTIN_THEMES.find((theme) => theme.id === 'vibe') || BUILTIN_THEMES[0];
+const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });
+
+module.exports = {
+ BUILTIN_THEMES,
+ DEFAULT_THEME,
+ DEFAULT_VARS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+const { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry } = require('./brand');
+const { OpenVibeAuthClient } = require('./auth-client');
+const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');
+const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth, internalApiAuth } = require('./middleware');
+const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
+const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
+const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
+
+module.exports = {
+ // Brand
+ BRAND,
+ BRAND_DEFAULTS,
+ buildBrandFromRegistry,
+ // Auth
+ OpenVibeAuthClient,
+ extractToken,
+ requireOpenVibeAuth,
+ optionalOpenVibeAuth,
+ internalApiAuth,
+ // Themes
+ CSS_VARIABLES,
+ DEFAULT_VARS,
+ BUILTIN_THEMES,
+ applyTheme,
+ resolveBuiltinTheme,
+ sanitizeCssValue,
+ loadFromStorage,
+ saveToStorage,
+ syncThemeToServer,
+ // Notifications
+ PRIORITY,
+ CATEGORY,
+ NOTIFICATION_TYPES: TYPES,
+ SOUNDS,
+ EMAIL_ELIGIBLE_CATEGORIES,
+ createNotification,
+ DEFAULT_NOTIFICATION_PREFS,
+ // Analytics
+ AnalyticsTracker,
+ classifyRequest,
+ parseUserAgent,
+ ANALYTICS_SCHEMA,
+ BOT_USER_AGENTS,
+ SUSPICIOUS_PATTERNS,
+ // URL Registry Resolver
+ URL_DEFINITIONS,
+ normalizeValue,
+ validateValue,
+ resolveRegistryValues,
+ formatRegistryEntry,
+ resolveBrandUrls,
+ // Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
+ // OpenVibeNotifications: require('./notification-ui'),
+ // OpenVibeUserCard: require('./user-card'),
+ // OpenVibeNavbar: require('./navbar'),
+ // OpenVibeAccountSwitcher: require('./account-switcher'),
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Express Middleware
+// Auth middleware for services consuming OpenVibe.Network SSO tokens.
+// ═══════════════════════════════════════════════════════════════
+
+const { OpenVibeAuthClient } = require('./auth-client');
+
+/**
+ * Extract token from request.
+ * Priority: Authorization header → cookie → query param (WebSocket)
+ */
+function extractToken(req) {
+ // 1. Authorization: Bearer <token>
+ const authHeader = req.headers.authorization;
+ if (authHeader && authHeader.startsWith('Bearer ')) {
+ return authHeader.slice(7);
+ }
+ // 2. Cookie
+ if (req.cookies?.ov_token) return req.cookies.ov_token;
+ if (req.cookies?.token) return req.cookies.token; // legacy compat
+ // 3. Query param (WebSocket upgrade)
+ if (req.query?.token) return req.query.token;
+ return null;
+}
+
+/**
+ * Create requireOpenVibeAuth middleware.
+ * Verifies JWT signed by OpenVibe.Network and attaches req.user.
+ *
+ * @param {OpenVibeAuthClient} authClient
+ * @returns {Function} Express middleware
+ */
+function requireOpenVibeAuth(authClient) {
+ return (req, res, next) => {
+ const token = extractToken(req);
+ if (!token) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ const user = authClient.verifyToken(token);
+ if (!user) {
+ return res.status(401).json({ error: 'Invalid or expired token' });
+ }
+ req.user = user;
+ req.token = token;
+ next();
+ };
+}
+
+/**
+ * Create optionalOpenVibeAuth middleware.
+ * Attaches req.user if a valid token is present, otherwise continues.
+ *
+ * @param {OpenVibeAuthClient} authClient
+ * @returns {Function} Express middleware
+ */
+function optionalOpenVibeAuth(authClient) {
+ return (req, _res, next) => {
+ const token = extractToken(req);
+ if (token) {
+ const user = authClient.verifyToken(token);
+ if (user) {
+ req.user = user;
+ req.token = token;
+ }
+ }
+ next();
+ };
+}
+
+/**
+ * Internal API authentication middleware.
+ * Verifies X-Internal-Key header for server-to-server calls.
+ *
+ * @param {string} internalKey - Expected key value
+ * @returns {Function} Express middleware
+ */
+function internalApiAuth(internalKey) {
+ return (req, res, next) => {
+ const provided = req.headers['x-internal-key'];
+ if (!provided || provided !== internalKey) {
+ return res.status(403).json({ error: 'Forbidden' });
+ }
+ next();
+ };
+}
+
+module.exports = {
+ extractToken,
+ requireOpenVibeAuth,
+ optionalOpenVibeAuth,
+ internalApiAuth,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,651 @@
@@ -0,0 +1,651 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Universal Navbar
+// Consistent top bar across all services with logo, navigation,
+// notification bell, account switcher, and theme-aware styling.
+// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };
+ let _navEl = null;
+
+ function injectStyles() {
+ if (document.getElementById('openvibe-navbar-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-navbar-styles';
+ s.textContent = `
+ .openvibe-navbar {
+ position: sticky; top: 0; z-index: 10000;
+ height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;
+ background: var(--bg-secondary, #252530);
+ border-bottom: 1px solid var(--border, #333340);
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ }
+ .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
+ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
+ .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
+ .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
+
+ .openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
+ .openvibe-navbar-links a {
+ padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
+ color: var(--text-secondary, #b0b0b8); text-decoration: none;
+ transition: all .15s;
+ }
+ .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #a78bfa); }
+
+ .openvibe-navbar-spacer { flex: 1; }
+
+ .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
+
+ .openvibe-navbar-avatar {
+ width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
+ border: 2px solid var(--border, #333340); transition: border-color .2s;
+ object-fit: cover;
+ }
+ .openvibe-navbar-avatar:hover { border-color: var(--accent, #8b5cf6); }
+
+ .openvibe-navbar-login {
+ padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
+ background: var(--accent, #8b5cf6); color: #fff; border: none; cursor: pointer;
+ transition: background .15s; text-decoration: none; display: inline-flex; align-items: center;
+ }
+ .openvibe-navbar-login:hover { background: var(--accent-dark, #6d28d9); }
+
+ .openvibe-navbar-dropdown {
+ position: absolute; top: 48px; right: 8px;
+ width: 260px; background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 10px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ display: none; flex-direction: column; overflow: hidden;
+ animation: openvibe-slide-down .2s ease;
+ }
+ .openvibe-navbar-dropdown.open { display: flex; }
+ @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
+
+ .openvibe-navbar-dropdown-header {
+ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
+ display: flex; align-items: center; gap: 10px;
+ }
+ .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-header .info { line-height: 1.3; }
+ .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
+ .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
+ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #a78bfa); }
+
+ .openvibe-navbar-dropdown-accounts {
+ padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);
+ max-height: 140px; overflow-y: auto;
+ }
+ .openvibe-navbar-dropdown-accounts .account-item {
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
+ border-radius: 6px; cursor: pointer; font-size: 12px;
+ color: var(--text-secondary, #b0b0b8); transition: background .12s;
+ }
+ .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #a78bfa); font-weight: 600; }
+ .openvibe-navbar-dropdown-accounts .add-account {
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
+ border-radius: 6px; cursor: pointer; font-size: 12px;
+ color: var(--text-muted, #707080); transition: background .12s;
+ text-decoration: none;
+ }
+ .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+
+ .openvibe-navbar-dropdown-menu { padding: 6px 8px; }
+ .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ padding: 8px; border-radius: 6px; font-size: 12px; font-weight: 500;
+ background: none; border: none; color: var(--text-primary, #e0e0e0);
+ cursor: pointer; text-align: left; text-decoration: none;
+ transition: background .12s;
+ }
+ .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-menu .danger { color: var(--live-red, #e74c3c); }
+ .openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }
+
+ .openvibe-navbar .openvibe-network-badge {
+ font-size: 10px; padding: 2px 8px; border-radius: 4px;
+ background: rgba(139,92,246,0.1); color: var(--accent-light, #a78bfa);
+ font-weight: 500; cursor: pointer; border: 1px solid transparent;
+ transition: all .15s;
+ }
+ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #6d28d9); }
+
+ @media (max-width: 600px) {
+ .openvibe-navbar-links { display: none; }
+ .openvibe-navbar .openvibe-network-badge { display: none; }
+ }
+ `;
+ document.head.appendChild(s);
+ }
+
+ // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)
+ const SERVICE_NAMES = {
+ live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',
+ media: 'OpenVibe.Media', network: 'OpenVibe.Network',
+ net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',
+ maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',
+ yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',
+ logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',
+ };
+
+ const SERVICE_ICONS = {
+ live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',
+ media: 'fa-photo-film', network: 'fa-circle-nodes',
+ net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',
+ maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',
+ yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',
+ logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',
+ };
+
+ // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)
+ const SUBDOMAIN_BRANDS = {
+ 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },
+ 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
+ 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
+ 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },
+ 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },
+ 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
+ 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
+ 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },
+ 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },
+ 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },
+ 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },
+ 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },
+ 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },
+ 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },
+ 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },
+ 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },
+ 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },
+ 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },
+ 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },
+ 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },
+ // Audio tool subdomains
+ 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },
+ 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },
+ 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },
+ 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },
+ 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },
+ 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },
+ 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },
+ 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },
+ 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },
+ 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },
+ 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },
+ 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },
+ 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },
+ 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },
+ 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },
+ 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },
+ 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },
+ 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },
+ 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },
+ 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },
+ 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },
+ 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },
+ 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },
+ // Text tool subdomains
+ 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
+ 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
+ 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },
+ 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },
+ 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },
+ 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },
+ 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },
+ 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },
+ 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },
+ 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },
+ 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },
+ 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },
+ 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },
+ 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },
+ 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },
+ 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },
+ 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },
+ 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },
+ 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },
+ 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },
+ 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },
+ 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },
+ 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },
+ 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },
+ 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },
+ 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },
+ 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },
+ 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },
+ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
+ 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },
+ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
+ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
+ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },
+ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
+ 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },
+ 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },
+ 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },
+ 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },
+ 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },
+ 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },
+ 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },
+ 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },
+ 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },
+ 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },
+ // Logo / design subdomains
+ 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },
+ 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },
+ 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },
+ 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },
+ 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },
+ 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },
+ 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },
+ 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },
+ 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },
+ 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },
+ 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },
+ 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },
+ // Document / PDF subdomains
+ 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },
+ 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },
+ 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },
+ 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },
+ 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },
+ 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },
+ 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },
+ 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },
+ 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },
+ 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },
+ 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },
+ 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },
+ 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },
+ 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },
+ 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },
+ // Network tool subdomains
+ 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },
+ 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },
+ 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },
+ 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },
+ 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },
+ 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },
+ 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },
+ 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },
+ 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },
+ 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },
+ 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },
+ 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },
+ 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },
+ 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },
+ 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },
+ 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },
+ 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },
+ 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },
+ 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },
+ 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },
+ 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },
+ 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },
+ 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },
+ 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },
+ 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },
+ 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },
+ 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },
+ 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },
+ 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },
+ 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },
+ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },
+ 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },
+ 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },
+ // Dev.OpenVibe subdomains
+ 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
+ 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
+ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },
+ 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },
+ 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },
+ 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },
+ 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },
+ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
+ 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },
+ 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
+ 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },
+ 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },
+ 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
+ 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
+ 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },
+ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
+ 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
+ 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },
+ 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
+ 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },
+ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
+ 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },
+ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
+ 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },
+ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
+ 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },
+ 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
+ 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
+ // Dev.OpenVibe aliases
+ 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
+ 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
+ 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
+ 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
+ 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
+ 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
+ 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
+ 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
+ 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
+ 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
+ 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
+ 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
+ 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
+ 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
+ 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
+ };
+
+ const SERVICE_LINKS = {
+ live: [
+ { label: 'Watch', href: '/' },
+ { label: 'Chat', href: '/chat' },
+ { label: 'VODs', href: '/vods' },
+ { label: 'Game', href: '/game' },
+ ],
+ tools: [
+ { label: 'Home', href: '/' },
+ ],
+ games: [
+ { label: 'Play', href: '/game' },
+ { label: 'Canvas', href: '/canvas' },
+ { label: 'Leaderboard', href: '/leaderboard' },
+ ],
+ media: [
+ { label: 'Home', href: '/' },
+ ],
+ network: [
+ { label: 'Home', href: '/' },
+ { label: 'My Account', href: '/my' },
+ { label: 'Themes', href: '/themes' },
+ ],
+ maps: [
+ { label: 'Map', href: '/' },
+ { label: 'Camps', href: '/camps' },
+ ],
+ food: [
+ { label: 'Food Banks', href: '/' },
+ { label: 'Meal Plan', href: '/#meal-plan' },
+ ],
+ img: [
+ { label: 'Convert', href: 'https://convert.openvibe.tools' },
+ { label: 'Compress', href: 'https://compress.openvibe.tools' },
+ { label: 'Resize', href: 'https://resize.openvibe.tools' },
+ { label: 'Crop', href: 'https://crop.openvibe.tools' },
+ ],
+ yt: [
+ { label: 'Download', href: '/' },
+ ],
+ audio: [
+ { label: 'Convert', href: 'https://audio.openvibe.tools' },
+ { label: 'Trim', href: 'https://trim.openvibe.tools' },
+ { label: 'Pitch', href: 'https://pitch.openvibe.tools' },
+ { label: 'Reverb', href: 'https://reverb.openvibe.tools' },
+ ],
+ text: [
+ { label: 'Fancy', href: 'https://fancy.openvibe.tools' },
+ { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },
+ { label: 'ASCII', href: 'https://ascii.openvibe.tools' },
+ { label: 'Symbols', href: 'https://symbols.openvibe.tools' },
+ ],
+ logo: [
+ { label: 'Title', href: 'https://title.openvibe.tools' },
+ { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },
+ { label: 'Badge', href: 'https://badge.openvibe.tools' },
+ { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },
+ ],
+ docs: [
+ { label: 'Merge', href: 'https://mergepdf.openvibe.tools' },
+ { label: 'Split', href: 'https://splitpdf.openvibe.tools' },
+ { label: 'Compress', href: 'https://compresspdf.openvibe.tools' },
+ { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' },
+ ],
+ net: [
+ { label: 'Lookup', href: 'https://lookup.openvibe.tools' },
+ { label: 'My IP', href: 'https://myip.openvibe.tools' },
+ { label: 'DNS', href: 'https://dns.openvibe.tools' },
+ { label: 'Ping', href: 'https://ping.openvibe.tools' },
+ { label: 'SSL', href: 'https://ssl.openvibe.tools' },
+ ],
+ dev: [
+ { label: 'JSON', href: 'https://json.openvibe.tools' },
+ { label: 'Base64', href: 'https://base64.openvibe.tools' },
+ { label: 'JWT', href: 'https://jwt.openvibe.tools' },
+ { label: 'Regex', href: 'https://regex.openvibe.tools' },
+ { label: 'Diff', href: 'https://diff.openvibe.tools' },
+ ],
+ };
+
+ function getAccounts() {
+ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
+ }
+
+ function escapeAttr(value) {
+ return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
+ }
+
+ function getAvatarInitial(user) {
+ const source = user?.display_name || user?.username || 'O';
+ return String(source).trim().charAt(0).toUpperCase() || 'O';
+ }
+
+ function makeAvatarPlaceholder(user, size = 64) {
+ const initial = getAvatarInitial(user);
+ const bg = user?.profile_color || '#8b5cf6';
+ const svg = `
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>
+ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>
+ </svg>`;
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
+ }
+
+ function avatarSrc(user, size = 64) {
+ return user?.avatar_url || makeAvatarPlaceholder(user, size);
+ }
+
+ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
+ const fallback = makeAvatarPlaceholder(user, size);
+ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
+ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
+ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
+ }
+
+ function attachAvatarFallbacks(rootEl) {
+ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
+ img.addEventListener('error', () => {
+ const fallback = img.dataset.fallbackSrc;
+ if (fallback && img.src !== fallback) {
+ img.src = fallback;
+ }
+ }, { once: true });
+ });
+ }
+
+ function render() {
+ if (_navEl) _navEl.remove();
+
+ const nav = document.createElement('nav');
+ nav.className = 'openvibe-navbar';
+ const svc = _config.service;
+ const links = SERVICE_LINKS[svc] || [];
+
+ // Resolve brand name + icon: config override > subdomain lookup > service defaults
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
+ let subBrand = SUBDOMAIN_BRANDS[host];
+ if (!subBrand && host && host.includes('.openvibe.tools')) {
+ // Try matching against service names
+ const parts = host.split('.');
+ const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
+ subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
+ }
+ const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
+ const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
+
+ const brandHref = '/';
+
+ const u = _config.user;
+ const accounts = getAccounts();
+ const isAnon = u && u.is_anon;
+ const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
+ const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+
+ nav.innerHTML = `
+ <a class="openvibe-navbar-brand" href="${brandHref}">
+ <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>
+ <div>
+ <div class="name">${svcName}</div>
+ </div>
+ </a>
+ <div class="openvibe-navbar-links">
+ ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}
+ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
+ </div>
+ <div class="openvibe-navbar-spacer"></div>
+ <div class="openvibe-navbar-right">
+ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>
+ <div id="openvibe-bell-mount"></div>
+ ${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
+ `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
+ </div>
+ `;
+
+ // Dropdown
+ if (u) {
+ const dropdown = document.createElement('div');
+ dropdown.className = 'openvibe-navbar-dropdown';
+ dropdown.id = 'openvibe-user-dropdown';
+
+ const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
+
+ dropdown.innerHTML = `
+ <div class="openvibe-navbar-dropdown-header">
+ ${avatarImg(u, 72, '', '')}
+ <div class="info">
+ <div class="name">${u.display_name || u.username}</div>
+ <div class="email">${u.email || `@${u.username}`}</div>
+ ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
+ </div>
+ </div>
+ <div class="openvibe-navbar-dropdown-accounts">
+ ${otherAccounts.map(a => `
+ <div class="account-item" data-account-id="${a.id}">
+ ${avatarImg(a, 48, '', '')}
+ <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>
+ </div>
+ `).join('')}
+ <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
+ <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>
+ <span>Switch to Anonymous</span>
+ </div>
+ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
+ <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>
+ <span>Add another account</span>
+ </a>
+ </div>
+ <div class="openvibe-navbar-dropdown-menu">
+ <a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
+ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>
+ <a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
+ <a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
+ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
+ <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>
+ <button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
+ </div>
+ `;
+ nav.appendChild(dropdown);
+
+ // Avatar click toggles dropdown
+ nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
+ dropdown.classList.toggle('open');
+ });
+
+ // Close on outside click
+ document.addEventListener('click', e => {
+ if (!nav.contains(e.target)) dropdown.classList.remove('open');
+ });
+
+ // Account switching
+ dropdown.querySelectorAll('[data-account-id]').forEach(el => {
+ el.addEventListener('click', () => {
+ const id = el.dataset.accountId;
+ document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));
+ dropdown.classList.remove('open');
+ });
+ });
+
+ dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {
+ dropdown.classList.remove('open');
+ if (_config.onLogout) _config.onLogout();
+ else {
+ document.cookie = 'ov_token=;path=/;max-age=0';
+ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
+ localStorage.removeItem('ov_token');
+ localStorage.removeItem('openvibe_anon_token');
+ localStorage.removeItem('openvibe_active_account');
+ window.location.reload();
+ }
+ });
+ } else {
+ nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {
+ if (!_config.onLogin) return;
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ _config.onLogin();
+ });
+ }
+
+ // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
+ const mount = document.getElementById('navbar-mount');
+ if (mount) {
+ mount.appendChild(nav);
+ } else {
+ document.body.prepend(nav);
+ }
+ _navEl = nav;
+ attachAvatarFallbacks(nav);
+ return nav;
+ }
+
+ const OpenVibeNavbar = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ return render();
+ },
+
+ /** Update user (after account switch). */
+ setUser(user) {
+ _config.user = user;
+ render();
+ },
+
+ setToken(token) { _config.token = token; },
+
+ /** Get the bell mount point for OpenVibeNotifications. */
+ getBellMount() {
+ return _navEl?.querySelector('#openvibe-bell-mount') || null;
+ },
+
+ getElement() { return _navEl; },
+
+ destroy() {
+ _navEl?.remove();
+ _navEl = null;
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
+ else root.OpenVibeNavbar = OpenVibeNavbar;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,539 @@
@@ -0,0 +1,539 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Notification UI Client
+// Drop-in vanilla JS library for toast notifications, bell badge,
+// notification panel, sounds, and real-time polling.
+// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
+// OpenVibeNotifications.init({ token, apiBase, onAction })
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const POLL_INTERVAL = 15_000; // 15s for unread count
+ const TOAST_DURATION = { low: 0, normal: 3800, high: 6000, critical: 0 }; // 0 = sticky
+ const MAX_TOASTS = 3;
+ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
+
+ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', onAction: null };
+ let _pollTimer = null;
+ let _lastCheck = null;
+ let _unreadCount = 0;
+ let _toastContainer = null;
+ let _panelEl = null;
+ let _bellEl = null;
+ let _audioCache = {};
+ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
+
+ // ── Styles (injected once) ───────────────────────────────
+ function injectStyles() {
+ if (document.getElementById('openvibe-notif-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'openvibe-notif-styles';
+ style.textContent = `
+ @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+ @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
+ @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
+ @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
+ @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
+ @keyframes openvibe-fade-in { from { opacity: 0; } to { opacity: 1; } }
+
+ .openvibe-toast-container {
+ position: fixed; top: 82px; right: 14px; z-index: 100000;
+ display: flex; flex-direction: column; gap: 6px;
+ pointer-events: none; max-width: 320px; width: 100%;
+ }
+ .openvibe-toast {
+ pointer-events: all;
+ background: color-mix(in srgb, var(--bg-card, #22222c) 92%, transparent);
+ border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent);
+ border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start;
+ box-shadow: 0 10px 24px rgba(0,0,0,0.28);
+ animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1);
+ cursor: pointer; position: relative; overflow: hidden;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ transition: border-color .2s;
+ }
+ .openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }
+ .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
+ .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
+ .openvibe-toast-body { flex: 1; min-width: 0; }
+ .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
+ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: rgba(192,150,92,0.15); color: var(--accent-light, #a78bfa); font-weight: 500; }
+ .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
+ .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
+ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); border-radius: 0 0 0 10px; }
+ .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
+ .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
+ .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
+ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; transition: all .15s; }
+ .openvibe-toast-actions button:hover { background: var(--accent-dark, #a07840); color: #fff; border-color: var(--accent-dark, #a07840); }
+ .openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
+ .openvibe-toast-user { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+ .openvibe-toast-user img { width: 24px; height: 24px; border-radius: 50%; }
+ .openvibe-toast-user .username { font-weight: 600; font-size: 12px; cursor: pointer; }
+
+ /* ── Bell ─────────────────────────────────────── */
+ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
+ .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
+ .openvibe-bell:hover svg { fill: var(--accent-light, #a78bfa); }
+ .openvibe-bell .badge {
+ position: absolute; top: 0; right: 0;
+ min-width: 16px; height: 16px; padding: 0 4px;
+ background: var(--live-red, #e74c3c); color: #fff;
+ border-radius: 8px; font-size: 10px; font-weight: 700;
+ display: flex; align-items: center; justify-content: center;
+ line-height: 1; pointer-events: none;
+ animation: openvibe-badge-pulse .4s ease;
+ }
+ .openvibe-bell .badge.hidden { display: none; }
+ .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
+
+ /* ── Panel ────────────────────────────────────── */
+ .openvibe-notif-panel {
+ position: fixed; top: 62px; right: 16px;
+ width: 380px; max-height: calc(100vh - 86px);
+ background: var(--bg-secondary, #252530);
+ border: 1px solid var(--border, #333340);
+ border-radius: 12px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ z-index: 99999; display: none; flex-direction: column;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ animation: openvibe-slide-down .2s ease;
+ }
+ .openvibe-notif-panel.open { display: flex; }
+ .openvibe-notif-panel-header {
+ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
+ display: flex; align-items: center; justify-content: space-between;
+ }
+ .openvibe-notif-panel-header h3 { font-size: 15px; font-weight: 600; margin: 0; }
+ .openvibe-notif-panel-header .actions { display: flex; gap: 8px; }
+ .openvibe-notif-panel-header .actions button { background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all .15s; }
+ .openvibe-notif-panel-header .actions button:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-notif-panel-tabs { display: flex; border-bottom: 1px solid var(--border, #333340); }
+ .openvibe-notif-panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #707080); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }
+ .openvibe-notif-panel-tabs button.active { color: var(--accent-light, #a78bfa); border-bottom-color: var(--accent, #8b5cf6); }
+ .openvibe-notif-panel-list { flex: 1; overflow-y: auto; padding: 4px 0; }
+ .openvibe-notif-item {
+ padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start;
+ cursor: pointer; transition: background .15s; border-left: 3px solid transparent;
+ }
+ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-notif-item.unread { background: rgba(192,150,92,0.04); border-left-color: var(--accent, #8b5cf6); }
+ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
+ .openvibe-notif-item .content { flex: 1; min-width: 0; }
+ .openvibe-notif-item .content .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
+ .openvibe-notif-item .content .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-notif-item .content .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; }
+ .openvibe-notif-item .content .meta .service-tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
+ .openvibe-notif-panel-empty { text-align: center; padding: 48px 24px; color: var(--text-muted, #707080); }
+ .openvibe-notif-panel-empty .icon { font-size: 36px; margin-bottom: 8px; display: block; opacity: .5; }
+
+ @media (max-width: 440px) {
+ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
+ .openvibe-notif-panel { right: 8px; left: 8px; width: auto; }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // ── Sound ────────────────────────────────────────────────
+ function playSound(priority) {
+ if (!_preferences.sound) return;
+ const file = SOUNDS[priority];
+ if (!file) return;
+ const url = `${_config.soundBase}/${file}`;
+ try {
+ if (!_audioCache[url]) _audioCache[url] = new Audio(url);
+ const audio = _audioCache[url];
+ audio.currentTime = 0;
+ audio.volume = priority === 'critical' ? 0.8 : 0.5;
+ audio.play().catch(() => {});
+ } catch {}
+ }
+
+ // ── Toast Container ──────────────────────────────────────
+ function ensureToastContainer() {
+ if (_toastContainer) return;
+ _toastContainer = document.createElement('div');
+ _toastContainer.className = 'openvibe-toast-container';
+ document.body.appendChild(_toastContainer);
+ }
+
+ function showToast(notification) {
+ if (!_preferences.toasts) return;
+ if (_preferences.muted_categories.includes(notification.category)) return;
+ ensureToastContainer();
+
+ // Limit visible toasts
+ while (_toastContainer.children.length >= MAX_TOASTS) {
+ const oldest = _toastContainer.firstChild;
+ oldest.remove();
+ }
+
+ const toast = document.createElement('div');
+ toast.className = `openvibe-toast priority-${notification.priority}`;
+ toast.dataset.notifId = notification.id;
+
+ const rich = notification.richContent || {};
+ let userHtml = '';
+ if (rich.user) {
+ const u = rich.user;
+ const nameStyle = u.profile_color ? `color:${u.profile_color}` : '';
+ const nameClass = u.name_effect ? `openvibe-name-fx-${u.name_effect}` : '';
+ userHtml = `<div class="openvibe-toast-user">
+ ${u.avatar_url ? `<img src="${u.avatar_url}" alt="">` : ''}
+ <span class="username ${nameClass}" style="${nameStyle}">${u.display_name || u.username}</span>
+ </div>`;
+ }
+
+ let actionsHtml = '';
+ if (rich.actions && rich.actions.length) {
+ actionsHtml = '<div class="openvibe-toast-actions">' +
+ rich.actions.filter(a => a.type !== 'input').map(a =>
+ `<button data-action="${a.id}" class="${a.style === 'primary' ? 'primary' : ''}">${a.label}</button>`
+ ).join('') + '</div>';
+ }
+
+ const serviceLabel = notification.service && notification.service !== 'network'
+ ? `<span class="service-badge">${notification.service}</span>` : '';
+
+ toast.innerHTML = `
+ <div class="openvibe-toast-icon">${notification.icon || '🔔'}</div>
+ <div class="openvibe-toast-body">
+ ${userHtml}
+ <div class="openvibe-toast-title">${notification.title}${serviceLabel}</div>
+ <div class="openvibe-toast-msg">${notification.message}</div>
+ ${actionsHtml}
+ </div>
+ <button class="openvibe-toast-close">×</button>
+ `;
+
+ // Progress bar for auto-dismiss
+ const duration = TOAST_DURATION[notification.priority] || 5000;
+ if (duration > 0) {
+ const bar = document.createElement('div');
+ bar.className = 'openvibe-toast-progress';
+ bar.style.width = '100%';
+ bar.style.transition = `width ${duration}ms linear`;
+ toast.appendChild(bar);
+ requestAnimationFrame(() => { bar.style.width = '0%'; });
+ }
+
+ // Events
+ toast.querySelector('.openvibe-toast-close').addEventListener('click', e => {
+ e.stopPropagation();
+ dismissToast(toast);
+ });
+
+ toast.addEventListener('click', () => {
+ if (rich.url) window.open(rich.url, '_blank');
+ markRead(notification.id);
+ dismissToast(toast);
+ });
+
+ // Action buttons
+ toast.querySelectorAll('[data-action]').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ const actionId = btn.dataset.action;
+ const action = rich.actions.find(a => a.id === actionId);
+ if (action?.url) { window.open(action.url, '_blank'); return; }
+ if (_config.onAction) _config.onAction(notification.id, actionId, notification);
+ dismissToast(toast);
+ });
+ });
+
+ _toastContainer.appendChild(toast);
+
+ // Auto dismiss
+ if (duration > 0) {
+ setTimeout(() => dismissToast(toast), duration);
+ }
+ }
+
+ function dismissToast(el) {
+ if (!el || !el.parentNode) return;
+ el.classList.add('removing');
+ setTimeout(() => el.remove(), 300);
+ }
+
+ // ── Bell / Badge ─────────────────────────────────────────
+ function createBell() {
+ const bell = document.createElement('div');
+ bell.className = 'openvibe-bell';
+ bell.innerHTML = `
+ <svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
+ <span class="badge hidden">0</span>
+ `;
+ bell.addEventListener('click', () => togglePanel());
+ _bellEl = bell;
+ return bell;
+ }
+
+ function updateBadge(count) {
+ _unreadCount = count;
+ if (!_bellEl) return;
+ const badge = _bellEl.querySelector('.badge');
+ if (count > 0) {
+ badge.textContent = count > 99 ? '99+' : count;
+ badge.classList.remove('hidden');
+ _bellEl.classList.add('ringing');
+ setTimeout(() => _bellEl.classList.remove('ringing'), 600);
+ } else {
+ badge.classList.add('hidden');
+ }
+ }
+
+ // ── Panel ────────────────────────────────────────────────
+ function createPanel() {
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-notif-panel';
+ panel.innerHTML = `
+ <div class="openvibe-notif-panel-header">
+ <h3>Notifications</h3>
+ <div class="actions">
+ <button data-action="mark-all-read">Mark all read</button>
+ <button data-action="settings">⚙️</button>
+ </div>
+ </div>
+ <div class="openvibe-notif-panel-tabs">
+ <button class="active" data-tab="all">All</button>
+ <button data-tab="unread">Unread</button>
+ <button data-tab="social">Social</button>
+ <button data-tab="game">Game</button>
+ <button data-tab="system">System</button>
+ </div>
+ <div class="openvibe-notif-panel-list"></div>
+ `;
+
+ panel.querySelector('[data-action="mark-all-read"]').addEventListener('click', markAllRead);
+ panel.querySelectorAll('[data-tab]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ panel.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ loadNotifications(btn.dataset.tab);
+ });
+ });
+
+ document.addEventListener('click', e => {
+ if (_panelEl?.classList.contains('open') && !_panelEl.contains(e.target) && !_bellEl?.contains(e.target)) {
+ _panelEl.classList.remove('open');
+ }
+ });
+
+ _panelEl = panel;
+ document.body.appendChild(panel);
+ return panel;
+ }
+
+ function togglePanel() {
+ if (!_panelEl) createPanel();
+ const opening = !_panelEl.classList.contains('open');
+ _panelEl.classList.toggle('open');
+ if (opening) loadNotifications('all');
+ }
+
+ async function loadNotifications(tab = 'all') {
+ if (!_panelEl) return;
+ const list = _panelEl.querySelector('.openvibe-notif-panel-list');
+ list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-muted)">Loading...</div>';
+
+ try {
+ const params = new URLSearchParams({ limit: '50' });
+ if (tab === 'unread') params.set('unread', '1');
+ else if (tab !== 'all') params.set('category', tab);
+
+ const res = await apiFetch(`/api/notifications?${params}`);
+ const data = await res.json();
+
+ if (!data.notifications || data.notifications.length === 0) {
+ list.innerHTML = `<div class="openvibe-notif-panel-empty"><span class="icon">🔔</span>No notifications yet</div>`;
+ return;
+ }
+
+ list.innerHTML = data.notifications.map(n => {
+ // Render each item in isolation — one malformed notification must
+ // never blank the whole dropdown ("Failed to load").
+ try {
+ const ago = timeAgo(n.created_at);
+ const serviceTag = n.service && n.service !== 'network'
+ ? `<span class="service-tag">${esc(n.service)}</span>` : '';
+ return `<div class="openvibe-notif-item ${n.read ? '' : 'unread'}" data-id="${esc(n.id)}">
+ <span class="icon">${esc(n.icon || '🔔')}</span>
+ <div class="content">
+ <div class="title">${esc(n.title)}</div>
+ <div class="msg">${esc(n.message)}</div>
+ <div class="meta"><span>${esc(ago)}</span>${serviceTag}</div>
+ </div>
+ </div>`;
+ } catch { return ''; }
+ }).join('');
+
+ list.querySelectorAll('.openvibe-notif-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const id = item.dataset.id;
+ markRead(id);
+ item.classList.remove('unread');
+ const notif = data.notifications.find(n => String(n.id) === id);
+ const rich = richOf(notif);
+ if (rich.url) window.open(rich.url, '_blank');
+ });
+ });
+ } catch {
+ list.innerHTML = '<div class="openvibe-notif-panel-empty"><span class="icon">⚠️</span>Failed to load</div>';
+ }
+ }
+
+ // ── API Helpers ──────────────────────────────────────────
+ function isCrossOrigin() {
+ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
+ }
+
+ function apiFetch(path, opts = {}) {
+ const headers = { ...opts.headers };
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ const crossOrigin = isCrossOrigin();
+ return fetch(`${_config.apiBase}${path}`, {
+ ...opts,
+ headers,
+ // Only send credentials for same-origin requests; cross-origin uses Authorization header
+ credentials: crossOrigin ? 'omit' : 'include',
+ });
+ }
+
+ async function markRead(id) {
+ try { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch {}
+ _unreadCount = Math.max(0, _unreadCount - 1);
+ updateBadge(_unreadCount);
+ }
+
+ async function markAllRead() {
+ try { await apiFetch('/api/notifications/read-all', { method: 'POST' }); } catch {}
+ updateBadge(0);
+ if (_panelEl?.classList.contains('open')) {
+ _panelEl.querySelectorAll('.openvibe-notif-item.unread').forEach(el => el.classList.remove('unread'));
+ }
+ }
+
+ async function pollUnread() {
+ try {
+ const res = await apiFetch('/api/notifications/unread-count');
+ const data = await res.json();
+ const prev = _unreadCount;
+ updateBadge(data.count || 0);
+
+ // If new notifications arrived, fetch and toast the latest
+ if (data.count > prev && _preferences.toasts) {
+ const newest = await apiFetch(`/api/notifications?unread=1&limit=${data.count - prev}&since=${_lastCheck || ''}`);
+ const nd = await newest.json();
+ if (nd.notifications) {
+ for (const n of nd.notifications.slice(0, 3)) {
+ const rich = n.rich_content ? JSON.parse(n.rich_content) : {};
+ showToast({ ...n, richContent: rich });
+ playSound(n.priority);
+ }
+ }
+ }
+ _lastCheck = new Date().toISOString();
+ } catch {}
+ }
+
+ // ── Helpers ──────────────────────────────────────────────
+ // Escape untrusted text before inserting into innerHTML. Notification
+ // titles/messages can be attacker-controlled (e.g. abusive broadcasts), so
+ // they must never be treated as HTML.
+ function esc(v) {
+ if (v == null) return '';
+ return String(v)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+ // rich_content arrives already parsed from the API, but tolerate a raw JSON
+ // string too. Never throw — a bad value just yields {}.
+ function richOf(n) {
+ const rc = n && n.rich_content;
+ if (!rc) return {};
+ if (typeof rc === 'object') return rc;
+ try { return JSON.parse(rc) || {}; } catch { return {}; }
+ }
+
+ // ── Time Formatting ──────────────────────────────────────
+ function timeAgo(dateStr) {
+ const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
+ if (diff < 60) return 'just now';
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
+ return new Date(dateStr).toLocaleDateString();
+ }
+
+ // ── Public API ───────────────────────────────────────────
+ const OpenVibeNotifications = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ if (opts.preferences) Object.assign(_preferences, opts.preferences);
+ _lastCheck = new Date().toISOString();
+
+ // Start polling
+ pollUnread();
+ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
+ },
+
+ destroy() {
+ clearInterval(_pollTimer);
+ _toastContainer?.remove();
+ _panelEl?.remove();
+ _toastContainer = null;
+ _panelEl = null;
+ },
+
+ /** Create and inject the bell icon. Returns the DOM element. */
+ createBell(container) {
+ const bell = createBell();
+ if (container) container.appendChild(bell);
+ pollUnread();
+ return bell;
+ },
+
+ /** Programmatically push a toast (for real-time WebSocket events). */
+ push(notification) {
+ showToast(notification);
+ playSound(notification.priority);
+ _unreadCount++;
+ updateBadge(_unreadCount);
+ },
+
+ /** Update preferences at runtime. */
+ setPreferences(prefs) {
+ Object.assign(_preferences, prefs);
+ },
+
+ /** Update auth token (e.g., after account switch). */
+ setToken(token) {
+ _config.token = token;
+ if (token) {
+ pollUnread();
+ if (!_pollTimer) _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
+ } else {
+ clearInterval(_pollTimer);
+ _pollTimer = null;
+ updateBadge(0);
+ }
+ },
+
+ /** Open/close the notification panel. */
+ togglePanel,
+
+ /** Get current unread count. */
+ get unreadCount() { return _unreadCount; },
+ };
+
+ // Export
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
+ else root.OpenVibeNotifications = OpenVibeNotifications;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Universal Notification System
+// Types, priorities, categories, and helpers shared across all
+// services. Both server-side (Node) and client-side (browser).
+// ═══════════════════════════════════════════════════════════════
+
+// ── Priority Levels ──────────────────────────────────────────
+// Determines toast style, sound, persistence, and email eligibility.
+const PRIORITY = Object.freeze({
+ LOW: 'low', // Silent, no toast, inbox only
+ NORMAL: 'normal', // Toast + badge, default sound
+ HIGH: 'high', // Sticky toast, alert sound, badge pulse
+ CRITICAL: 'critical', // Sticky + email-eligible, alarm sound, cannot dismiss silently
+});
+
+// ── Categories ───────────────────────────────────────────────
+// Users can configure per-category mute/sound/email preferences.
+const CATEGORY = Object.freeze({
+ SOCIAL: 'social', // Follows, friend requests, mentions, kisses
+ CHAT: 'chat', // DMs, @mentions, whispers
+ GAME: 'game', // Level-ups, loot, dungeon invites, canvas events
+ STREAM: 'stream', // Stream went live, raid, host, clip created
+ ECONOMY: 'economy', // Coins received, shop sales, trade offers
+ ACHIEVEMENT: 'achievement', // Badges, milestones, unlocks
+ MODERATION: 'moderation', // Warnings, bans, mutes, reports
+ SYSTEM: 'system', // Password changed, new login, security alerts
+ SERVICE: 'service', // Cross-service: linked accounts, SSO events
+ ADMIN: 'admin', // Admin broadcasts, maintenance notices
+});
+
+// ── Notification Types (expandable per-service) ──────────────
+// Each type maps to a category + default priority + template.
+const TYPES = Object.freeze({
+ // Social
+ FOLLOW: { category: 'social', priority: 'normal', icon: '👤', title: 'New Follower' },
+ MENTION: { category: 'social', priority: 'normal', icon: '@', title: 'Mentioned You' },
+ FRIEND_REQUEST: { category: 'social', priority: 'normal', icon: '🤝', title: 'Friend Request' },
+
+ // Chat
+ DIRECT_MESSAGE: { category: 'chat', priority: 'normal', icon: '💬', title: 'New Message' },
+ WHISPER: { category: 'chat', priority: 'normal', icon: '🔇', title: 'Whisper' },
+
+ // Game
+ LEVEL_UP: { category: 'game', priority: 'normal', icon: '⬆️', title: 'Level Up!' },
+ LOOT_DROP: { category: 'game', priority: 'normal', icon: '🎁', title: 'Loot Drop' },
+ DUNGEON_INVITE: { category: 'game', priority: 'high', icon: '🏰', title: 'Dungeon Invite' },
+ BATTLE_CHALLENGE: { category: 'game', priority: 'high', icon: '⚔️', title: 'Battle Challenge' },
+ CANVAS_OVERWRITE: { category: 'game', priority: 'low', icon: '🎨', title: 'Pixel Overwritten' },
+ ACHIEVEMENT_UNLOCK: { category: 'achievement', priority: 'normal', icon: '🏆', title: 'Achievement Unlocked' },
+
+ // Stream
+ STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },
+ RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },
+ CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },
+
+ // Economy
+ COINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },
+ TRADE_OFFER: { category: 'economy', priority: 'normal', icon: '🤝', title: 'Trade Offer' },
+
+ // Moderation
+ WARNING: { category: 'moderation', priority: 'high', icon: '⚠️', title: 'Warning' },
+ TIMEOUT: { category: 'moderation', priority: 'high', icon: '⏰', title: 'Timeout' },
+ BAN: { category: 'moderation', priority: 'critical', icon: '🚫', title: 'Account Banned' },
+
+ // System / Security
+ PASSWORD_CHANGED: { category: 'system', priority: 'critical', icon: '🔒', title: 'Password Changed' },
+ NEW_LOGIN: { category: 'system', priority: 'high', icon: '🔐', title: 'New Login Detected' },
+ EMAIL_VERIFIED: { category: 'system', priority: 'normal', icon: '✅', title: 'Email Verified' },
+ ACCOUNT_LINKED: { category: 'system', priority: 'normal', icon: '🔗', title: 'Account Linked' },
+
+ // Service / Cross-platform
+ WELCOME: { category: 'service', priority: 'normal', icon: '🔥', title: 'Welcome to OpenVibe' },
+ SERVICE_ANNOUNCEMENT:{ category: 'admin', priority: 'high', icon: '📢', title: 'Announcement' },
+ MAINTENANCE: { category: 'admin', priority: 'critical', icon: '🛠️', title: 'Scheduled Maintenance' },
+});
+
+// ── Sound Map ────────────────────────────────────────────────
+// Priority → sound file (relative to /assets/sounds/ on each service).
+const SOUNDS = Object.freeze({
+ low: null,
+ normal: 'notification.mp3',
+ high: 'notification-high.mp3',
+ critical: 'notification-alarm.mp3',
+});
+
+// ── Email Eligibility ────────────────────────────────────────
+// Categories that are email-worthy by default when the user has not
+// explicitly enabled email for a category-specific preference.
+const EMAIL_ELIGIBLE_CATEGORIES = new Set([
+ 'system', 'moderation', 'admin',
+]);
+
+function isEmailEligible(notification) {
+ return notification.priority === PRIORITY.CRITICAL
+ && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
+}
+
+// ── Rich Content Schema ──────────────────────────────────────
+// Notifications can carry optional rich payloads.
+//
+// richContent: {
+// image?: string, // URL to preview image
+// thumbnail?: string, // Small avatar/icon URL
+// body?: string, // Markdown-safe extended body
+// url?: string, // Click-through URL
+// service?: string, // Origin service ID
+// actions?: [ // Interactive buttons/inputs
+// { id: string, label: string, style: 'primary'|'secondary'|'danger', url?: string },
+// { id: string, type: 'input', placeholder: string, submitLabel: string },
+// ],
+// user?: { // Linked user display info
+// id: number, username: string, display_name: string,
+// avatar_url: string, profile_color: string, role: string,
+// name_effect?: string, particle_effect?: string,
+// },
+// context?: object, // Arbitrary service-specific data
+// }
+
+// ── Notification Object Shape ────────────────────────────────
+// {
+// id: string, // UUID
+// type: string, // Key from TYPES
+// category: string, // From CATEGORY
+// priority: string, // From PRIORITY
+// title: string, // Short headline
+// message: string, // Plain-text body
+// icon: string, // Emoji or icon class
+// user_id: number, // Recipient
+// sender_id?: number, // Who triggered it (nullable)
+// service: string, // Origin service ('live', 'games', 'network')
+// richContent?: object, // See schema above
+// read: boolean,
+// dismissed: boolean,
+// emailed: boolean,
+// created_at: string, // ISO 8601
+// expires_at?: string, // Optional auto-expire
+// }
+
+function createNotification({ type, message, userId, senderId, service, richContent, expiresAt }) {
+ const typeDef = TYPES[type];
+ if (!typeDef) throw new Error(`Unknown notification type: ${type}`);
+
+ return {
+ type,
+ category: typeDef.category,
+ priority: typeDef.priority,
+ title: typeDef.title,
+ icon: typeDef.icon,
+ message: message || '',
+ user_id: userId,
+ sender_id: senderId || null,
+ service: service || 'network',
+ richContent: richContent || null,
+ read: false,
+ dismissed: false,
+ emailed: false,
+ expires_at: expiresAt || null,
+ };
+}
+
+// ── User Preferences Defaults ────────────────────────────────
+const DEFAULT_NOTIFICATION_PREFS = Object.freeze({
+ enabled: true,
+ sound: true,
+ toasts: true,
+ email_critical: true, // Email for CRITICAL only
+ muted_categories: [], // Categories the user has silenced
+ do_not_disturb: false,
+ dnd_schedule: null, // { start: '22:00', end: '08:00', timezone: 'America/Los_Angeles' }
+});
+
+module.exports = {
+ PRIORITY,
+ CATEGORY,
+ TYPES,
+ SOUNDS,
+ EMAIL_ELIGIBLE_CATEGORIES,
+ isEmailEligible,
+ createNotification,
+ DEFAULT_NOTIFICATION_PREFS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "name": "openvibe-shared",
+ "version": "1.0.0",
+ "description": "Shared auth, theme, and brand utilities for the OpenVibe",
+ "main": "index.js",
+ "exports": {
+ ".": "./index.js",
+ "./analytics": "./analytics.js",
+ "./auth-client": "./auth-client.js",
+ "./theme-sync": "./theme-sync.js",
+ "./brand": "./brand.js",
+ "./middleware": "./middleware.js",
+ "./notifications": "./notifications.js",
+ "./url-resolver": "./url-resolver.js"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+const assert = require('assert');
+const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
+
+// ── Test: Deploy infrastructure keys exist ───────────────────
+const deployKeys = [
+ 'DEPLOY_ACME_EMAIL',
+ 'DEPLOY_CERT_MODE',
+ 'DEPLOY_CLOUDFLARE_TOKEN',
+ 'DEPLOY_DOMAINS',
+ 'DEPLOY_NGINX_MODE',
+ 'DEPLOY_NGINX_SITES_PATH',
+ 'DEPLOY_NGINX_BACKUP_PATH',
+ 'DEPLOY_SERVICE_MAP',
+];
+
+for (const key of deployKeys) {
+ assert.ok(URL_DEFINITIONS[key], `Missing deploy key: ${key}`);
+ assert.strictEqual(URL_DEFINITIONS[key].key, key);
+ assert.ok(URL_DEFINITIONS[key].category.startsWith('deploy_'), `Deploy key ${key} should have deploy_ category`);
+}
+
+// ── Test: Deploy key defaults ────────────────────────────────
+assert.strictEqual(URL_DEFINITIONS.DEPLOY_CERT_MODE.default, 'manual');
+assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_MODE.default, 'preview');
+assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_SITES_PATH.default, '/etc/nginx/sites-enabled');
+assert.strictEqual(URL_DEFINITIONS.DEPLOY_CLOUDFLARE_TOKEN.type, 'secret');
+
+// ── Test: Secret type normalization ──────────────────────────
+assert.strictEqual(normalizeValue(' my-secret-token ', 'secret'), 'my-secret-token');
+assert.strictEqual(normalizeValue(null, 'secret'), null);
+assert.strictEqual(normalizeValue(undefined, 'secret'), null);
+
+// ── Test: Deploy config resolves through registry chain ──────
+const env = {};
+const bootstrap = {
+ DEPLOY_ACME_EMAIL: '[email protected]',
+ DEPLOY_CERT_MODE: 'cloudflare',
+};
+const overrides = {
+ DEPLOY_CERT_MODE: 'manual',
+};
+
+const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
+
+assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
+assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.source, 'bootstrap');
+assert.strictEqual(resolved.DEPLOY_CERT_MODE.value, 'manual');
+assert.strictEqual(resolved.DEPLOY_CERT_MODE.source, 'admin');
+assert.strictEqual(resolved.DEPLOY_NGINX_MODE.value, 'preview');
+assert.strictEqual(resolved.DEPLOY_NGINX_MODE.source, 'default');
+
+// ── Test: DEPLOY_DOMAINS as JSON array ───────────────────────
+const domainsResolved = resolveRegistryValues({}, {
+ DEPLOY_DOMAINS: JSON.stringify([
+ { domain: 'openvibe.tools', wildcard: true, certName: 'openvibe.tools', services: ['network'] },
+ { domain: 'openvibe.live', wildcard: true, certName: 'openvibe.live-0001', services: ['live'] },
+ ]),
+}, {}, URL_DEFINITIONS);
+
+assert.ok(Array.isArray(domainsResolved.DEPLOY_DOMAINS.value));
+assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value.length, 2);
+assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value[0].domain, 'openvibe.tools');
+
+// ── Test: DEPLOY_SERVICE_MAP as JSON map ─────────────────────
+const svcMapResolved = resolveRegistryValues({}, {
+ DEPLOY_SERVICE_MAP: JSON.stringify({ custom: { port: 9000, domains: ['custom.example.com'] } }),
+}, {}, URL_DEFINITIONS);
+
+assert.ok(typeof svcMapResolved.DEPLOY_SERVICE_MAP.value === 'object');
+assert.strictEqual(svcMapResolved.DEPLOY_SERVICE_MAP.value.custom.port, 9000);
+
+// ── Test: White-label defaults still work ────────────────────
+const whitelabelResolved = resolveRegistryValues({}, {
+ NETWORK_NAME: 'CoolNet',
+ TOOLS_SERVICE_NAME: 'CoolTools',
+ DEPLOY_ACME_EMAIL: '[email protected]',
+ DEPLOY_CERT_MODE: 'cloudflare',
+}, {}, URL_DEFINITIONS);
+
+assert.strictEqual(whitelabelResolved.NETWORK_NAME.value, 'CoolNet');
+assert.strictEqual(whitelabelResolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
+assert.strictEqual(whitelabelResolved.DEPLOY_CERT_MODE.value, 'cloudflare');
+
+console.log('✅ url-resolver deploy config tests passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+const assert = require('assert');
+const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
+
+const env = {
+ OV_LIVE_URL: 'https://env.example.com',
+ WEBRTC_PUBLIC_URL: 'https://webrtc.env.example.com',
+ OV_NETWORK_URL: 'https://network.env.example.com',
+};
+
+const bootstrap = {
+ OV_LIVE_URL: 'https://bootstrap.example.com',
+ WHIP_PUBLIC_URL: 'https://bootstrap.whip.example.com',
+};
+
+const overrides = {
+ OV_LIVE_URL: 'https://admin.example.com',
+};
+
+const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
+assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://admin.example.com');
+assert.strictEqual(resolved.OV_LIVE_URL.source, 'admin');
+assert.strictEqual(resolved.WHIP_PUBLIC_URL.value, 'https://bootstrap.whip.example.com');
+assert.strictEqual(resolved.WHIP_PUBLIC_URL.source, 'bootstrap');
+assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.value, 'https://webrtc.env.example.com');
+assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.source, 'env');
+assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://network.env.example.com');
+assert.strictEqual(resolved.OV_NETWORK_URL.source, 'env');
+assert.strictEqual(resolved.OV_GAMES_URL.value, 'https://openvibe.games');
+assert.strictEqual(resolved.OV_MEDIA_URL.value, 'https://openvibe.media');
+assert.strictEqual(resolved.JSMPEG_PUBLIC_URL.source, 'default');
+
+assert.strictEqual(normalizeValue('turn:turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
+assert.strictEqual(normalizeValue('turn://turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
+assert.strictEqual(normalizeValue('turns://turn.example.com:5349', 'turn_url'), 'turns:turn.example.com:5349');
+assert.strictEqual(normalizeValue('turn://user:[email protected]:3478', 'turn_url'), null);
+assert.strictEqual(normalizeValue('turn://invalid host', 'turn_url'), null);
+
+console.log('✅ openvibe-shared url-resolver precedence test passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Theme Loader (Browser)
+// Synchronous theme application from cookie/localStorage.
+// Load this BEFORE navbar.js to prevent FOUC.
+// Sets CSS custom properties on <html> from the user's theme.
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const COOKIE_NAME = 'ov_theme_id';
+ const STORAGE_KEY = 'ov_theme';
+ // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,
+ // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —
+ // that's what makes the theme chosen at openvibe.network/themes sync everywhere.
+ // Apps may override via init({apiBase}).
+ const DEFAULT_API_BASE = 'https://openvibe.network';
+ let _config = { apiBase: DEFAULT_API_BASE };
+
+ // ── Condensed built-in theme variable maps ───────────────
+ // Kept in sync with packages/openvibe-shared/builtin-themes.js
+ const THEMES = {
+ 'vibe': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#666','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)' },
+ 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#64748b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155' },
+ 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#5a7a5a','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a' },
+ 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#6a5a80','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a' },
+ 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#6272a4','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--success':'#50fa7b','--danger':'#ff5555','--warning':'#f1fa8c','--info':'#8be9fd','--live-red':'#ff5555' },
+ 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#75715e','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--danger':'#f92672','--warning':'#e6db74','--info':'#66d9ef' },
+ 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#4a7080','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050' },
+ 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#7a5a50','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030' },
+ 'arctic': { '--bg-primary':'#0c1220','--bg-secondary':'#101828','--bg-tertiary':'#182030','--bg-card':'#141c2a','--bg-hover':'#1e2840','--bg-input':'#0e1422','--text-primary':'#e0eaf5','--text-secondary':'#8aa0c0','--text-muted':'#5a708a','--accent':'#38bdf8','--accent-light':'#7dd3fc','--accent-dark':'#0284c7','--border':'#1e3050','--border-light':'#2a4060' },
+ 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#6a4a50','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30' },
+ 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#7050a0','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--info':'#00e5ff','--success':'#69f0ae' },
+ 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#606470','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48' },
+ 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#005a18','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014' },
+ 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#7b88a1','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--success':'#a3be8c','--danger':'#bf616a','--warning':'#ebcb8b','--info':'#81a1c1' },
+ 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#928374','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--success':'#b8bb26','--danger':'#fb4934','--warning':'#fe8019','--info':'#83a598' },
+ 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#444444','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525' },
+ 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#7a6a54','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020' },
+ 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#7a5a7a','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40' },
+ 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#664700','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200' },
+ 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#9a9ab0','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)' },
+ 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#9a8e7e','--accent':'#8b6914','--accent-light':'#b08a30','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)' },
+ 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#8a90aa','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)' },
+ 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#7a9a6e','--accent':'#16a34a','--accent-light':'#22c55e','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)' },
+ 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#aa8070','--accent':'#f97316','--accent-light':'#fb923c','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)' },
+ 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#9080aa','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)' },
+ 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#928374','--accent':'#d65d0e','--accent-light':'#fe8019','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--success':'#98971a','--danger':'#cc241d','--warning':'#d79921','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)' },
+ 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#94a3b8','--accent':'#0ea5e9','--accent-light':'#38bdf8','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)' },
+ 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#9a8e7e','--accent':'#b8860b','--accent-light':'#daa520','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)' },
+ 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#aa7090','--accent':'#e11d48','--accent-light':'#f43f5e','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)' },
+ 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#93a1a1','--text-muted':'#586e75','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--success':'#859900','--danger':'#dc322f','--warning':'#cb4b16','--info':'#268bd2' },
+ 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#586e75','--text-muted':'#93a1a1','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--success':'#859900','--danger':'#dc322f','--warning':'#cb4b16','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)' },
+ 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#6c7086','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--success':'#a6e3a1','--danger':'#f38ba8','--warning':'#fab387','--info':'#89b4fa','--live-red':'#f38ba8' },
+ 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#9ca0b0','--accent':'#8839ef','--accent-light':'#a05cff','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--success':'#40a02b','--danger':'#d20f39','--warning':'#fe640b','--info':'#1e66f5','--live-red':'#d20f39','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)' },
+ 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff' },
+ };
+
+ function getCookie(name) {
+ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
+ return match ? decodeURIComponent(match[1]) : null;
+ }
+
+ function setCookie(name, value, days) {
+ const maxAge = days * 24 * 60 * 60;
+ const encoded = encodeURIComponent(value);
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY
+ // when actually running on openvibe.tools or one of its subdomains, so the
+ // theme follows the user across the tool satellites. Everywhere else
+ // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the
+ // cookie is host-only — NO Domain attribute.
+ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
+ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
+ } else {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
+ }
+ }
+
+ /**
+ * Apply a theme's CSS variables to document.documentElement.
+ * If themeId is not found in the built-in map, does nothing.
+ */
+ function applyById(themeId) {
+ const vars = THEMES[themeId];
+ if (!vars) return false;
+ const el = document.documentElement;
+ for (const [prop, val] of Object.entries(vars)) {
+ el.style.setProperty(prop, val);
+ }
+ return true;
+ }
+
+ /**
+ * Apply arbitrary CSS variables object to <html>.
+ */
+ function applyVars(vars) {
+ if (!vars || typeof vars !== 'object') return;
+ const el = document.documentElement;
+ for (const [prop, val] of Object.entries(vars)) {
+ if (prop.startsWith('--')) el.style.setProperty(prop, val);
+ }
+ }
+
+ /**
+ * Resolve the active theme from (in order):
+ * 1. localStorage 'ov_theme' (has id + variables — instant)
+ * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)
+ * Falls back to 'vibe'.
+ */
+ function resolveAndApply() {
+ // 1. Try localStorage (same-origin cache with full variables)
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const saved = JSON.parse(raw);
+ if (saved && saved.variables && typeof saved.variables === 'object') {
+ applyVars(saved.variables);
+ return saved.id || saved.slug || 'vibe';
+ }
+ if (saved && (saved.id || saved.slug)) {
+ const id = saved.id || saved.slug;
+ if (applyById(id)) return id;
+ }
+ }
+ } catch { /* corrupt localStorage */ }
+
+ // 2. Try cross-domain cookie
+ const cookieId = getCookie(COOKIE_NAME);
+ if (cookieId && applyById(cookieId)) {
+ // Cache in localStorage for faster next load
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));
+ } catch { /* quota */ }
+ return cookieId;
+ }
+
+ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)
+ applyById('vibe');
+ return 'vibe';
+ }
+
+ /**
+ * Save theme choice both locally and cross-domain.
+ * Call this when the user picks a theme.
+ */
+ function save(themeId, variables) {
+ const vars = variables || THEMES[themeId];
+ if (!vars) return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));
+ } catch { /* quota */ }
+ setCookie(COOKIE_NAME, themeId, 365);
+ }
+
+ /**
+ * Get the current theme ID (from cookie or localStorage).
+ */
+ function getCurrent() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const saved = JSON.parse(raw);
+ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;
+ }
+ } catch {}
+ return getCookie(COOKIE_NAME) || 'vibe';
+ }
+
+ // ── Auto-apply on load (synchronous, prevents FOUC) ─────
+ const activeId = resolveAndApply();
+
+ // ── If user is logged in, try to sync from server ────────
+ // This runs async after page paint to pick up server-side changes
+ if (typeof fetch !== 'undefined') {
+ setTimeout(function () {
+ const token = getCookie('ov_token') ||
+ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));
+ if (!token) return;
+ fetch(_config.apiBase + '/api/themes/me/active', {
+ headers: { 'Authorization': 'Bearer ' + token },
+ credentials: 'include',
+ }).then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ if (!data || !data.theme_id) return;
+ const serverId = data.theme_id;
+ const custom = data.custom_variables;
+ if (custom && typeof custom === 'object' && Object.keys(custom).length) {
+ // Custom theme: apply the built-in base (if any) then the user's overrides.
+ const base = THEMES[serverId] || {};
+ applyVars(base);
+ applyVars(custom);
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }
+ setCookie(COOKIE_NAME, serverId, 365);
+ } else if (serverId !== getCurrent()) {
+ if (applyById(serverId)) save(serverId, THEMES[serverId]);
+ }
+ }).catch(function () { /* offline / CORS */ });
+ }, 500);
+ }
+
+ // ── Public API ───────────────────────────────────────────
+ const OpenVibeThemeLoader = {
+ THEMES: THEMES,
+ apply: applyById,
+ applyVars: applyVars,
+ save: save,
+ getCurrent: getCurrent,
+ resolveAndApply: resolveAndApply,
+ init: function (opts) {
+ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
+ _config.apiBase = opts.apiBase.trim();
+ }
+ return OpenVibeThemeLoader;
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
+ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+
+const { BUILTIN_THEMES, DEFAULT_VARS } = require('./builtin-themes');
+
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Theme Sync Engine
+// Isomorphic theme system shared across all OpenVibe services.
+// Works in both Node.js (SSR) and browser environments.
+// ═══════════════════════════════════════════════════════════════
+
+const CSS_VARIABLES = [
+ '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card',
+ '--bg-hover', '--bg-input',
+ '--text-primary', '--text-secondary', '--text-muted',
+ '--accent', '--accent-light', '--accent-dark',
+ '--live-red', '--success', '--warning', '--danger', '--info',
+ '--border', '--border-light', '--shadow', '--shadow-lg',
+];
+
+const NORMALIZED_DEFAULT_VARS = Object.freeze({ ...DEFAULT_VARS });
+
+// ── Dangerous CSS patterns (XSS prevention) ─────────────────
+const DANGEROUS_PATTERNS = [
+ /url\s*\(/i, /expression\s*\(/i, /javascript:/i,
+ /@import/i, /behavior\s*:/i, /var\s*\(/i, /-moz-binding/i,
+];
+
+function sanitizeCssValue(value) {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ if (trimmed.length > 200) return null;
+ for (const pat of DANGEROUS_PATTERNS) {
+ if (pat.test(trimmed)) return null;
+ }
+ return trimmed;
+}
+
+// ── Theme Application ────────────────────────────────────────
+
+/**
+ * Apply a theme's variables to a target (DOM element or plain object).
+ * In browser: pass `document.documentElement` as target.
+ * In Node: pass an empty object to collect computed values.
+ */
+function applyTheme(theme, target) {
+ if (!theme || !theme.variables) return;
+ const vars = { ...NORMALIZED_DEFAULT_VARS, ...theme.variables };
+ for (const [key, value] of Object.entries(vars)) {
+ if (!CSS_VARIABLES.includes(key)) continue;
+ const safe = sanitizeCssValue(value);
+ if (!safe) continue;
+ if (target && typeof target.style !== 'undefined' && target.style.setProperty) {
+ // DOM element (browser)
+ target.style.setProperty(key, safe);
+ } else if (typeof target === 'object') {
+ // Plain object (Node SSR)
+ target[key] = safe;
+ }
+ }
+}
+
+/**
+ * Resolve a theme by ID from the built-in catalog.
+ * Falls back to 'vibe' if not found.
+ */
+function resolveBuiltinTheme(themeId) {
+ return BUILTIN_THEMES.find(t => t.id === themeId || t.slug === themeId)
+ || BUILTIN_THEMES[0];
+}
+
+// ── Browser-Only Helpers ─────────────────────────────────────
+// These are safe to call in Node (they no-op if `window` is undefined).
+
+const STORAGE_KEY = 'ov_theme';
+const THEME_API_BASE = 'https://openvibe.network/api/themes';
+
+function loadFromStorage() {
+ if (typeof localStorage === 'undefined') return null;
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
+function saveToStorage(theme) {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ id: theme.id,
+ slug: theme.slug,
+ name: theme.name,
+ mode: theme.mode,
+ variables: theme.variables,
+ }));
+ } catch { /* quota exceeded, etc. */ }
+}
+
+/**
+ * Sync theme choice to the central OpenVibe.Network server.
+ * Requires an auth token. Fails silently if offline.
+ */
+async function syncThemeToServer(themeId, customVars, token) {
+ if (typeof fetch === 'undefined' || !token) return;
+ try {
+ await fetch(`${THEME_API_BASE}/me`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify({ theme_id: themeId, custom_variables: customVars || null }),
+ });
+ } catch { /* offline, CORS, etc. */ }
+}
+
+module.exports = {
+ CSS_VARIABLES,
+ DEFAULT_VARS: NORMALIZED_DEFAULT_VARS,
+ BUILTIN_THEMES,
+ sanitizeCssValue,
+ applyTheme,
+ resolveBuiltinTheme,
+ loadFromStorage,
+ saveToStorage,
+ syncThemeToServer,
+ STORAGE_KEY,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,597 @@
@@ -0,0 +1,597 @@
+'use strict';
+
+const URL_DEFINITIONS = Object.freeze({
+ OV_LIVE_URL: {
+ key: 'OV_LIVE_URL',
+ label: 'OpenVibe.Live Public URL',
+ category: 'public_first_party_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.live',
+ description: 'Canonical public origin for OpenVibe.Live.',
+ },
+ OV_TOOLS_URL: {
+ key: 'OV_TOOLS_URL',
+ label: 'OpenVibe.Tools Public URL',
+ category: 'public_first_party_urls',
+ service: 'tools',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.tools',
+ description: 'Canonical public origin for the OpenVibe.Tools gateway.',
+ },
+ OV_GAMES_URL: {
+ key: 'OV_GAMES_URL',
+ label: 'OpenVibe.Games Public URL',
+ category: 'public_first_party_urls',
+ service: 'games',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.games',
+ description: 'Canonical public origin for OpenVibe.Games.',
+ },
+ OV_MEDIA_URL: {
+ key: 'OV_MEDIA_URL',
+ label: 'OpenVibe.Media Public URL',
+ category: 'public_first_party_urls',
+ service: 'media',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.media',
+ description: 'Canonical public origin for OpenVibe.Media.',
+ },
+ WEBRTC_PUBLIC_URL: {
+ key: 'WEBRTC_PUBLIC_URL',
+ label: 'WebRTC Public URL',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'origin',
+ default: 'http://localhost:3000',
+ description: 'Public origin used for WebRTC endpoint discovery and browser consumers.',
+ },
+ WHIP_PUBLIC_URL: {
+ key: 'WHIP_PUBLIC_URL',
+ label: 'WHIP Public URL',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'origin',
+ default: 'http://localhost:3000',
+ description: 'Canonical WHIP ingestion origin for OBS and other WHIP clients.',
+ },
+ WHIP_PUBLIC_URL_ENABLED: {
+ key: 'WHIP_PUBLIC_URL_ENABLED',
+ label: 'Dedicated WHIP Host Enabled',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'boolean',
+ default: false,
+ description: 'Enable use of the dedicated WHIP hostname configured by WHIP_PUBLIC_URL. If disabled, the app falls back to the safe public WebRTC origin.',
+ },
+ JSMPEG_PUBLIC_URL: {
+ key: 'JSMPEG_PUBLIC_URL',
+ label: 'JSMPEG Public URL',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'origin',
+ default: 'http://localhost:3000',
+ description: 'Public origin used to construct JSMPEG relay endpoints for browsers and FFmpeg clients.',
+ },
+ TURN_URL: {
+ key: 'TURN_URL',
+ label: 'TURN URL',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'turn_url',
+ default: '',
+ description: 'TURN server connection string used for WebRTC NAT traversal.',
+ },
+ MEDIASOUP_ANNOUNCED_IP: {
+ key: 'MEDIASOUP_ANNOUNCED_IP',
+ label: 'Mediasoup Announced IP/Hostname',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'hostname',
+ default: 'localhost',
+ description: 'Hostname or public IP advertised in Mediasoup ICE candidates.',
+ },
+ RTMP_HOST: {
+ key: 'RTMP_HOST',
+ label: 'RTMP Host',
+ category: 'protocol_ingest_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'rtmp_host',
+ default: '',
+ description: 'Hostname used for RTMP ingest. Do not include protocol or path.',
+ },
+ OV_NETWORK_URL: {
+ key: 'OV_NETWORK_URL',
+ label: 'OpenVibe.Network URL',
+ category: 'public_first_party_urls',
+ service: 'network',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.network',
+ description: 'Public URL for the OpenVibe.Network SSO, account, and registry service.',
+ },
+ OV_NETWORK_LOGIN_URL: {
+ key: 'OV_NETWORK_LOGIN_URL',
+ label: 'OpenVibe.Network Login URL',
+ category: 'public_first_party_urls',
+ service: 'network',
+ scope: 'global',
+ type: 'origin',
+ default: 'https://openvibe.network',
+ description: 'Public login URL for OpenVibe.Network. Typically the same as the public OpenVibe.Network origin.',
+ },
+ OV_NETWORK_INTERNAL_URL: {
+ key: 'OV_NETWORK_INTERNAL_URL',
+ label: 'OpenVibe.Network Internal URL',
+ category: 'internal_service_urls',
+ service: 'network',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4000',
+ description: 'Internal URL used by services to call the OpenVibe.Network internal API.',
+ },
+ OV_LIVE_INTERNAL_URL: {
+ key: 'OV_LIVE_INTERNAL_URL',
+ label: 'OpenVibe.Live Internal URL',
+ category: 'internal_service_urls',
+ service: 'live',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:3000',
+ description: 'Internal URL used by OpenVibe.Network or other OpenVibe services to reach OpenVibe.Live.',
+ },
+ OV_TOOLS_INTERNAL_URL: {
+ key: 'OV_TOOLS_INTERNAL_URL',
+ label: 'OpenVibe.Tools Internal URL',
+ category: 'internal_service_urls',
+ service: 'tools',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4001',
+ description: 'Internal URL used by OpenVibe.Network to reach the OpenVibe.Tools gateway.',
+ },
+ OV_GAMES_INTERNAL_URL: {
+ key: 'OV_GAMES_INTERNAL_URL',
+ label: 'OpenVibe.Games Internal URL',
+ category: 'internal_service_urls',
+ service: 'games',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:8000',
+ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Games.',
+ },
+ OV_MEDIA_INTERNAL_URL: {
+ key: 'OV_MEDIA_INTERNAL_URL',
+ label: 'OpenVibe.Media Internal URL',
+ category: 'internal_service_urls',
+ service: 'media',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4100',
+ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Media.',
+ },
+ OV_MAPS_INTERNAL_URL: {
+ key: 'OV_MAPS_INTERNAL_URL',
+ label: 'Maps.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'maps',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4010',
+ description: 'Internal URL of the Maps.OpenVibe tools satellite.',
+ },
+ OV_FOOD_INTERNAL_URL: {
+ key: 'OV_FOOD_INTERNAL_URL',
+ label: 'Food.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'food',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4011',
+ description: 'Internal URL of the Food.OpenVibe tools satellite.',
+ },
+ OV_IMG_INTERNAL_URL: {
+ key: 'OV_IMG_INTERNAL_URL',
+ label: 'Img.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'img',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4012',
+ description: 'Internal URL of the Img.OpenVibe tools satellite.',
+ },
+ OV_YT_INTERNAL_URL: {
+ key: 'OV_YT_INTERNAL_URL',
+ label: 'YT.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'yt',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4013',
+ description: 'Internal URL of the YT.OpenVibe tools satellite.',
+ },
+ OV_AUDIO_INTERNAL_URL: {
+ key: 'OV_AUDIO_INTERNAL_URL',
+ label: 'Audio.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'audio',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4014',
+ description: 'Internal URL of the Audio.OpenVibe tools satellite.',
+ },
+ OV_TEXT_INTERNAL_URL: {
+ key: 'OV_TEXT_INTERNAL_URL',
+ label: 'Text.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'text',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4015',
+ description: 'Internal URL of the Text.OpenVibe tools satellite.',
+ },
+ OV_DOCS_INTERNAL_URL: {
+ key: 'OV_DOCS_INTERNAL_URL',
+ label: 'Docs.OpenVibe Internal URL',
+ category: 'internal_service_urls',
+ service: 'docs',
+ scope: 'global',
+ type: 'internal_base_url',
+ default: 'http://127.0.0.1:4016',
+ description: 'Internal URL of the Docs.OpenVibe tools satellite.',
+ },
+
+ // ── Brand / Site Identity ─────────────────────────────────────────────────
+ // Configurable during first-time setup and editable in admin.
+ // These values drive display names, JWT metadata, email copy, and CORS policy.
+ // Defaults match the OpenVibe — change for white-label installs.
+
+ NETWORK_NAME: {
+ key: 'NETWORK_NAME',
+ label: 'Network / Organization Name',
+ category: 'brand_identity',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: 'OpenVibe',
+ description: 'Human-readable name for the overall platform (e.g. "OpenVibe" or your custom brand).',
+ },
+ TOOLS_SERVICE_NAME: {
+ key: 'TOOLS_SERVICE_NAME',
+ label: 'Tools Service Name',
+ category: 'brand_identity',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: 'OpenVibe.Tools',
+ description: 'Display name for the SSO/tools service (e.g. "OpenVibe.Tools" or "CoolTools").',
+ },
+ STREAMER_SERVICE_NAME: {
+ key: 'STREAMER_SERVICE_NAME',
+ label: 'Streamer Service Name',
+ category: 'brand_identity',
+ service: 'live',
+ scope: 'global',
+ type: 'string',
+ default: 'OpenVibe.Live',
+ description: 'Display name for the streaming service (e.g. "OpenVibe.Live").',
+ },
+ GAMES_SERVICE_NAME: {
+ key: 'GAMES_SERVICE_NAME',
+ label: 'Games Service Name',
+ category: 'brand_identity',
+ service: 'games',
+ scope: 'global',
+ type: 'string',
+ default: 'OpenVibe.Games',
+ description: 'Display name for the games service (e.g. "OpenVibe.Games").',
+ },
+ MEDIA_SERVICE_NAME: {
+ key: 'MEDIA_SERVICE_NAME',
+ label: 'Media Service Name',
+ category: 'brand_identity',
+ service: 'media',
+ scope: 'global',
+ type: 'string',
+ default: 'OpenVibe.Media',
+ description: 'Display name for the media service (e.g. "OpenVibe.Media").',
+ },
+
+ // ── CORS / Origin Policy ──────────────────────────────────────────────────
+ // Controls which additional origins are allowed cross-origin access.
+ // The public service URLs above are always auto-allowed.
+
+ TOOLS_SUBDOMAIN_BASE: {
+ key: 'TOOLS_SUBDOMAIN_BASE',
+ label: 'Tools Subdomain Base Domain',
+ category: 'cors_policy',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: 'openvibe.tools',
+ description: 'Base domain for which all https://*.{domain} subdomains are trusted first-party origins (e.g. "openvibe.tools"). Leave empty to disable subdomain wildcarding.',
+ },
+ ALLOWED_EXTRA_ORIGINS: {
+ key: 'ALLOWED_EXTRA_ORIGINS',
+ label: 'Extra Allowed Origins (JSON array)',
+ category: 'cors_policy',
+ service: 'network',
+ scope: 'global',
+ type: 'json_array',
+ default: '[]',
+ description: 'JSON array of additional origins allowed by CORS across all services (e.g. ["https://my-cdn.com"]). Auto-includes all configured service public URLs.',
+ },
+
+ // ── Deploy Infrastructure ─────────────────────────────────────────────────
+ // Certificate, DNS, and reverse-proxy management config.
+ // These drive the automated TLS issuance and Nginx generation system.
+
+ DEPLOY_ACME_EMAIL: {
+ key: 'DEPLOY_ACME_EMAIL',
+ label: 'ACME / Let\'s Encrypt Email',
+ category: 'deploy_tls',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: '',
+ description: 'Email address used for Let\'s Encrypt certificate registration and expiry notifications.',
+ },
+ DEPLOY_CERT_MODE: {
+ key: 'DEPLOY_CERT_MODE',
+ label: 'Certificate Mode',
+ category: 'deploy_tls',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: 'manual',
+ description: 'Certificate provisioning mode: "cloudflare" for automated DNS-01 via Cloudflare API, "manual" for manual DNS challenge, or "none" to skip.',
+ },
+ DEPLOY_CLOUDFLARE_TOKEN: {
+ key: 'DEPLOY_CLOUDFLARE_TOKEN',
+ label: 'Cloudflare API Token (DNS Edit)',
+ category: 'deploy_tls',
+ service: 'network',
+ scope: 'global',
+ type: 'secret',
+ default: '',
+ description: 'Cloudflare API token with Zone:DNS:Edit permissions. Used for automated DNS-01 wildcard certificate challenges.',
+ },
+ DEPLOY_DOMAINS: {
+ key: 'DEPLOY_DOMAINS',
+ label: 'Managed Domains (JSON)',
+ category: 'deploy_tls',
+ service: 'network',
+ scope: 'global',
+ type: 'json_array',
+ default: '[]',
+ description: 'JSON array of domain objects managed by the deploy system. Each entry: {"domain":"openvibe.network","wildcard":false,"certName":"openvibe.network","services":["network"]}.',
+ },
+ DEPLOY_NGINX_MODE: {
+ key: 'DEPLOY_NGINX_MODE',
+ label: 'Nginx Management Mode',
+ category: 'deploy_nginx',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: 'preview',
+ description: 'Nginx config management mode: "preview" (generate only), "apply" (write + validate + reload), or "disabled".',
+ },
+ DEPLOY_NGINX_SITES_PATH: {
+ key: 'DEPLOY_NGINX_SITES_PATH',
+ label: 'Nginx sites-enabled Path',
+ category: 'deploy_nginx',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: '/etc/nginx/sites-enabled',
+ description: 'Directory where Nginx site configs are written. Defaults to /etc/nginx/sites-enabled.',
+ },
+ DEPLOY_NGINX_BACKUP_PATH: {
+ key: 'DEPLOY_NGINX_BACKUP_PATH',
+ label: 'Nginx Backup Path',
+ category: 'deploy_nginx',
+ service: 'network',
+ scope: 'global',
+ type: 'string',
+ default: '/etc/nginx/sites-backup',
+ description: 'Directory for Nginx config backups before apply. Created automatically.',
+ },
+ DEPLOY_SERVICE_MAP: {
+ key: 'DEPLOY_SERVICE_MAP',
+ label: 'Service → Port / Domain Map (JSON)',
+ category: 'deploy_nginx',
+ service: 'network',
+ scope: 'global',
+ type: 'json_map',
+ default: '{}',
+ description: 'JSON map of service configurations for Nginx generation. Each key is a service ID, value has port, domains[], wildcardDomain, maxBodySize, etc.',
+ },
+});
+
+function normalizeOrigin(value) {
+ if (!value || typeof value !== 'string') return null;
+ try {
+ const url = new URL(value.trim());
+ if (!url.protocol || !url.hostname) return null;
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
+ } catch {
+ return null;
+ }
+}
+
+function normalizeInternalBaseUrl(value) {
+ if (!value || typeof value !== 'string') return null;
+ try {
+ const url = new URL(value.trim());
+ if (!url.protocol || !url.hostname) return null;
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
+ } catch {
+ return null;
+ }
+}
+
+function normalizeWsOrigin(value) {
+ if (!value || typeof value !== 'string') return null;
+ try {
+ const url = new URL(value.trim());
+ if (!url.hostname) return null;
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
+ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
+ } catch {
+ return null;
+ }
+}
+
+function normalizeHostname(value) {
+ if (!value || typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ if (/^\w+:\/\//.test(trimmed)) {
+ try {
+ const url = new URL(trimmed);
+ return url.hostname || null;
+ } catch {
+ return null;
+ }
+ }
+ if (/[^a-zA-Z0-9.\-:]/.test(trimmed)) return null;
+ return trimmed;
+}
+
+function normalizeTurnUrl(value) {
+ if (!value || typeof value !== 'string') return null;
+ let trimmed = value.trim();
+ if (/^turns?:[^/]/i.test(trimmed)) {
+ trimmed = trimmed.replace(/^turns?:/i, (s) => s + '//');
+ }
+ if (!/^turns?:\/\//i.test(trimmed)) return null;
+ try {
+ const url = new URL(trimmed);
+ if (!url.hostname) return null;
+ const protocol = url.protocol.toLowerCase();
+ if (protocol !== 'turn:' && protocol !== 'turns:') return null;
+ if (url.username || url.password) return null;
+ return `${protocol}${url.hostname}${url.port ? `:${url.port}` : ''}${url.pathname}${url.search}`;
+ } catch {
+ return null;
+ }
+}
+
+function normalizeBoolean(value) {
+ if (value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE' || value === 'True') {
+ return true;
+ }
+ if (value === false || value === 0 || value === '0' || value === 'false' || value === 'FALSE' || value === 'False') {
+ return false;
+ }
+ return null;
+}
+
+function normalizeJson(value, type) {
+ if (typeof value === 'string') {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+ }
+ if (type === 'json_map' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ return value;
+ }
+ if (type === 'json_array' && Array.isArray(value)) {
+ return value;
+ }
+ return null;
+}
+
+function normalizeValue(value, type) {
+ if (value === undefined || value === null) return null;
+ switch (type) {
+ case 'origin': return normalizeOrigin(value);
+ case 'internal_base_url': return normalizeInternalBaseUrl(value);
+ case 'ws_origin': return normalizeWsOrigin(value);
+ case 'hostname': return normalizeHostname(value);
+ case 'rtmp_host': return normalizeHostname(value);
+ case 'turn_url': return normalizeTurnUrl(value);
+ case 'json_map': return normalizeJson(value, 'json_map');
+ case 'json_array': return normalizeJson(value, 'json_array');
+ case 'boolean': return normalizeBoolean(value);
+ case 'secret': return typeof value === 'string' ? value.trim() : String(value);
+ case 'string': return typeof value === 'string' ? value.trim() : String(value);
+ default: return typeof value === 'string' ? value.trim() : String(value);
+ }
+}
+
+function validateValue(value, type) {
+ return normalizeValue(value, type) !== null;
+}
+
+function resolveRegistryValues(env = {}, overrides = {}, bootstrap = {}, definitions = URL_DEFINITIONS) {
+ const resolved = {};
+ for (const [key, def] of Object.entries(definitions)) {
+ let rawValue = def.default;
+ let source = 'default';
+ if (env[key]) {
+ rawValue = env[key];
+ source = 'env';
+ }
+ if (bootstrap[key] != null && bootstrap[key] !== '') {
+ rawValue = bootstrap[key];
+ source = 'bootstrap';
+ }
+ if (overrides[key] != null && overrides[key] !== '') {
+ rawValue = overrides[key];
+ source = 'admin';
+ }
+ const normalized = normalizeValue(rawValue, def.type);
+ resolved[key] = {
+ key,
+ label: def.label,
+ category: def.category,
+ service: def.service,
+ scope: def.scope,
+ type: def.type,
+ description: def.description,
+ value: normalized,
+ source,
+ };
+ }
+ return resolved;
+}
+
+function formatRegistryEntry(entry) {
+ if (!entry || typeof entry !== 'object') return null;
+ return {
+ key: entry.key,
+ label: entry.label,
+ category: entry.category,
+ service: entry.service,
+ scope: entry.scope,
+ type: entry.type,
+ description: entry.description,
+ value: entry.value || null,
+ source: entry.source || 'admin',
+ updatedBy: entry.updated_by || null,
+ updatedAt: entry.updated_at || null,
+ };
+}
+
+module.exports = {
+ URL_DEFINITIONS,
+ normalizeValue,
+ validateValue,
+ resolveRegistryValues,
+ formatRegistryEntry,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — User Card & Context Menu
+// Right-click any username to see profile card, quick actions,
+// linked services, name effects, and moderator tools.
+// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })
+// OpenVibeUserCard.attach(element, userId)
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
+ let _cardEl = null;
+ let _cache = new Map();
+
+ function injectStyles() {
+ if (document.getElementById('openvibe-usercard-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-usercard-styles';
+ s.textContent = `
+ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }
+ .openvibe-usercard {
+ position: fixed; z-index: 99999;
+ width: 300px; background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 12px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ animation: openvibe-card-in .2s ease; overflow: hidden;
+ }
+ .openvibe-usercard-banner {
+ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #8b5cf6));
+ position: relative;
+ }
+ .openvibe-usercard-avatar {
+ width: 56px; height: 56px; border-radius: 50%;
+ border: 3px solid var(--bg-card, #22222c);
+ position: absolute; bottom: -28px; left: 16px;
+ background: var(--bg-tertiary, #2a2a38);
+ object-fit: cover;
+ }
+ .openvibe-usercard-body { padding: 36px 16px 12px; }
+ .openvibe-usercard-name {
+ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;
+ }
+ .openvibe-usercard-name .role-badge {
+ font-size: 9px; padding: 2px 6px; border-radius: 3px;
+ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
+ }
+ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }
+ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }
+ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }
+ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }
+ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }
+ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
+ .openvibe-usercard-services .svc {
+ font-size: 10px; padding: 3px 8px; border-radius: 4px;
+ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);
+ display: flex; align-items: center; gap: 4px;
+ }
+ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }
+ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }
+ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }
+ .openvibe-usercard-actions button {
+ display: flex; align-items: center; gap: 8px;
+ width: 100%; padding: 8px 12px; background: none;
+ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);
+ font-size: 12px; cursor: pointer; text-align: left;
+ transition: background .12s;
+ }
+ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }
+ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }
+
+ /* Name effects (CSS classes applied to usernames) */
+ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }
+ @keyframes openvibe-rainbow { to { background-position: 200% center; } }
+ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
+ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }
+ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }
+ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }
+ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }
+ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }
+ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }
+ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
+ `;
+ document.head.appendChild(s);
+ }
+
+ // ── Fetch User Data ──────────────────────────────────────
+ async function fetchUser(userId) {
+ if (_cache.has(userId)) return _cache.get(userId);
+ try {
+ const headers = {};
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });
+ if (!res.ok) return null;
+ const data = await res.json();
+ _cache.set(userId, data);
+ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache
+ return data;
+ } catch { return null; }
+ }
+
+ // ── Render Card ──────────────────────────────────────────
+ function renderCard(data, x, y) {
+ closeCard();
+ const user = data.user;
+ const linked = data.linked_accounts || [];
+
+ const roleBadge = user.role !== 'user'
+ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';
+
+ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';
+ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';
+
+ const services = linked.map(l => {
+ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };
+ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;
+ }).join('');
+
+ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);
+ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);
+
+ let actions = '';
+ if (!isSelf) {
+ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;
+ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;
+ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;
+ if (isMod) {
+ actions += `<div class="openvibe-usercard-divider"></div>`;
+ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;
+ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;
+ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;
+ }
+ } else {
+ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
+ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
+ }
+
+ const card = document.createElement('div');
+ card.className = 'openvibe-usercard';
+
+ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
+ card.innerHTML = `
+ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
+ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
+ <div class="openvibe-usercard-body">
+ <div class="openvibe-usercard-name">
+ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
+ ${roleBadge}
+ </div>
+ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
+ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}
+ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}
+ <div class="openvibe-usercard-divider"></div>
+ <div class="openvibe-usercard-actions">${actions}</div>
+ </div>
+ `;
+
+ // Position
+ const vw = window.innerWidth, vh = window.innerHeight;
+ card.style.left = Math.min(x, vw - 320) + 'px';
+ card.style.top = Math.min(y, vh - 400) + 'px';
+
+ // Overlay to close on click-outside
+ const overlay = document.createElement('div');
+ overlay.className = 'openvibe-usercard-overlay';
+ overlay.addEventListener('click', closeCard);
+ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(card);
+ _cardEl = { card, overlay };
+
+ // Action handlers
+ card.querySelectorAll('[data-act]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const act = btn.dataset.act;
+ closeCard();
+ if (act === 'profile') {
+ const url = isSelf ? `${_config.apiBase}/my`
+ : `${_config.apiBase}/user/${user.username}`;
+ window.open(url, '_blank');
+ } else if (act === 'message') {
+ // Dispatch custom event for the host app to handle
+ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
+ detail: { action: act, user }
+ }));
+ } else {
+ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
+ detail: { action: act, user }
+ }));
+ }
+ });
+ });
+ }
+
+ function closeCard() {
+ if (_cardEl) {
+ _cardEl.card.remove();
+ _cardEl.overlay.remove();
+ _cardEl = null;
+ }
+ }
+
+ // ── Public API ───────────────────────────────────────────
+ const OpenVibeUserCard = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ },
+
+ /** Attach right-click card to an element. */
+ attach(el, userId) {
+ el.style.cursor = 'pointer';
+ el.addEventListener('contextmenu', async e => {
+ e.preventDefault();
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, e.clientX, e.clientY);
+ });
+ // Also support click on mobile
+ el.addEventListener('click', async e => {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, e.clientX, e.clientY);
+ }
+ });
+ },
+
+ /** Programmatically show a card at position. */
+ async show(userId, x, y) {
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, x, y);
+ },
+
+ close: closeCard,
+ clearCache() { _cache.clear(); },
+
+ /** Apply name effect CSS class to an element. */
+ applyNameEffect(el, effectName) {
+ if (!effectName) return;
+ el.classList.add(`openvibe-name-fx-${effectName}`);
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
+ else root.OpenVibeUserCard = OpenVibeUserCard;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);