openvibers/openvibe.community Commits
Pastes: Community becomes the authority (PASTES_AUTHORITY=community)
Changes
@@@ -33,3 +33,16 @@ OV_LIVE_URL=https://openvibe.live# OpenVibe.Media — public host for raw paste text and screenshotsOV_MEDIA_URL=https://openvibe.media++# Media's internal address: new screenshot uploads (community authority) go to its file store++# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++++# Who owns pastes:++# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live++# community this site's own database is the authority (native API, pages, raw text)++# With 'community' the OAuth client above also needs identity.subject.resolve (author names).++PASTES_AUTHORITY=live++# SQLite database (production unit: /var/lib/openvibe-community/community.db)++COMMUNITY_DB_PATH=./data/community.db++# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)++#VIEW_HASH_SECRET=@@@ -14,4 +14,5 @@ jobs:cache: npm- run: npm ci --no-audit --no-fund- run: npm test++ - run: npx openvibe-contracts-check --service community --src server@@@ -6,12 +6,19 @@ The community hub of the OpenVibe network: community-run, open source, free speethe rules. Today it is the home of **pastes** (code, text and screenshots with a link);spaces, threads and submissions follow.--It is a small Node/Express app (CommonJS, no framework, no database of its own) that++It is a small Node/Express app (CommonJS, no framework, one SQLite database) thatserver-renders every page — crawlers and no-JS readers get the whole thing — and adds alittle progressive JavaScript for pagination, copy buttons and the upload path.## How it fits the network++Pastes are moving to Community (roadmap Wave 5). `PASTES_AUTHORITY` picks who owns them:++++- **`live`** (default, today's production): the table below. Deploying the new code changes++ nothing until the flag flips.++- **`community`**: Community's own database is the authority — see++ [Community as the paste authority](#community-as-the-paste-authority).++| Concern | Where it lives | How Community reaches it || --- | --- | --- || Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |@@@ -27,6 +34,50 @@ writes, which only Live can do, so Community proxies `/api/pastes/*` to Live andvisitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,anonymous-post limits, ownership and author names are therefore identical to Live's.++### Community as the paste authority++++With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API++(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and++response shapes the browser clients already use, and every page, `/p/:slug/raw`,++`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which++redirects its old paste URLs here after cutover).++++- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's++ owner is the JWT's `subject_id` (older tokens are resolved once through the Network and++ cached in `legacy_id_map`). In responses `user_id` carries that subject id, next to++ `owner_subject` and `origin`; names/avatars come from `subject_projection`, a cache++ refreshed through the Network's `POST /internal/identity/resolve-batch` (service token,++ `identity.subject.resolve`).++- **Service callers** (Live's adapter, Live's AI jobs) send a Network service token for++ audience `openvibe.community` and need the route's capability: `community.paste.create`++ (create), `community.paste.write` (edit, delete, fork, like, copy, comment as someone),++ `community.paste.moderate` (`/admin/*`, `/bulk`, `/:slug/censor`, the AI queue++ `?needs_ai=1` and `POST /:slug/ai`). Headers: `X-OV-Subject: usr_…|gst_…` names the acting++ person (no subject = anonymous); `X-OV-Origin: ai` stores AI output with origin `ai` and no++ owner; `X-OV-Source-Ref: <EntityRef JSON>` records e.g. the stream; `X-OV-Staff: 1` (needs++ moderate) vouches that the acting person is staff. Services may bring a `slug` and++ `metadata`; browsers never. Identity is never read from bodies or queries.++- **Limits** as before: 20 anonymous writes per 10 minutes per address (API and the no-JS++ form share it); people get Media's 30 s paste cooldown, 200/day and comment limits.++- **Screenshots**: new uploads lose their EXIF/XMP metadata and go to Media's file store++ (`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with++ `media.object.upload`); the paste keeps the public URL and `media_ref`++ `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.++- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.++++Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff++2026-08-20T03:00:26Z]` imports Media's `openvibe.media.pastes-export` bundle. It checks the++bundle's format, counts and sha256 first, is idempotent (upsert by `legacy_media_id`; a newer++bundle adds and updates, counters never go down, local edits and deletes stay), maps Live user++ids to subjects through the Network (rows before the cutoff are also checked as Network ids;++disagreement or no answer imports the row ownerless and records an `import_hold`), keeps AI++moment pastes ownerless with origin `ai`, and prints a reconciliation report (also stored in++`migration_runs`). `--dry-run` rolls everything back.++++`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network++has no service-token lookup by username), so a person appears there once Community has seen++them — signed in here, or named as an author in a resolved projection.++## EndpointsPages (server-rendered HTML):@@@ -36,9 +87,9 @@ Pages (server-rendered HTML):| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming || `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` || `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |--| `GET /p/:slug/raw` | 302 → `https://openvibe.media/p/:slug/raw` |--| `GET /p/:slug/screenshot` | 302 → `https://openvibe.media/p/:slug/screenshot` |--| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to Media |++| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |++| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |++| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image || `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` || `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |@@@ -46,7 +97,7 @@ API and machine endpoints:| Route | What || --- | --- |--| `ANY /api/pastes/*` | Transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched |++| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) || `GET /api/health`, `GET /api/ready` | Liveness / readiness || `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` || `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |@@@ -84,6 +135,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` || `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages || `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |++| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |++| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |++| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |++| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts || `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |## Run@@@ -91,7 +146,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0```npm installnpm start # http://127.0.0.1:4200--npm test # mocks Live + Network in-process; no network needed++npm test # mocks Live, Network and Media in-process; no network needed```## Deploy@@@ -104,8 +159,15 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T# /etc/letsencrypt/live/openvibe.community/```--Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. Nothing is--written to disk by the app; there is no migration step.++Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. The schema++is created idempotently at boot (only when `PASTES_AUTHORITY=community`; in `live` mode the++app writes nothing to disk). The database lives in the unit's `StateDirectory`++(`/var/lib/openvibe-community`), so the code tree stays read-only.++++Before flipping to `community`, the Network's `community` OAuth client needs the++`identity.subject.resolve` capability (audience `openvibe.network`) and++`media.object.upload` (audience `openvibe.media`), and Live's service client needs the++`community.paste.*` capabilities it will use.The Network must have the OAuth client `community` registered with redirect`https://openvibe.community/auth/callback`, and serve the current `openvibe-shared`@@@ -119,10 +181,20 @@ server/index.js process entry (listen, shutdown)app.js Express app factory: middleware, routesconfig.js env → config++ db.js SQLite (better-sqlite3): schema, opened at COMMUNITY_DB_PATHauth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth-- live-client.js server-side reads of Live's /api/pastes for rendered pages-- pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address)-- pastes/catalog.js cached window of recent public pastes: trending, related, language filter++ identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous++ identity/network.js Network resolve-batch client + subject_projection cache++ media/files.js new screenshot bytes → Media's file store (service token)++ media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding++ live-client.js server-side reads of Live's /api/pastes for rendered pages ('live')++ pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address) ('live')++ pastes/api.js native /api/pastes/* ('community')++ pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes++ pastes/store.js pure SQL over pastes / versions / likes / comments / projections++ pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)++ pastes/source.js where pages read pastes from (Live or the store)++ pastes/catalog.js recent public pastes: trending, related, language filterrender/layout.js page shell: SEO head, shared chrome, hashed assetsrender/pages.js home / browse / paste / new / my / error templatesrender/highlight.js highlight.js wrapper, language list, download extensions@@@ -130,14 +202,15 @@ server/public/ css/community.css, js/community.js, favicon.svg, og-default.pngvendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-shareddeploy/ systemd unit, nginx vhost--test/ run.js + *.test.js (mock Live + mock Network with a real RS256 key)++scripts/import-pastes.js Media paste bundle importer++test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)```## What is next (phase 2)Spaces (per-streamer / per-topic communities), threads (long-form forum discussion) andsubmissions (clips, art, ideas, reports for community review). These need storage of their--own — Community has none today — and moderation tooling; the paste pages, auth layer and++own — the paste database is the first — and moderation tooling; the paste pages, auth layer andchrome are built to be reused by them.## Related services@@@ -21,7 +21,10 @@ NoNewPrivileges=truePrivateTmp=trueProtectSystem=strictProtectHome=true--# Nothing is written to disk — pastes live in OpenVibe.Media, sessions are cookies.++# Community's own database (used once PASTES_AUTHORITY=community; untouched before that).++# The code tree stays read-only; the database lives in /var/lib/openvibe-community.++StateDirectory=openvibe-community++Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db[Install]WantedBy=multi-user.target@@@ -9,12 +9,15 @@"version": "1.0.0","license": "MIT","dependencies": {++ "better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0","highlight.js": "^11.11.1",++ "multer": "^2.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0","openvibe-shared": "file:./vendor/openvibe-shared"},"engines": {@@@ -34,12 +37,102 @@"node": ">= 0.6"}},++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "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/body-parser": {"version": "1.20.8","resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",@@@ -64,12 +157,47 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "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": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },"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/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",@@@ -108,6 +236,12 @@"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/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -166,6 +300,30 @@"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",@@@ -185,6 +343,15 @@"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",@@@ -235,6 +402,15 @@"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",@@@ -280,6 +456,15 @@"node": ">= 0.6"}},++ "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.3","resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",@@@ -341,6 +526,34 @@"express": ">= 4.11"}},++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "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",@@@ -377,6 +590,12 @@"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",@@@ -423,6 +642,12 @@"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",@@@ -512,12 +737,38 @@"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",@@@ -527,6 +778,12 @@"node": ">= 0.10"}},++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },"node_modules/jsonwebtoken": {"version": "9.0.3","resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",@@@ -687,12 +944,63 @@"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-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": "2.4.0",++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",++ "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",++ "license": "MIT",++ "dependencies": {++ "append-field": "^1.0.0",++ "busboy": "^1.6.0",++ "type-is": "^1.6.18"++ },++ "engines": {++ "node": ">= 10.16.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "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",@@@ -702,6 +1010,18 @@"node": ">= 0.6"}},++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -726,6 +1046,31 @@"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-contracts": {++ "version": "0.5.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",++ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"resolved": "vendor/openvibe-shared","link": true@@@ -745,6 +1090,33 @@"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/proxy-addr": {"version": "2.0.8","resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",@@@ -762,6 +1134,16 @@"url": "https://opencollective.com/express"}},++ "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.16.0","resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",@@@ -802,6 +1184,44 @@"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": "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/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -963,6 +1383,51 @@"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/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -972,6 +1437,60 @@"node": ">= 0.8"}},++ "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.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "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/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -981,6 +1500,18 @@"node": ">=0.6"}},++ "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",@@@ -1003,6 +1534,12 @@"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",@@@ -1021,6 +1558,12 @@"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"++ },"vendor/openvibe-shared": {"version": "1.0.0","license": "MIT",@@@ -2,7 +2,7 @@"name": "openvibe-community","version": "1.0.0","private": true,-- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (pastes stored by OpenVibe.Media via OpenVibe.Live, identity by OpenVibe.Network).",++ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -13,15 +13,18 @@"author": "OpenVibe Community","license": "MIT","dependencies": {++ "better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0","highlight.js": "^11.11.1",++ "multer": "^2.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0","openvibe-shared": "file:./vendor/openvibe-shared"},"engines": {-- "node": ">=18.0.0"++ "node": ">=20.0.0"}}nnew file mode 100644@@@ -0,0 +1,70 @@++#!/usr/bin/env node++'use strict';++++/**++ * Import OpenVibe.Media's paste export into Community's database.++ *++ * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z]++ *++ * Reads the same environment as the server (.env / /etc/openvibe/community.env):++ * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the++ * client needs identity.subject.resolve). Safe to re-run: see server/pastes/importer.js.++ *++ * Exit codes: 0 imported (or dry run finished), 2 bundle refused, 1 anything else.++ */++const fs = require('fs');++const path = require('path');++++function usage(msg) {++ if (msg) console.error(msg);++ console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>]');++ process.exit(msg ? 1 : 0);++}++++function parseArgs(argv) {++ const out = { file: null, dryRun: false, cutoff: null };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--dry-run') out.dryRun = true;++ else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }++ else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);++ else if (a === '-h' || a === '--help') usage();++ else if (a.startsWith('-')) usage(`unknown option ${a}`);++ else if (!out.file) out.file = a;++ else usage(`unexpected argument ${a}`);++ }++ if (!out.file) usage('missing <bundle.json>');++ return out;++}++++async function main() {++ const args = parseArgs(process.argv.slice(2));++ const config = require('../server/config');++ const { openDb } = require('../server/db');++ const { createNetworkIdentity } = require('../server/identity/network');++ const { importBundle, formatReport, ImportError, DEFAULT_CUTOFF } = require('../server/pastes/importer');++++ let bundle;++ try { bundle = JSON.parse(fs.readFileSync(args.file, 'utf8')); }++ catch (err) { console.error(`Cannot read ${args.file}: ${err.message}`); process.exit(2); }++++ const db = openDb(config.dbPath);++ const network = createNetworkIdentity({ config, db });++ try {++ const report = await importBundle(db, bundle, {++ // A dry run writes nothing at all, not even the display cache.++ resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),++ cutoff: args.cutoff || DEFAULT_CUTOFF,++ dryRun: args.dryRun,++ source: `${bundle.app || 'media'}:${path.basename(args.file)}`,++ });++ console.log(formatReport(report));++ } catch (err) {++ if (err instanceof ImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }++ throw err;++ } finally {++ db.close();++ }++}++++main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });@@@ -6,14 +6,20 @@* Express app factory (server/index.js listens; tests build their own instance).** Pages (server-rendered) API / machine-- * GET / home ALL /api/pastes/* → OpenVibe.Live proxy++ * GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below* GET /pastes browse GET /api/health, /api/ready* GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml-- * GET /p/:slug/raw → Media /auth/login|callback|logout|me|refresh-- * GET /p/:slug/screenshot → Media++ * GET /p/:slug/raw raw text /auth/login|callback|logout|me|refresh++ * GET /p/:slug/screenshot → image* GET /p/:slug/download* GET|POST /new create* GET /my signed-in user's pastes++ *++ * PASTES_AUTHORITY (config.pastesAuthority):++ * 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through++ * Live's API, raw text and screenshots bounce to OpenVibe.Media.++ * 'community' this site's own database is the authority: the native API++ * (pastes/api.js), pages, raw text and screenshots all come from the store.*/const path = require('path');const express = require('express');@@@ -24,6 +30,7 @@ const rateLimit = require('express-rate-limit');const config = require('./config');const live = require('./live-client');const catalog = require('./pastes/catalog');++const source = require('./pastes/source');const seo = require('./seo');const pages = require('./render/pages');const { assetVersion } = require('./render/layout');@@@ -74,9 +81,41 @@ function createApp(opts = {}) {app.use('/auth', createAuthRoutes(config, auth));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }-- // ── /api/pastes → OpenVibe.Live (before any body parser: bodies stream through) ──++ // ── Paste authority ──────────────────────────────────────++ const community = config.pastesAuthority === 'community';++ let viewers = null;++ if (community) {++ const { openDb, getDb } = require('./db');++ const { createNetworkIdentity } = require('./identity/network');++ const { createViewerResolver } = require('./identity/viewer');++ const { createMediaFiles } = require('./media/files');++ const { createPasteService } = require('./pastes/service');++ const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());++ const network = opts.network || createNetworkIdentity({ config, db });++ const media = opts.media || createMediaFiles({ config });++ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits });++ viewers = createViewerResolver({ auth, config, network });++ source.use(service);++ app.locals.db = db;++ app.locals.pastes = service;++ } else {++ source.use(null);++ }++ // One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).++ const anonWriteLimiter = rateLimit({++ windowMs: 10 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,++ skip: (req) => !!(req.viewer ? req.viewer.kind !== 'anonymous' : req.user),++ handler: (req, res) => {++ const error = 'Too many anonymous posts — sign in or try again later';++ if (req.originalUrl.startsWith('/api/')) return res.status(429).json({ error });++ res.status(429).type('html').set('Cache-Control', 'no-cache').send(pages.newPage({ user: req.user, error }));++ },++ });++++ // ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));-- app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));++ if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));++ else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));app.get('/api/ready', (_req, res) => res.json({ ready: true }));@@@ -99,8 +138,9 @@ function createApp(opts = {}) {app.get('/feed.xml', wrap(seo.feedHandler));// ── Pages ────────────────────────────────────────────────-- const withUser = optionalAuth(auth);-- const ctxOf = (req) => ({ token: req.token, ip: req.ip });++ // Pages are for browsers: in community mode the viewer (subject, staff) is resolved too.++ const withUser = community ? viewers.middleware({ services: false }) : optionalAuth(auth);++ const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);app.get('/', withUser, wrap(async (req, res) => {@@@ -121,7 +161,7 @@ function createApp(opts = {}) {if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);let paste;try {-- paste = await live.getPaste(slug, ctxOf(req));++ paste = await source.getPaste(slug, ctxOf(req));} catch (err) {if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);@@@ -131,17 +171,41 @@ function createApp(opts = {}) {html(res, pages.pastePage({ paste, related, user: req.user }));}));-- // Raw text and screenshots are public on OpenVibe.Media — bounce there.-- app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));-- app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));++ if (community) {++ // The store is the authority: raw text is served here, the screenshot link is the stored++ // Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)++ const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');++ app.get('/p/:slug/raw', withUser, (req, res) => {++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);++ try {++ const out = app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));++ if (out.redirect) return res.redirect(302, out.redirect);++ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');++ res.type('text/plain; charset=utf-8').send(out.content);++ } catch (err) {++ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');++ if (err.status === 404) return notFound(res);++ throw err;++ }++ });++ app.get('/p/:slug/screenshot', withUser, (req, res) => {++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);++ try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }++ catch (err) { if (err.status === 404) return notFound(res); throw err; }++ });++ } else {++ // Raw text and screenshots are public on OpenVibe.Media — bounce there.++ app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));++ app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));++ }app.get('/p/:slug/download', withUser, wrap(async (req, res) => {const { slug } = req.params;if (!SLUG_RE.test(slug)) return res.status(404).type('text/plain').send('Not found');let paste;-- try { paste = await live.getPaste(slug, { ...ctxOf(req), noView: true }); }++ try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }-- if (paste.type === 'screenshot') return res.redirect(302, live.screenshotUrl(slug));++ if (paste.type === 'screenshot') return res.redirect(302, community ? (paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`) : live.screenshotUrl(slug));res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);res.set('Cache-Control', 'private, no-cache');res.type('text/plain; charset=utf-8').send(String(paste.content || ''));@@@ -150,13 +214,15 @@ function createApp(opts = {}) {app.get('/new', withUser, wrap(async (req, res) => {let fork = null;if (req.query.fork && SLUG_RE.test(String(req.query.fork))) {-- try { const p = await live.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }++ try { const p = await source.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }}html(res, pages.newPage({ user: req.user, fork }));}));// No-JS fallback: a plain form post becomes the same JSON create the API path uses.-- app.post('/new', withUser, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {++ // In community mode the anonymous-write budget applies here too (in 'live' mode Live enforces it).++ const formLimiter = community ? anonWriteLimiter : (_req, _res, next) => next();++ app.post('/new', withUser, formLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {const b = req.body || {};const values = {title: String(b.title || '').slice(0, 200),@@@ -169,7 +235,7 @@ function createApp(opts = {}) {if (values.visibility === 'private' && !req.user) values.visibility = 'unlisted';if (!values.content.trim()) return html(res, pages.newPage({ user: req.user, values, error: 'Paste something first — the content is empty.' }), 400);try {-- const out = await live.createPaste({++ const out = await source.createPaste({title: values.title, content: values.content, language: values.language, visibility: values.visibility,burn_after_read: values.burn_after_read, is_nsfw: values.is_nsfw,}, ctxOf(req));@@@ -187,7 +253,7 @@ function createApp(opts = {}) {if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);let pastes = [], total = 0, error = null;try {-- const out = await live.listByUser(req.user.username, { limit: 100 }, ctxOf(req));++ const out = await source.listByUser(req.user.username, { limit: 100 }, ctxOf(req));pastes = (out && out.pastes) || [];total = Number(out && out.total) || pastes.length;} catch (err) {@@@ -44,4 +44,13 @@ module.exports = {liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),// OpenVibe.Media — public host that serves raw paste text and screenshots.mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),++ // Media's internal address: new screenshot uploads go to its file store (community app).++ mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),++++ // Who answers /api/pastes/* and the paste pages:++ // 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.++ // 'community' — this site's own database is the authority (server/pastes/api.js).++ pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',++ // SQLite file for Community's own data (pastes once authority=community).++ dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',};nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++++/**++ * Community's own database (SQLite, better-sqlite3).++ *++ * Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit++ * history, likes and comments live here. People are referenced by Network subject ids++ * (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache++ * of what the Network says about them.++ *++ * Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS pastes (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ slug TEXT UNIQUE NOT NULL,++ owner_subject TEXT,++ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),++ type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),++ title TEXT NOT NULL DEFAULT 'Untitled',++ content TEXT,++ language TEXT DEFAULT 'text',++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),++ screenshot_url TEXT,++ media_ref TEXT,++ stream_ref TEXT, -- JSON EntityRef, e.g. {"service":"live","type":"stream","id":"123"}++ metadata TEXT, -- JSON++ burn_after_read INTEGER NOT NULL DEFAULT 0,++ forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,++ pinned INTEGER NOT NULL DEFAULT 0,++ views INTEGER NOT NULL DEFAULT 0,++ unique_views INTEGER NOT NULL DEFAULT 0,++ copies INTEGER NOT NULL DEFAULT 0,++ likes INTEGER NOT NULL DEFAULT 0,++ is_nsfw INTEGER NOT NULL DEFAULT 0,++ ai_summary TEXT,++ ai_tags TEXT, -- JSON array++ ai_analyzed_at DATETIME,++ revision INTEGER NOT NULL DEFAULT 1,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ deleted_at DATETIME,++ legacy_media_id INTEGER UNIQUE,++ legacy_user_id INTEGER++);++CREATE INDEX IF NOT EXISTS idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;++CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner_subject, created_at DESC);++CREATE INDEX IF NOT EXISTS idx_pastes_forked ON pastes(forked_from);++++CREATE TABLE IF NOT EXISTS paste_versions (++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ revision INTEGER NOT NULL,++ title TEXT,++ content TEXT,++ language TEXT,++ edited_by TEXT,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (paste_id, revision)++);++++CREATE TABLE IF NOT EXISTS paste_likes (++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ subject_id TEXT NOT NULL,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (paste_id, subject_id)++);++++CREATE TABLE IF NOT EXISTS paste_comments (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ author_subject TEXT,++ anon_name TEXT,++ parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,++ message TEXT NOT NULL,++ is_deleted INTEGER NOT NULL DEFAULT 0,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ legacy_media_id INTEGER UNIQUE++);++CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id, parent_id);++++-- Per-visitor view bookkeeping (cooldown + unique counts). Visitors are 'u:<subject>' or an++-- HMAC of the address ('ip:<hash>'); no raw addresses are stored.++CREATE TABLE IF NOT EXISTS paste_visits (++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ visitor TEXT NOT NULL,++ first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ visits INTEGER NOT NULL DEFAULT 1,++ PRIMARY KEY (paste_id, visitor)++);++CREATE INDEX IF NOT EXISTS idx_paste_visits_last ON paste_visits(last_at);++++CREATE TABLE IF NOT EXISTS subject_projection (++ subject_id TEXT PRIMARY KEY,++ username TEXT,++ display_name TEXT,++ avatar_url TEXT,++ profile_color TEXT,++ refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP++);++CREATE INDEX IF NOT EXISTS idx_subject_projection_username ON subject_projection(username COLLATE NOCASE);++++CREATE TABLE IF NOT EXISTS migration_runs (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ source TEXT NOT NULL,++ started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ finished_at DATETIME,++ report TEXT++);++++CREATE TABLE IF NOT EXISTS legacy_id_map (++ source_system TEXT NOT NULL,++ source_type TEXT NOT NULL,++ source_id TEXT NOT NULL,++ target_type TEXT NOT NULL,++ target_id TEXT NOT NULL,++ PRIMARY KEY (source_system, source_type, source_id)++);++++CREATE TABLE IF NOT EXISTS import_hold (++ source_type TEXT NOT NULL,++ source_id TEXT NOT NULL,++ reason TEXT NOT NULL,++ detail TEXT,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (source_type, source_id, reason)++);++`;++++/** Open (creating the directory and schema if needed). ':memory:' works for tests. */++function openDb(file) {++ if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });++ const db = new Database(file);++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ db.exec(SCHEMA);++ return db;++}++++let _shared = null;++/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */++function getDb() {++ if (!_shared) _shared = openDb(require('./config').dbPath);++ return _shared;++}++++module.exports = { openDb, getDb, SCHEMA };nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++++/**++ * OpenVibe.Network identity client — who a subject is, for display.++ *++ * Community stores people as Network subject ids (usr_… / gst_…). Names and pictures come from++ * the Network's resolve-batch endpoint (capability identity.subject.resolve, called with a++ * client-credentials service token) and are cached in subject_projection. The cache is never++ * authority: an entry older than PROJECTION_TTL_MS is refreshed in the background, a missing one++ * is fetched before the response (bounded by RESOLVE_WAIT_MS so a slow Network only costs names).++ *++ * POST /internal/identity/resolve-batch++ * { subject_ids: [...] } or { system, type: 'user', ids: [...] } (≤ 500 per call)++ * → { results: { <key>: { subject: {type,id}, network_user_id, username, display_name, avatar_url, banned } | null } }++ */++const { serviceAuth } = require('openvibe-contracts');++const store = require('../pastes/store');++++const BATCH = 500;++const PROJECTION_TTL_MS = 6 * 60 * 60 * 1000;++const RESOLVE_WAIT_MS = 2500;++++function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}) {++ const base = config.networkInternalUrl;++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve',++ fetchImpl,++ });++++ /** Relative avatar paths the Network hands out are relative to the Network. */++ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);++++ async function post(body, retried = false) {++ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(8000),++ });++ // A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.++ if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data.results !== 'object') {++ const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);++ err.status = res.status;++ throw err;++ }++ return data.results;++ }++++ /** Resolve many keys in ≤500 chunks. `shape(chunk)` builds the request body. → Map key → projection|null */++ async function resolveChunks(keys, shape) {++ const out = new Map();++ const list = [...new Set(keys.map(String))];++ for (let i = 0; i < list.length; i += BATCH) {++ const chunk = list.slice(i, i + BATCH);++ const results = await post(shape(chunk));++ for (const k of chunk) out.set(k, results[k] || null);++ }++ return out;++ }++++ /** Store a Network projection in the cache. */++ function remember(p) {++ if (!p || !p.subject || !p.subject.id) return;++ store.upsertProjection(db, {++ subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,++ avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,++ });++ }++++ /** Subject ids → projections (and cache them). */++ async function resolveSubjects(subjectIds) {++ const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));++ for (const p of map.values()) remember(p);++ return map;++ }++++ /** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */++ async function resolveLegacy(system, ids, { cache = true } = {}) {++ const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));++ if (cache) for (const p of map.values()) remember(p);++ return map;++ }++++ /**++ * The subject of a Network account id, for user JWTs issued before tokens carried++ * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).++ */++ async function subjectForNetworkUser(networkUserId) {++ if (networkUserId == null || networkUserId === '') return null;++ const hit = store.mapGet(db, 'network', 'user', networkUserId);++ if (hit && hit.target_type === 'subject') return hit.target_id;++ const map = await resolveLegacy('network', [networkUserId]);++ const p = map.get(String(networkUserId));++ if (!p || !p.subject || p.subject.type !== 'user') return null;++ store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);++ return p.subject.id;++ }++++ /** What a verified user JWT says about its holder is a fresh projection too. */++ function rememberClaims(subjectId, claims) {++ if (!subjectId || !claims) return;++ store.upsertProjection(db, {++ subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,++ avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,++ });++ }++++ let inflight = null;++ const pending = new Set();++ /** Background refresh of stale entries, batched and deduplicated. */++ function refreshLater(ids) {++ for (const id of ids) pending.add(id);++ if (inflight) return;++ inflight = Promise.resolve().then(async () => {++ while (pending.size) {++ const batch = [...pending].slice(0, BATCH);++ batch.forEach((id) => pending.delete(id));++ try { await resolveSubjects(batch); } catch (err) { console.warn('[Identity] projection refresh failed:', err.message); pending.clear(); }++ }++ }).finally(() => { inflight = null; });++ }++++ /**++ * Projections for display. Missing entries are fetched now (bounded wait); stale ones are++ * served as they are and refreshed behind the response. → Map subject_id → projection row++ */++ async function projections(subjectIds) {++ const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];++ if (!ids.length) return new Map();++ let map = store.getProjections(db, ids);++ const missing = ids.filter((id) => !map.has(id));++ const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);++ if (stale.length) refreshLater(stale);++ if (missing.length) {++ let timer;++ const wait = new Promise((resolve) => { timer = setTimeout(resolve, RESOLVE_WAIT_MS); });++ // The lookup keeps running (and fills the cache) if the wait runs out first.++ const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));++ await Promise.race([lookup, wait]);++ clearTimeout(timer);++ map = store.getProjections(db, ids);++ }++ return map;++ }++++ return { resolveSubjects, resolveLegacy, subjectForNetworkUser, rememberClaims, projections, tokens };++}++++module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++++/**++ * Who is calling — resolved once per request into `req.viewer`:++ *++ * { kind: 'anonymous' }++ * { kind: 'user', subject: 'usr_…'|null, staff, user: <JWT claims> }++ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from++ * the token's subject_id; older tokens without one are resolved through the Network.++ * role admin/global_mod makes the viewer staff. X-OV-* headers are ignored for browsers.++ * { kind: 'service', service: 'svc:live', claims, subject: 'usr_…'|'gst_…'|null, origin, sourceRef, staff }++ * A first-party service holding a Network client-credentials token for audience++ * openvibe.community. It names the person it acts for in X-OV-Subject; with no subject the++ * write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a++ * person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).++ * X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate.++ *++ * Identity never comes from a request body or query. A request that presents a service token is++ * judged on that token alone: a bad one is refused, never downgraded to anonymous.++ */++const contracts = require('openvibe-contracts');++const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');++++const { ids, capabilities, serviceAuth, http } = contracts;++const STAFF_ROLES = new Set(['admin', 'global_mod']);++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.community';++++class ViewerError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });++++function isActingSubject(v) {++ return typeof v === 'string' && (ids.isSubjectId('user', v) || ids.isSubjectId('guest', v));++}++++/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */++function hasCap(viewer, capabilityId) {++ return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);++}++++function createViewerResolver({ auth, config, network }) {++ async function fromServiceToken(req, token) {++ const publicKey = await auth.ensureKey();++ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });++ if (!r.ok) throw new ViewerError(401, r.code, r.reason);++ const claims = r.claims;++++ const originHeader = req.get('x-ov-origin');++ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');++ const origin = originHeader === 'ai' ? 'ai' : 'user';++++ const subjectHeader = req.get('x-ov-subject');++ let subject = null;++ if (subjectHeader) {++ if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');++ // AI output is never attributed to a person, even if the caller also names one.++ subject = origin === 'ai' ? null : subjectHeader;++ }++++ let sourceRef = null;++ const refHeader = req.get('x-ov-source-ref');++ if (refHeader) {++ try { sourceRef = JSON.parse(refHeader); } catch { sourceRef = undefined; }++ if (!sourceRef || !contracts.validate('common.entity-ref@1', sourceRef).valid) {++ throw new ViewerError(400, 'request.invalid_source_ref', 'X-OV-Source-Ref must be a JSON EntityRef {service, type, id}');++ }++ }++++ let staff = false;++ if (req.get('x-ov-staff') === '1') {++ const c = capabilities.check(claims, 'community.paste.moderate');++ if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs community.paste.moderate');++ staff = true;++ }++ return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff };++ }++++ async function fromUserToken(token) {++ const claims = await auth.verify(token);++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;++ let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject && network && claims.sub != null) {++ try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }++ }++ if (subject && network) { try { network.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ const user = claimsToUser(claims);++ if (subject && !user.subject_id) user.subject_id = subject;++ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user, token };++ }++++ /**++ * Resolve the caller. opts.services=false (server-rendered pages) treats a service token as++ * no identity at all: pages are for browsers.++ */++ async function resolve(req, opts = {}) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodeJwtPayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ if (opts.services === false) return ANONYMOUS;++ return fromServiceToken(req, token);++ }++ }++ const token = extractToken(req);++ if (!token) return ANONYMOUS;++ return (await fromUserToken(token)) || ANONYMOUS;++ }++++ /** Express middleware: sets req.viewer (and req.user/req.token for the page renderers). */++ function middleware(opts = {}) {++ return async (req, res, next) => {++ try {++ req.viewer = await resolve(req, opts);++ if (req.viewer.kind === 'user') { req.user = req.viewer.user; req.token = req.viewer.token; }++ next();++ } catch (err) {++ if (!(err instanceof ViewerError)) return next(err);++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ }++ };++ }++++ return { resolve, middleware };++}++++module.exports = { createViewerResolver, hasCap, isActingSubject, ANONYMOUS, STAFF_ROLES, ViewerError };@@@ -10,7 +10,9 @@ const { createApp } = require('./app');const app = createApp();const server = app.listen(config.port, config.host, () => {console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);-- console.log(`[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);++ console.log(config.pastesAuthority === 'community'++ ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`++ : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);});server.keepAliveTimeout = 65_000;nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Media file store client — where new screenshot bytes go.++ *++ * Community keeps only the link: an upload is POSTed as multipart (field `file`) to Media's++ * community file store with a service token (capability media.object.upload), and Media answers++ * { key, url, size, mime }. The paste stores the absolute public URL and a transitional media++ * reference `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot++ * URLs; no bytes are ever copied here.++ */++const { serviceAuth, ids } = require('openvibe-contracts');++++function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.media',++ scope: 'media.object.upload',++ fetchImpl,++ });++ const absolute = (u) => (/^https?:\/\//i.test(u) ? u : `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`);++++ /** Upload one file. → { key, url (absolute), size, mime, media_ref } */++ async function upload({ buffer, filename, mime }, retried = false) {++ const fd = new FormData();++ fd.append('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), filename || 'upload.bin');++ const res = await fetchImpl(`${config.mediaInternalUrl}/api/v1/community/files`, {++ method: 'POST',++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: fd,++ signal: AbortSignal.timeout(60_000),++ });++ if (res.status === 401 && !retried) { tokens.invalidate(); return upload({ buffer, filename, mime }, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.key || !data.url) {++ const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);++ err.status = res.status;++ throw err;++ }++ return { key: data.key, url: absolute(data.url), size: data.size, mime: data.mime, media_ref: ids.legacyMediaId('community', 'file', data.key) };++ }++++ return { upload, tokens };++}++++module.exports = { createMediaFiles };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * Drop embedded metadata (EXIF incl. GPS, XMP, text chunks) from an uploaded image without++ * re-encoding it. Media used to do this by re-encoding with sharp; Community has no image++ * library, and the pixels don't need touching — only the side-channel segments do.++ *++ * JPEG — APP1 (EXIF/XMP) and APP13 (IPTC/Photoshop) segments and COM comments are removed;++ * APP0 (JFIF), APP2 (ICC colour profile) and APP14 (Adobe) stay.++ * PNG — eXIf, tEXt, zTXt, iTXt and tIME chunks are removed.++ * WebP — EXIF and XMP chunks are removed and the VP8X flags updated.++ * GIF — left alone (animations survive; GIF carries no EXIF).++ * Anything that doesn't parse cleanly is returned unchanged.++ */++++function stripJpeg(buf) {++ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return buf;++ const parts = [buf.subarray(0, 2)];++ let i = 2;++ while (i + 4 <= buf.length) {++ if (buf[i] !== 0xff) return buf;++ const marker = buf[i + 1];++ if (marker === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts); } // start of scan: rest is image data++ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue; }++ const len = buf.readUInt16BE(i + 2);++ if (len < 2 || i + 2 + len > buf.length) return buf;++ const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;++ if (!drop) parts.push(buf.subarray(i, i + 2 + len));++ i += 2 + len;++ }++ return buf;++}++++const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);++const PNG_DROP = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);++function stripPng(buf) {++ if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) return buf;++ const parts = [PNG_SIG];++ let i = 8;++ while (i + 12 <= buf.length) {++ const len = buf.readUInt32BE(i);++ const type = buf.toString('latin1', i + 4, i + 8);++ const end = i + 12 + len;++ if (end > buf.length) return buf;++ if (!PNG_DROP.has(type)) parts.push(buf.subarray(i, end));++ i = end;++ if (type === 'IEND') return Buffer.concat(parts);++ }++ return buf;++}++++function stripWebp(buf) {++ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf;++ const chunks = [];++ let i = 12;++ while (i + 8 <= buf.length) {++ const type = buf.toString('latin1', i, i + 4);++ const len = buf.readUInt32LE(i + 4);++ const end = i + 8 + len + (len & 1);++ if (i + 8 + len > buf.length) return buf;++ if (type !== 'EXIF' && type !== 'XMP ') chunks.push(Buffer.from(buf.subarray(i, Math.min(end, buf.length))));++ i = end;++ }++ for (const c of chunks) {++ // VP8X flags byte: bit 3 = EXIF present, bit 2 = XMP present.++ if (c.toString('latin1', 0, 4) === 'VP8X' && c.length > 8) c[8] &= ~0x0c;++ }++ const body = Buffer.concat(chunks);++ const head = Buffer.alloc(12);++ head.write('RIFF', 0, 'latin1');++ head.writeUInt32LE(body.length + 4, 4);++ head.write('WEBP', 8, 'latin1');++ return Buffer.concat([head, body]);++}++++function stripImageMetadata(buf, mime) {++ try {++ if (mime === 'image/jpeg') return stripJpeg(buf);++ if (mime === 'image/png') return stripPng(buf);++ if (mime === 'image/webp') return stripWebp(buf);++ } catch { /* fall through: unchanged */ }++ return buf;++}++++module.exports = { stripImageMetadata, stripJpeg, stripPng, stripWebp };nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++++/**++ * /api/pastes/* — Community's native paste API (PASTES_AUTHORITY=community).++ *++ * Same paths, bodies, status codes and response shapes the browser clients already use against++ * OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.++ *++ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)++ * ?needs_ai=1 — AI work queue, community.paste.moderate only++ * POST / create (JSON text paste, or multipart with a `screenshot` file)++ * GET /config limits++ * POST /screenshot create an image paste (multipart `screenshot`)++ * GET /by-user/:username someone's pastes (their own hidden ones for them)++ * GET /:slug one paste (counts a view unless ?no_view=1)++ * PUT /:slug DELETE /:slug owner or staff++ * GET /:slug/raw → /p/:slug/raw++ * GET /:slug/versions edit history (owner or staff)++ * POST /:slug/fork|like|copy++ * GET /:slug/comments POST /:slug/comments DELETE /:slug/comments/:id++ * Staff: GET /admin/stats, GET|DELETE /admin/forks, POST /bulk, POST /:slug/censor, POST /:slug/ai++ *++ * Callers (identity/viewer.js): browsers with the Network user JWT, anonymous browsers, and++ * first-party services with a Network service token. A service must hold the capability of the++ * route it calls: community.paste.create (create), community.paste.write (everything done as the++ * acting subject), community.paste.moderate (staff routes, needs_ai, X-OV-Staff). Anonymous writes++ * are limited to 20 per 10 minutes per address.++ */++const express = require('express');++const multer = require('multer');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const { PasteError } = require('./service');++const { hasCap } = require('../identity/viewer');++++const PASTE_CAPS = ['community.paste.create', 'community.paste.write', 'community.paste.moderate'];++++function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = null }) {++ const router = express.Router();++ router.use(contracts.http.middleware());++ router.use(viewers.middleware());++++ const json = express.json({ limit: '1mb' });++ const jsonErrors = (err, _req, res, next) => (err ? res.status(400).json({ error: 'Malformed JSON body' }) : next());++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 30 } }).single('screenshot');++ const withFile = (req, res, next) => upload(req, res, (err) => (err ? res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large' : (err.message || 'Upload failed') }) : next()));++ const body = (req, res, next) => (req.is('multipart/form-data') ? withFile(req, res, next) : json(req, res, (err) => jsonErrors(err, req, res, next)));++++ // Anonymous posts are allowed; people acting as themselves and services have their own limits.++ const anonWriteLimiter = sharedLimiter || rateLimit({++ windowMs: 10 * 60 * 1000,++ max: 20,++ standardHeaders: true,++ legacyHeaders: false,++ skip: (req) => !req.viewer || req.viewer.kind !== 'anonymous',++ message: { error: 'Too many anonymous posts — sign in or try again later' },++ });++++ /** A service must hold the route's capability; browsers are judged by identity instead. */++ const guard = (cap) => (req, res, next) => {++ const v = req.viewer;++ if (v.kind !== 'service') return next();++ const c = contracts.capabilities.check(v.claims, cap);++ if (c.allowed) return next();++ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++ };++ /** Reads: any community.paste.* grant (a service token with none of them isn't meant for us). */++ const anyCap = (req, res, next) => {++ const v = req.viewer;++ if (v.kind !== 'service' || PASTE_CAPS.some((c) => hasCap(v, c))) return next();++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'no community.paste capability granted', ctx: req.ov });++ };++ /** Staff-only routes: a service with community.paste.moderate, or an admin/global_mod browser. */++ const staffOnly = (req, res, next) => {++ const v = req.viewer;++ if (v.kind === 'service') return guard('community.paste.moderate')(req, res, next);++ if (v.kind === 'user' && v.staff) return next();++ if (v.kind === 'anonymous') return res.status(401).json({ error: 'Authentication required' });++ return res.status(403).json({ error: 'Admin access required' });++ };++ /** The AI pass (needs_ai queue, /:slug/ai) is for services only, never a browser — not even staff. */++ const serviceModerator = (req, res, next) => {++ if (req.viewer.kind !== 'service') return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'service token with community.paste.moderate required', ctx: req.ov });++ return guard('community.paste.moderate')(req, res, next);++ };++++ const ctx = (req) => ({ ip: req.ip, userAgent: req.get('user-agent') || '' });++ const run = (fn, status = 200) => async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out === undefined || res.headersSent) return;++ res.status(status).json(out);++ } catch (err) {++ if (err instanceof PasteError) return res.status(err.status).json(err.body);++ console.error('[Pastes]', err && err.stack ? err.stack : err);++ if (!res.headersSent) res.status(500).json({ error: 'Internal error' });++ }++ };++ const slug = (req) => req.params.slug;++++ // ── collection + literal paths (before /:slug) ───────────++ router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'++ ? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })++ : next()), run((req) => service.list(req.viewer, req.query)));++++ router.post('/', guard('community.paste.create'), anonWriteLimiter, body,++ run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));++++ router.get('/config', anyCap, run((req) => service.config(req.viewer)));++++ router.get('/admin/stats', staffOnly, run(() => service.stats()));++ router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));++ router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));++ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));++++ router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,++ run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));++++ router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));++++ // ── one paste ────────────────────────────────────────────++ router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));++ router.put('/:slug', guard('community.paste.write'), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));++ router.delete('/:slug', guard('community.paste.write'), run((req) => service.remove(req.viewer, slug(req))));++++ router.post('/:slug/censor', staffOnly, withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));++ router.post('/:slug/ai', serviceModerator, json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));++++ router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));++ router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));++++ router.post('/:slug/fork', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));++ router.post('/:slug/like', guard('community.paste.write'), run((req) => service.like(req.viewer, slug(req))));++ router.post('/:slug/copy', guard('community.paste.write'), run((req) => service.copy(req.viewer, slug(req))));++++ router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));++ router.post('/:slug/comments', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors,++ run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));++ // Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.++ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));++++ // Avatars belong to the Network account; Live's proxy set a Live-local avatar here.++ router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));++++ router.use((req, res) => res.status(404).json({ error: 'Not found' }));++ return router;++}++++module.exports = { createPastesApi };@@@ -8,11 +8,15 @@* public pastes (Media's page cap) in memory for a short while and answers trending,* related, language counts and the views/language browse modes from it. Everything here is* fetched anonymously, so nothing unlisted or private can ever appear on a shared page.++ *++ * With the community authority (source.local) the store answers and the window is kept only++ * LOCAL_TTL_MS — long enough that one page's latest/trending/languages share a single query.*/--const live = require('../live-client');++const source = require('./source');const WINDOW = 200; // Media's maximum page sizeconst TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live++const LOCAL_TTL_MS = 2000;const PER_PAGE = 24;let _recent = { at: 0, promise: null, pastes: [] };@@@ -25,8 +29,8 @@ function _textOnlyPublic(rows) {async function recent() {const now = Date.now();if (_recent.promise) return _recent.promise;-- if (now - _recent.at < TTL_MS) return _recent.pastes;-- _recent.promise = live.listPastes({ limit: WINDOW, offset: 0 })++ if (now - _recent.at < (source.local ? LOCAL_TTL_MS : TTL_MS)) return _recent.pastes;++ _recent.promise = source.listPastes({ limit: WINDOW, offset: 0 }).then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; }).catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });return _recent.promise;@@@ -76,13 +80,13 @@ async function browse(query = {}, ctx = {}) {let pastes, total, windowed = false;if (sort === 'new' && !lang) {-- const out = await live.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);++ const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);pastes = _textOnlyPublic(out && out.pastes);total = Number(out && out.total) || pastes.length;} else {let rows;if (q) {-- const out = await live.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);++ const out = await source.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);rows = _textOnlyPublic(out && out.pastes);} else {rows = await recent();nnew file mode 100644@@@ -0,0 +1,319 @@++'use strict';++++/**++ * Paste importer — OpenVibe.Media's paste export bundle → Community's store.++ *++ * Bundle (produced by Media, format openvibe.media.pastes-export v1):++ * { format, version, app, generated_at, since_id, max_id, sha256, counts: {pastes, comments, likes},++ * pastes: [...], comments: [...], likes: [...] }++ * sha256 is over JSON.stringify({ pastes, comments, likes }).++ *++ * Rules:++ * - The bundle is verified (format, version, counts, sha256) before anything is touched.++ * - Idempotent: rows are upserted by legacy_media_id, so a re-run changes nothing and a newer++ * bundle adds and updates. A paste deleted in Community stays deleted.++ * - Slugs, timestamps, counters, visibility, NSFW and AI fields are kept. Counters never go++ * down (Community may have counted views since the last run).++ * - Owners: bundle user_ids are Live user ids, except rows created before the id-space fix++ * (cutoff) which may hold a Network user id instead. Every id is resolved through the Network++ * (system=live, and system=network for pre-cutoff rows). Pre-cutoff with two different++ * answers → imported ownerless + hold 'ambiguous_owner'; no answer → ownerless + hold++ * 'owner_unmapped'. A like that can't be mapped is held and not imported.++ * - AI moment pastes (Live's AI jobs) get origin 'ai' and no owner: AI output is never++ * attributed to a person. Their stream goes into stream_ref.++ * - Every mapping lands in legacy_id_map; every run in migration_runs with a reconciliation report.++ * - dryRun does all of it inside a transaction and rolls back.++ */++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const store = require('./store');++++const FORMAT = 'openvibe.media.pastes-export';++const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';++// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image++// pastes; the frame file names Live's AI jobs upload are the remaining marker.++const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;++++class ImportError extends Error {}++class Rollback extends Error {}++++function toMs(ts) {++ if (!ts) return NaN;++ const s = String(ts);++ return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);++}++++function objectOf(v) {++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;++ if (typeof v === 'string') { try { const o = JSON.parse(v); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; } }++ return null;++}++++function isAiPaste(p) {++ const meta = objectOf(p.metadata);++ if (meta && meta.ai_moment === true) return true;++ return p.type === 'screenshot' && !!meta && typeof meta.original_name === 'string' && AI_FRAME_NAME.test(meta.original_name);++}++++/** Refuse a bundle that isn't exactly what it says it is. */++function verifyBundle(bundle) {++ if (!bundle || typeof bundle !== 'object') throw new ImportError('bundle is not a JSON object');++ if (bundle.format !== FORMAT) throw new ImportError(`unexpected format ${JSON.stringify(bundle.format)} (want ${FORMAT})`);++ if (bundle.version !== 1) throw new ImportError(`unsupported version ${JSON.stringify(bundle.version)} (want 1)`);++ for (const k of ['pastes', 'comments', 'likes']) {++ if (!Array.isArray(bundle[k])) throw new ImportError(`bundle.${k} is not an array`);++ const want = bundle.counts && bundle.counts[k];++ if (want !== bundle[k].length) throw new ImportError(`count mismatch for ${k}: counts says ${want}, bundle has ${bundle[k].length}`);++ }++ const sha = crypto.createHash('sha256').update(JSON.stringify({ pastes: bundle.pastes, comments: bundle.comments, likes: bundle.likes })).digest('hex');++ if (sha !== String(bundle.sha256 || '').toLowerCase()) throw new ImportError(`sha256 mismatch: bundle says ${bundle.sha256}, content is ${sha}`);++}++++/**++ * Import a verified bundle.++ * opts.resolveLegacy(system, ids) → Map<String(id), projection|null> (Network resolve-batch)++ * opts.cutoff ISO time of the id-space fix++ * opts.dryRun roll everything back at the end++ * opts.source label for migration_runs++ * → report++ */++async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, dryRun = false, source = 'media' } = {}) {++ verifyBundle(bundle);++ const cutoffMs = toMs(cutoff);++ if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);++ const startedAt = new Date().toISOString();++++ // ── 1. Resolve every legacy user id before the transaction (network calls) ──++ const liveIds = new Set();++ const preCutoffIds = new Set();++ const note = (userId, createdAt) => {++ if (userId == null) return;++ liveIds.add(String(userId));++ if (!(toMs(createdAt) >= cutoffMs)) preCutoffIds.add(String(userId));++ };++ for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);++ for (const c of bundle.comments) note(c.user_id, c.created_at);++ for (const l of bundle.likes) note(l.user_id, l.created_at);++ const subjectOf = (proj) => (proj && proj.subject && proj.subject.type === 'user' ? proj.subject.id : null);++ const liveMap = liveIds.size ? await resolveLegacy('live', [...liveIds]) : new Map();++ const netMap = preCutoffIds.size ? await resolveLegacy('network', [...preCutoffIds]) : new Map();++++ function owner(userId, createdAt) {++ if (userId == null) return { subject: null, hold: null };++ const key = String(userId);++ const live = subjectOf(liveMap.get(key));++ const pre = !(toMs(createdAt) >= cutoffMs);++ if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };++ const net = subjectOf(netMap.get(key));++ if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };++ if (live || net) return { subject: live || net, hold: null };++ return { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'pre-cutoff' } };++ }++++ const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });++ const report = {++ source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,++ cutoff, dry_run: !!dryRun,++ pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },++ comments: { ...blank(), bundle: bundle.comments.length },++ likes: { ...blank(), bundle: bundle.likes.length },++ holds: {},++ users: { live_ids: liveIds.size, live_mapped: [...liveMap.values()].filter(subjectOf).length, network_checked: preCutoffIds.size, network_mapped: [...netMap.values()].filter(subjectOf).length },++ };++ const countHold = (reason) => { report.holds[reason] = (report.holds[reason] || 0) + 1; };++++ const hold = db.prepare('INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)');++ const clearOwnerHolds = db.prepare("DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')");++ const byLegacy = db.prepare('SELECT * FROM pastes WHERE legacy_media_id = ?');++ const slugOwner = db.prepare('SELECT id, legacy_media_id FROM pastes WHERE slug = ?');++ const commentByLegacy = db.prepare('SELECT * FROM paste_comments WHERE legacy_media_id = ?');++++ const run = db.transaction(() => {++ // Users seen: record the mappings the Network gave us.++ for (const [id, proj] of liveMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'live', 'user', id, 'subject', s); }++ for (const [id, proj] of netMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'network', 'user', id, 'subject', s); }++++ // ── 2. Pastes ──++ const PASTE_TEXT_FIELDS = ['type', 'title', 'content', 'language', 'visibility', 'screenshot_url', 'media_ref', 'stream_ref', 'metadata',++ 'burn_after_read', 'pinned', 'is_nsfw', 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'origin', 'updated_at'];++ const COUNTERS = ['views', 'unique_views', 'copies', 'likes'];++ for (const p of bundle.pastes) {++ const ai = isAiPaste(p);++ if (ai) report.pastes.ai++;++ const who = ai ? { subject: null, hold: null } : owner(p.user_id, p.created_at);++ const meta = objectOf(p.metadata);++ const streamId = p.stream_id != null ? p.stream_id : (meta && meta.stream_id != null ? meta.stream_id : null);++ const row = {++ slug: String(p.slug),++ origin: ai ? 'ai' : 'user',++ type: p.type === 'screenshot' ? 'screenshot' : 'paste',++ title: store.sanitizeTitle(p.title),++ content: p.content == null ? null : String(p.content),++ language: p.language || 'text',++ visibility: ['public', 'unlisted', 'private'].includes(p.visibility) ? p.visibility : 'public',++ screenshot_url: p.screenshot_url || null,++ media_ref: p.type === 'screenshot' ? ids.legacyMediaId(bundle.app || 'live', 'paste', p.id) : null,++ stream_ref: streamId != null && streamId !== '' ? JSON.stringify({ service: 'live', type: 'stream', id: String(streamId) }) : null,++ metadata: meta ? JSON.stringify(meta) : null,++ burn_after_read: p.burn_after_read ? 1 : 0,++ pinned: p.pinned ? 1 : 0,++ is_nsfw: p.is_nsfw ? 1 : 0,++ ai_summary: p.ai_summary || null,++ ai_tags: p.ai_tags == null ? null : (typeof p.ai_tags === 'string' ? p.ai_tags : JSON.stringify(p.ai_tags)),++ ai_analyzed_at: p.ai_analyzed_at || null,++ views: Number(p.views) || 0, unique_views: Number(p.unique_views) || 0, copies: Number(p.copies) || 0, likes: Number(p.likes) || 0,++ created_at: p.created_at, updated_at: p.updated_at || p.created_at,++ };++ const existing = byLegacy.get(p.id);++ let communityId;++ if (!existing) {++ const clash = slugOwner.get(row.slug);++ if (clash) {++ hold.run('paste', String(p.id), 'slug_conflict', JSON.stringify({ slug: row.slug, community_id: clash.id }));++ countHold('slug_conflict'); report.pastes.skipped++;++ continue;++ }++ const cols = Object.keys(row).concat(['owner_subject', 'legacy_media_id', 'legacy_user_id']);++ const vals = Object.values(row).concat([who.subject, p.id, p.user_id == null ? null : Number(p.user_id)]);++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...vals);++ communityId = info.lastInsertRowid;++ report.pastes.inserted++;++ } else {++ communityId = existing.id;++ if (existing.deleted_at) { report.pastes.unchanged++; store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId); continue; }++ const patch = {};++ // Edited here since the last import? Then Community's text wins over the bundle's.++ const localNewer = existing.revision > 1 || toMs(existing.updated_at) > toMs(row.updated_at);++ if (!localNewer) for (const k of PASTE_TEXT_FIELDS) if (row[k] !== existing[k]) patch[k] = row[k];++ for (const k of COUNTERS) if (row[k] > existing[k]) patch[k] = row[k];++ if (who.subject && !existing.owner_subject) patch.owner_subject = who.subject;++ if (row.origin === 'ai' && existing.owner_subject && existing.origin !== 'ai') patch.owner_subject = null;++ const legacyUser = p.user_id == null ? null : Number(p.user_id);++ if (legacyUser !== existing.legacy_user_id) patch.legacy_user_id = legacyUser;++ const keys = Object.keys(patch);++ if (keys.length) {++ db.prepare(`UPDATE pastes SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), communityId);++ report.pastes.updated++;++ } else {++ report.pastes.unchanged++;++ }++ }++ store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId);++ if (who.hold) {++ hold.run('paste', String(p.id), who.hold, JSON.stringify(who.detail));++ countHold(who.hold); report.pastes.held++;++ } else {++ clearOwnerHolds.run('paste', String(p.id));++ }++ }++ // Forks point at Community ids (second pass: the original may come later in the bundle).++ for (const p of bundle.pastes) {++ if (p.forked_from == null) continue;++ const target = store.mapGet(db, 'media', 'paste', p.forked_from);++ const self = store.mapGet(db, 'media', 'paste', p.id);++ if (!self) continue;++ if (!target) { report.pastes.forks_unresolved++; continue; }++ const changed = db.prepare('UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?').run(Number(target.target_id), Number(self.target_id), Number(target.target_id)).changes;++ if (changed) report.pastes.forks_remapped++;++ }++++ // ── 3. Comments ──++ const comments = bundle.comments.slice().sort((a, b) => a.id - b.id);++ for (const c of comments) {++ const paste = store.mapGet(db, 'media', 'paste', c.paste_id);++ if (!paste) {++ hold.run('comment', String(c.id), 'paste_missing', JSON.stringify({ paste_id: c.paste_id }));++ countHold('paste_missing'); report.comments.skipped++;++ continue;++ }++ const who = owner(c.user_id, c.created_at);++ const row = {++ paste_id: Number(paste.target_id), anon_name: c.anon_name || null, message: String(c.message == null ? '' : c.message),++ is_deleted: c.is_deleted ? 1 : 0, created_at: c.created_at, updated_at: c.updated_at || c.created_at,++ };++ const existing = commentByLegacy.get(c.id);++ let cid;++ if (!existing) {++ const info = db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(row.paste_id, who.subject, row.anon_name, row.message, row.is_deleted, row.created_at, row.updated_at, c.id);++ cid = info.lastInsertRowid;++ report.comments.inserted++;++ } else {++ cid = existing.id;++ const patch = {};++ for (const k of ['message', 'anon_name', 'updated_at']) if (row[k] !== existing[k]) patch[k] = row[k];++ // Deletion is one-way: a comment removed on either side stays removed.++ if (row.is_deleted && !existing.is_deleted) patch.is_deleted = 1;++ if (who.subject && !existing.author_subject) patch.author_subject = who.subject;++ const keys = Object.keys(patch);++ if (keys.length) {++ db.prepare(`UPDATE paste_comments SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), cid);++ report.comments.updated++;++ } else {++ report.comments.unchanged++;++ }++ }++ store.mapSet(db, 'media', 'comment', c.id, 'comment', cid);++ if (who.hold) {++ hold.run('comment', String(c.id), who.hold, JSON.stringify(who.detail));++ countHold(who.hold); report.comments.held++;++ } else {++ clearOwnerHolds.run('comment', String(c.id));++ }++ }++ for (const c of comments) {++ if (c.parent_id == null) continue;++ const self = store.mapGet(db, 'media', 'comment', c.id);++ const parent = store.mapGet(db, 'media', 'comment', c.parent_id);++ if (self && parent) db.prepare('UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?').run(Number(parent.target_id), Number(self.target_id), Number(parent.target_id));++ }++++ // ── 4. Likes (only ever imported for a mapped person) ──++ for (const l of bundle.likes) {++ const key = `${l.paste_id}:${l.user_id}`;++ const paste = store.mapGet(db, 'media', 'paste', l.paste_id);++ if (!paste) {++ hold.run('like', key, 'paste_missing', JSON.stringify({ paste_id: l.paste_id, user_id: l.user_id }));++ countHold('paste_missing'); report.likes.skipped++;++ continue;++ }++ const who = l.user_id == null ? { subject: null, hold: 'owner_unmapped', detail: { user_id: null } } : owner(l.user_id, l.created_at);++ if (!who.subject) {++ hold.run('like', key, who.hold, JSON.stringify(who.detail));++ countHold(who.hold); report.likes.held++;++ continue;++ }++ clearOwnerHolds.run('like', key);++ const info = db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)').run(Number(paste.target_id), who.subject, l.created_at || null);++ if (info.changes) report.likes.inserted++; else report.likes.unchanged++;++ }++++ report.finished_at = new Date().toISOString();++ db.prepare('INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)')++ .run(source, startedAt, report.finished_at, JSON.stringify(report));++ if (dryRun) throw new Rollback();++ });++++ try { run(); } catch (err) { if (!(err instanceof Rollback)) throw err; }++ return report;++}++++/** Plain-text reconciliation table. */++function formatReport(r) {++ const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;++ const out = [++ `Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,++ ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; id-space cutoff ${r.cutoff}`,++ '',++ ` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,++ line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),++ '',++ ` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,++ ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} pre-cutoff ids checked as network ids (${r.users.network_mapped} mapped)`,++ ` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,++ ];++ return out.join('\n');++}++++module.exports = { importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };nnew file mode 100644@@@ -0,0 +1,611 @@++'use strict';++++/**++ * Paste service — the rules of the paste system, on top of store.js.++ *++ * This reproduces what OpenVibe.Media's paste API did behind OpenVibe.Live's /api/pastes proxy++ * (status codes, error texts, response shapes, limits), with identity reworked around Network++ * subjects:++ * - a "limited" caller is one acting as a person (a signed-in browser, or a service naming++ * X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as++ * they applied to Media's user callers. Anonymous writes are limited per address in api.js.++ * - private pastes are visible to their owner and to staff only; unlisted ones by slug or to++ * their owner. Nothing private is ever listed for anyone else.++ * - AI output (service + X-OV-Origin: ai) is stored with origin 'ai' and no owner.++ *++ * Every method takes the resolved viewer (identity/viewer.js) and returns a plain object for++ * the JSON response, or throws PasteError(status, body).++ */++const crypto = require('crypto');++const store = require('./store');++const { stripImageMetadata } = require('../media/strip-metadata');++const { capabilities } = require('openvibe-contracts');++++const DEFAULT_LIMITS = {++ maxSizeKb: 512,++ screenshotMaxSizeMb: 8,++ cooldownSeconds: 30,++ maxPerUserPerDay: 200,++ commentCooldownSeconds: 10,++ commentMaxLength: 2000,++ commentsPerMinute: 5,++ commentAnonAllowed: true,++ viewCooldownSec: 6 * 3600,++ viewEventsPerMinPerIp: 60,++};++const IMAGE_MIME = /^image\/(png|jpeg|webp|gif)$/;++const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;++// Literal /api/pastes/<word> routes: a paste with one of these slugs would be unreachable there.++const RESERVED_SLUGS = new Set(['config', 'screenshot', 'bulk', 'admin', 'by-user']);++const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;++const AI_DISPLAY_NAME = 'OpenVibe AI';++++class PasteError extends Error {++ constructor(status, body) {++ super(body && body.error);++ this.name = 'PasteError';++ this.status = status;++ this.body = body;++ }++}++const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };++++const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';++const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);++++function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }++++function createPasteService({ db, network = null, media = null, config = {}, limits = {} } = {}) {++ const L = { ...DEFAULT_LIMITS, ...limits };++ const visitorSecret = process.env.VIEW_HASH_SECRET++ || crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');++++ // ── who may do what ──────────────────────────────────────++ const isOwner = (v, p) => !!(v && v.subject && p.owner_subject && v.subject === p.owner_subject);++ const isStaff = (v) => !!(v && v.staff);++ const canSeeHidden = (v, p) => isOwner(v, p) || isStaff(v);++ // Media applied cooldowns/daily caps to callers acting as a person; not to anonymous app calls.++ const limited = (v) => !!(v && v.subject);++ const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);++ const isService = (v) => !!(v && v.kind === 'service');++ const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };++++ /**++ * A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone++ * but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).++ */++ function visible(v, slug) {++ const p = store.getBySlug(db, String(slug));++ if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');++ return p;++ }++++ // ── response shapes ──────────────────────────────────────++ function streamIdOf(ref) {++ return ref && ref.service === 'live' && ref.type === 'stream' && /^\d+$/.test(String(ref.id)) ? Number(ref.id) : null;++ }++++ function authorFields(subject, origin, projections) {++ if (origin === 'ai') return { username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null };++ const p = subject ? projections.get(subject) : null;++ return {++ username: p ? p.username : null,++ display_name: p ? (p.display_name || p.username) : null,++ avatar_url: p ? p.avatar_url : null,++ profile_color: p ? p.profile_color : null,++ };++ }++++ /**++ * Media's pastePublic shape plus the author projection. `user_id` carries the owner's subject++ * id — Community has no numeric user ids — so "user_id is falsy ⇒ anonymous" keeps working,++ * and a comparison against some service's numeric id never matches by accident.++ */++ function shape(p, v, projections) {++ const ref = parseJson(p.stream_ref);++ return {++ unique_views: p.unique_views || 0,++ id: p.id,++ app_id: 'community',++ slug: p.slug,++ user_id: p.owner_subject || null,++ owner_subject: p.owner_subject || null,++ origin: p.origin,++ type: p.type,++ title: p.title,++ content: p.content,++ language: p.language,++ visibility: p.visibility,++ stream_id: streamIdOf(ref),++ stream_ref: ref,++ screenshot_url: p.screenshot_url || null,++ metadata: p.metadata || null,++ burn_after_read: !!p.burn_after_read,++ forked_from: p.forked_from || null,++ pinned: !!p.pinned,++ views: p.views || 0,++ copies: p.copies || 0,++ likes: p.likes || 0,++ is_nsfw: !!p.is_nsfw,++ ai_summary: p.ai_summary || null,++ ai_tags: p.ai_tags || null,++ ai_analyzed_at: p.ai_analyzed_at || null,++ revision: p.revision,++ url: `/p/${p.slug}`,++ raw_url: `/p/${p.slug}/raw`,++ created_at: p.created_at,++ updated_at: p.updated_at,++ is_owner: isOwner(v, p),++ ...authorFields(p.owner_subject, p.origin, projections),++ };++ }++++ async function projectionsFor(subjects) {++ if (!network) return store.getProjections(db, subjects);++ try { return await network.projections(subjects); } catch { return store.getProjections(db, subjects); }++ }++++ async function shapeMany(rows, v, { preview = false } = {}) {++ const projections = await projectionsFor(rows.map((r) => r.owner_subject));++ return rows.map((r) => {++ const out = shape(r, v, projections);++ if (preview) out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists++ return out;++ });++ }++ async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }++++ function shapeComment(c, projections) {++ const out = {++ id: c.id, paste_id: c.paste_id, user_id: c.author_subject || null, author_subject: c.author_subject || null,++ parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.is_deleted,++ created_at: c.created_at, updated_at: c.updated_at,++ ...authorFields(c.author_subject, 'user', projections),++ };++ if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, projections)); out.reply_count = c.reply_count; }++ return out;++ }++++ // ── rate limits (acting persons) ─────────────────────────++ function pasteRateCheck(v) {++ if (!limited(v)) return;++ if (L.cooldownSeconds > 0) {++ const elapsed = (Date.now() - store.lastPasteTime(db, v.subject)) / 1000;++ if (elapsed < L.cooldownSeconds) {++ const wait = Math.ceil(L.cooldownSeconds - elapsed);++ fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });++ }++ }++ if (L.maxPerUserPerDay > 0 && store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {++ fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);++ }++ }++++ // Comment limits, per acting person (Media keyed them by address, which behind a proxy is the proxy).++ const recentComments = new Map(); // key → [{ at, message }] newest first++ function commentRateCheck(key, message) {++ const now = Date.now();++ const list = (recentComments.get(key) || []).filter((c) => now - c.at < 60_000);++ recentComments.set(key, list);++ if (list.length && now - list[0].at < L.commentCooldownSeconds * 1000) {++ fail(429, `Please wait ${Math.ceil((L.commentCooldownSeconds * 1000 - (now - list[0].at)) / 1000)}s before commenting again`);++ }++ if (list.length >= L.commentsPerMinute) fail(429, 'Too many comments. Please slow down.');++ if (list.length && list[0].message === message) fail(400, 'Duplicate comment');++ }++ function commentRecorded(key, message) {++ const list = recentComments.get(key) || [];++ list.unshift({ at: Date.now(), message });++ recentComments.set(key, list.slice(0, 20));++ }++++ // ── view counting (Media's rules: per visitor, cooldown, no owner/bot views) ──++ const ipBudget = new Map();++ function overBudget(ip) {++ const now = Date.now();++ const b = ipBudget.get(ip);++ if (!b || now - b.start >= 60_000) { ipBudget.set(ip, { start: now, n: 1 }); return false; }++ b.n += 1;++ return b.n > L.viewEventsPerMinPerIp;++ }++ const sweep = setInterval(() => {++ const now = Date.now();++ for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);++ for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);++ try { store.pruneVisits(db, 30); } catch { /* db closed in tests */ }++ }, 10 * 60_000);++ if (sweep.unref) sweep.unref();++++ function countView(v, p, { ip, userAgent } = {}) {++ if (BOT_UA.test(String(userAgent || ''))) return;++ if (overBudget(ip || 'unknown')) return;++ const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;++ const r = store.recordVisit(db, p.id, visitor, L.viewCooldownSec);++ p.views = r.views; p.unique_views = r.unique_views;++ }++++ /** Burn-after-read: the paste is gone the moment this read makes it spent. */++ function burn(p) {++ store.softDelete(db, p.id);++ fail(410, 'This paste has been burned after reading.');++ }++++ // ── validation helpers ───────────────────────────────────++ function visibilityOf(value, owned) {++ const vis = ['unlisted', 'private'].includes(value) ? value : 'public';++ // Nobody could ever open an ownerless private paste; it becomes unlisted instead.++ return vis === 'private' && !owned ? 'unlisted' : vis;++ }++++ function streamRefFor(v, body) {++ if (v.kind === 'service' && v.sourceRef) return JSON.stringify(v.sourceRef);++ const sid = body.stream_id;++ if (sid != null && /^\d{1,18}$/.test(String(sid))) return JSON.stringify({ service: 'live', type: 'stream', id: String(sid) });++ return null;++ }++++ /** Service callers may carry structured metadata (AI moments); browsers may not. */++ function serviceMetadata(v, body) {++ if (!isService(v) || body.metadata == null || body.metadata === '') return null;++ const obj = typeof body.metadata === 'string' ? parseJson(body.metadata) : body.metadata;++ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) fail(400, 'metadata must be a JSON object');++ const text = JSON.stringify(obj);++ if (text.length > 16 * 1024) fail(400, 'metadata too large (max 16 KB)');++ return obj;++ }++++ function aiFields(v, body) {++ if (!isService(v) || body.ai_summary == null) return {};++ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);++ return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };++ }++++ function slugFor(v, body) {++ if (!isService(v) || body.slug == null || body.slug === '') return store.generateSlug(db);++ const slug = String(body.slug);++ if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');++ if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');++ return slug;++ }++++ function created(row, extra = {}) {++ return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };++ }++++ // ═════════════════════════════════════════════════════════++ return {++ PasteError,++ limits: L,++++ /** GET /api/pastes */++ async list(v, q = {}) {++ const needsAi = truthy(q.needs_ai);++ if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');++ const limit = intIn(q.limit, 50, 1, 200);++ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);++ const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };++ // ?username= lists one person's pastes (Live resolved it to its own id; we go through++ // the projection cache). Their own unlisted/private ones only for them (or staff).++ if (q.username && q.username !== 'all') {++ const subject = store.subjectsByUsername(db, q.username)[0];++ if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };++ opts.ownerSubject = subject;++ if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;++ }++ const { rows, total } = store.listPastes(db, opts);++ return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };++ },++++ /** GET /api/pastes/by-user/:username */++ async byUser(v, username, q = {}) {++ const subject = store.subjectsByUsername(db, username)[0];++ if (!subject) fail(404, 'User not found');++ const limit = intIn(q.limit, 30, 1, 100);++ const includeHidden = v.subject === subject;++ const { rows, total } = store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });++ const proj = store.getProjections(db, [subject]).get(subject);++ return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };++ },++++ /** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */++ async get(v, slug, ctx = {}) {++ const p = visible(v, slug);++ if (!ctx.noView && !isOwner(v, p)) {++ if (p.burn_after_read) {++ p.views = store.bumpViews(db, p.id);++ if (p.views > 1) burn(p);++ } else {++ countView(v, p, ctx);++ }++ }++ const liked = v.subject ? store.hasLiked(db, p.id, v.subject) : false;++ return { paste: { ...(await shapeOne(p, v)), liked } };++ },++++ /** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */++ raw(v, slug, ctx = {}) {++ const p = store.getBySlug(db, String(slug));++ if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };++ if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');++ if (!isOwner(v, p)) {++ if (p.burn_after_read) {++ if (p.views > 0) burn(p);++ store.bumpViews(db, p.id);++ } else {++ countView(v, p, ctx);++ }++ }++ return { content: String(p.content || '') };++ },++++ /** GET /p/:slug/screenshot target (the stored Media URL). */++ screenshotUrl(v, slug) {++ const p = store.getBySlug(db, String(slug));++ if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');++ return p.screenshot_url;++ },++++ /** POST /api/pastes (text) */++ async createText(v, body = {}) {++ pasteRateCheck(v);++ const content = body.content;++ if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');++ if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);++ const metadata = serviceMetadata(v, body);++ const row = store.insertPaste(db, {++ slug: slugFor(v, body),++ owner_subject: v.subject || null,++ origin: v.origin === 'ai' ? 'ai' : 'user',++ type: 'paste',++ title: store.sanitizeTitle(body.title),++ content: content.trim(),++ language: store.detectLanguage(content, body.language),++ visibility: visibilityOf(body.visibility, !!v.subject),++ stream_ref: streamRefFor(v, body),++ metadata: metadata ? JSON.stringify(metadata) : null,++ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,++ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,++ ...aiFields(v, body),++ });++ return created(row, { paste: await shapeOne(row, v) });++ },++++ /** POST /api/pastes/screenshot (and multipart POST /api/pastes) */++ async createScreenshot(v, body = {}, file, ctx = {}) {++ pasteRateCheck(v);++ if (!file || !file.buffer) fail(400, 'No screenshot uploaded');++ if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');++ if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);++ if (!media) fail(503, 'Media service unavailable');++ const slug = slugFor(v, body);++ const extra = serviceMetadata(v, body);++ const bytes = stripImageMetadata(file.buffer, file.mimetype);++ let stored;++ try {++ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype });++ } catch (err) {++ console.warn('[Pastes] screenshot upload failed:', err.message);++ fail(502, 'Media service unavailable');++ }++ const metadata = {++ ...(extra || {}),++ page_url: body.page_url || (extra && extra.page_url) || null,++ user_agent: body.user_agent || ctx.userAgent || null,++ original_name: file.originalname || null,++ size_bytes: bytes.length,++ mime_type: file.mimetype,++ };++ const row = store.insertPaste(db, {++ slug,++ owner_subject: v.subject || null,++ origin: v.origin === 'ai' ? 'ai' : 'user',++ type: 'screenshot',++ title: store.sanitizeTitle(body.title || 'Screenshot'),++ content: String(body.description || body.content || ''),++ language: 'text',++ visibility: visibilityOf(body.visibility, !!v.subject),++ screenshot_url: stored.url,++ media_ref: stored.media_ref,++ stream_ref: streamRefFor(v, body),++ metadata: JSON.stringify(metadata),++ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,++ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,++ ...aiFields(v, body),++ });++ return created(row, { paste: await shapeOne(row, v) });++ },++++ /** PUT /api/pastes/:slug — owner (or staff) only. */++ async update(v, slug, body = {}) {++ needIdentity(v);++ const p = visible(v, slug);++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');++ const patch = {};++ if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);++ if (body.content !== undefined && p.type === 'paste') {++ const content = String(body.content);++ if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');++ patch.content = content;++ patch.language = store.detectLanguage(content, body.language);++ }++ if (body.visibility !== undefined) patch.visibility = visibilityOf(body.visibility, !!p.owner_subject);++ if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;++ if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;++ if (!Object.keys(patch).length) fail(400, 'Nothing to update');++ const row = store.updatePaste(db, p.id, patch, v.subject || null);++ return { paste: await shapeOne(row, v) };++ },++++ /** DELETE /api/pastes/:slug — owner (or staff) only. */++ remove(v, slug) {++ needIdentity(v);++ const p = visible(v, slug);++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');++ store.softDelete(db, p.id);++ return { success: true };++ },++++ /** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */++ versions(v, slug) {++ needIdentity(v);++ const p = visible(v, slug);++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');++ const list = store.listVersions(db, p.id);++ return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };++ },++++ /** POST /api/pastes/:slug/fork */++ async fork(v, slug) {++ const original = visible(v, slug);++ if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');++ pasteRateCheck(v);++ const row = store.insertPaste(db, {++ slug: store.generateSlug(db),++ owner_subject: v.subject || null,++ origin: v.origin === 'ai' ? 'ai' : 'user',++ type: 'paste',++ title: store.sanitizeTitle(`Fork of ${original.title}`),++ content: original.content,++ language: original.language,++ visibility: 'public',++ forked_from: original.id,++ });++ return created(row, { paste: await shapeOne(row, v) });++ },++++ /** POST /api/pastes/:slug/like — toggles; needs a person. */++ like(v, slug) {++ if (!v.subject) fail(401, 'Authentication required');++ const p = visible(v, slug);++ return store.toggleLike(db, p.id, v.subject);++ },++++ /** POST /api/pastes/:slug/copy */++ copy(v, slug) {++ const p = visible(v, slug);++ return { copies: store.incrementCopies(db, p.id) };++ },++++ /** GET /api/pastes/:slug/comments */++ async comments(v, slug, q = {}) {++ const p = visible(v, slug);++ const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);++ const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);++ const list = store.listComments(db, p.id, limit, offset);++ const subjects = [];++ for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }++ const projections = await projectionsFor(subjects);++ return { comments: list.map((c) => shapeComment(c, projections)), total: store.countComments(db, p.id) };++ },++++ /** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */++ async addComment(v, slug, body = {}, ctx = {}) {++ const p = visible(v, slug);++ const author = v.subject || null;++ if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');++ const message = String(body.message || '').trim();++ if (!message) fail(400, 'Comment cannot be empty');++ if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);++ let anonName = null;++ if (!author) {++ anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';++ anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';++ }++ const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;++ if (limited(v)) commentRateCheck(rateKey, message);++ const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;++ if (parentId) {++ const parent = store.getComment(db, parentId);++ if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');++ if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');++ }++ const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });++ if (limited(v)) commentRecorded(rateKey, message);++ return { comment: shapeComment(c, await projectionsFor([author])) };++ },++++ /** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */++ deleteComment(v, slug, commentId) {++ needIdentity(v);++ const p = visible(v, slug);++ const c = store.getComment(db, parseInt(commentId, 10));++ if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');++ const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);++ if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');++ store.softDeleteComment(db, c.id);++ return { message: 'Comment deleted' };++ },++++ /** GET /api/pastes/config */++ config(v) {++ return {++ maxSizeKb: L.maxSizeKb,++ screenshotMaxSizeMb: L.screenshotMaxSizeMb,++ cooldownSeconds: L.cooldownSeconds,++ maxPerUserPerDay: L.maxPerUserPerDay,++ todayCount: v.subject ? store.countOwnerSince(db, v.subject, '-1 day') : 0,++ };++ },++++ // ── staff (api.js has already checked the viewer is staff) ──++ stats() { return { stats: store.stats(db) }; },++++ forks(q = {}) {++ const limit = intIn(q.limit, 100, 1, 500);++ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);++ const { forks, total } = store.listForks(db, limit, offset);++ return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };++ },++++ deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },++++ bulk(body = {}) {++ const { slugs, action } = body;++ if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');++ if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');++ let done = 0, skipped = 0;++ db.transaction(() => {++ for (const slug of slugs.slice(0, 500)) {++ const p = store.getBySlug(db, String(slug));++ if (!p) { skipped++; continue; }++ if (action === 'delete') store.softDelete(db, p.id);++ else store.setVisibility(db, p.id, action);++ done++;++ }++ })();++ return { done, skipped };++ },++++ /** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */++ async censor(v, slugOrId, file) {++ let p = store.getBySlug(db, String(slugOrId));++ if (!p && /^\d+$/.test(String(slugOrId))) p = store.getById(db, parseInt(slugOrId, 10));++ if (!p) fail(404, 'Paste not found');++ if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');++ if (!file || !file.buffer) fail(400, 'Censored image is required');++ if (!/^image\/(png|jpeg|webp)$/.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, or WebP images allowed');++ if (!media) fail(503, 'Media service unavailable');++ let stored;++ try {++ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype });++ } catch (err) {++ console.warn('[Pastes] censor upload failed:', err.message);++ fail(502, 'Media service unavailable');++ }++ const row = store.setScreenshot(db, p.id, stored.url, stored.media_ref);++ return { paste: await shapeOne(row, v) };++ },++++ /** POST /api/pastes/:slug/ai — the AI pass writes its results back. */++ async setAi(v, slug, body = {}) {++ const p = store.getBySlug(db, String(slug));++ if (!p) fail(404, 'Paste not found');++ const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);++ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);++ const row = store.setAi(db, p.id, summary, tags);++ return { ok: true, paste: await shapeOne(row, v) };++ },++ };++}++++module.exports = { createPasteService, PasteError, DEFAULT_LIMITS, AI_DISPLAY_NAME };nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++++/**++ * Where server-rendered pages read pastes from.++ *++ * 'live' authority → live-client.js (OpenVibe.Live's API, Media behind it) — the default.++ * 'community' authority → this site's own store, through the same service the API uses, so a++ * page and the API can never disagree about visibility or view counts.++ *++ * Both expose the live-client call shapes (listPastes / getPaste / listByUser / createPaste);++ * `ctx` carries the visitor ({ token, ip, userAgent, viewer, noView }).++ */++const live = require('../live-client');++const { ANONYMOUS } = require('../identity/viewer');++++let current = { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };++++function localSource(service) {++ const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;++ return {++ local: true,++ service,++ listPastes: (query, ctx = {}) => service.list(who(ctx), query || {}),++ getPaste: async (slug, ctx = {}) => (await service.get(who(ctx), slug, { noView: !!ctx.noView, ip: ctx.ip, userAgent: ctx.userAgent })).paste,++ listByUser: (username, query, ctx = {}) => service.byUser(who(ctx), username, query || {}),++ createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),++ };++}++++/** Switch to the store (community authority); passing nothing goes back to Live. */++function use(service) {++ current = service ? localSource(service) : { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };++ return current;++}++++module.exports = {++ use,++ get local() { return current.local; },++ listPastes: (...a) => current.listPastes(...a),++ getPaste: (...a) => current.getPaste(...a),++ listByUser: (...a) => current.listByUser(...a),++ createPaste: (...a) => current.createPaste(...a),++};nnew file mode 100644@@@ -0,0 +1,393 @@++'use strict';++++/**++ * Paste store — pure database functions over Community's own tables (server/db.js).++ *++ * No HTTP, no identity resolution, no policy: every function takes the database handle first++ * and does exactly the SQL its name says (parameterized, always). Who may call what, rate++ * limits and response shaping live in service.js.++ *++ * Deleted pastes are soft-deleted (deleted_at set, content scrubbed) so their slug and legacy id++ * stay reserved and a re-import can never bring them back; every read below skips them.++ */++const crypto = require('crypto');++++// ── Slugs (the same adjective-noun-number style OpenVibe.Media has always minted) ──++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',++];++++/** A free adj-noun-NN slug. Deleted pastes keep theirs, so an old link never points somewhere new. */++function generateSlug(db) {++ const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');++ for (let i = 0; i < 10; i++) {++ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;++ if (!taken.get(slug)) return slug;++ }++ // ~180k combinations; if ten draws all collide, widen the number rather than fail.++ for (let i = 0; i < 10; i++) {++ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;++ if (!taken.get(slug)) return slug;++ }++ throw new Error('Could not generate a unique paste slug');++}++++function sanitizeTitle(title) {++ return String(title == null ? '' : title).trim().slice(0, 200) || 'Untitled';++}++++/** Media's language sniffing, unchanged: an explicit hint wins unless it is 'auto'. */++function detectLanguage(content, hint) {++ if (hint && hint !== 'auto') return String(hint).slice(0, 32);++ 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';++}++++const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);++++// ── Pastes ───────────────────────────────────────────────────++++function getBySlug(db, slug) {++ return db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;++}++++function getById(db, id) {++ return db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;++}++++const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',++ 'screenshot_url', 'media_ref', 'stream_ref', 'metadata', 'burn_after_read', 'forked_from', 'pinned', 'is_nsfw',++ 'ai_summary', 'ai_tags', 'ai_analyzed_at'];++++/** Insert a new paste (fields already validated); returns the stored row. */++function insertPaste(db, fields) {++ const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)++ .run(...cols.map((c) => fields[c]));++ return getById(db, info.lastInsertRowid);++}++++/**++ * Apply an edit. `patch` may hold title/content/language/visibility/is_nsfw/pinned. A change to++ * title, content or language bumps the revision and appends a paste_versions row; the first++ * edit also snapshots the original as revision 1, so the history is complete without storing a++ * copy of every paste that is never edited.++ */++function updatePaste(db, id, patch, editedBy = null) {++ return db.transaction(() => {++ const cur = getById(db, id);++ if (!cur) return null;++ const sets = [];++ const params = [];++ for (const k of ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned']) {++ if (patch[k] === undefined) continue;++ sets.push(`${k} = ?`);++ params.push(patch[k]);++ }++ const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);++ if (textChanged) {++ const hasCur = db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);++ if (!hasCur) {++ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);++ }++ sets.push('revision = revision + 1');++ }++ sets.push('updated_at = CURRENT_TIMESTAMP');++ db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);++ const next = getById(db, id);++ if (textChanged) {++ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')++ .run(id, next.revision, next.title, next.content, next.language, editedBy);++ }++ return next;++ })();++}++++function listVersions(db, pasteId) {++ return db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);++}++++/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */++function softDelete(db, id) {++ return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,++ ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;++}++++/**++ * List pastes.++ * opts.ownerSubject only this owner's pastes++ * opts.includeHidden also unlisted + private (only ever set for the owner or staff)++ * opts.type 'paste' | 'screenshot'++ * opts.search substring of title or content++ * opts.origin 'user' | 'ai' (default: all)++ * opts.needsAi pastes without an AI summary, any visibility (staff work queue)++ * opts.sort 'oldest' | newest (default)++ * Returns { rows, total }.++ */++function listPastes(db, opts = {}) {++ const where = ['deleted_at IS NULL'];++ const params = [];++ // The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.++ if (opts.needsAi) where.push("COALESCE(ai_summary, '') = '' AND ai_analyzed_at IS NULL");++ else if (!opts.includeHidden) where.push("visibility = 'public'");++ if (opts.origin === 'user' || opts.origin === 'ai' || opts.origin === 'imported') { where.push('origin = ?'); params.push(opts.origin); }++ if (opts.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }++ if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }++ if (opts.search) {++ const q = `%${likeEscape(opts.search)}%`;++ where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");++ params.push(q, q);++ }++ const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';++ const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);++ const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);++ const clause = where.join(' AND ');++ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)++ .all(...params, limit, offset);++ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);++ return { rows, total };++}++++/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */++function countOwnerSince(db, subject, sinceSql) {++ return db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;++}++++/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */++function lastPasteTime(db, subject) {++ const row = db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);++ return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;++}++++function hasLiked(db, pasteId, subject) {++ return !!db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);++}++++/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */++function toggleLike(db, pasteId, subject) {++ return db.transaction(() => {++ const already = hasLiked(db, pasteId, subject);++ if (already) db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);++ else db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id) VALUES (?, ?)').run(pasteId, subject);++ db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);++ return { liked: !already, likes: db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId).likes };++ })();++}++++function incrementCopies(db, pasteId) {++ db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);++ return db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId).copies;++}++++/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */++function bumpViews(db, pasteId) {++ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);++ return db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId).views;++}++++/**++ * Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a++ * unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }++ */++function recordVisit(db, pasteId, visitor, cooldownSec) {++ return db.transaction(() => {++ const row = db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);++ let counted = false, unique = false;++ if (!row) {++ db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);++ db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);++ counted = true; unique = true;++ } else {++ const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');++ if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {++ db.prepare('UPDATE paste_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);++ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);++ counted = true;++ }++ }++ const c = db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);++ return { counted, unique, views: c.views, unique_views: c.unique_views };++ })();++}++++function pruneVisits(db, days = 30) {++ return db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`).changes;++}++++function setAi(db, pasteId, summary, tags) {++ db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = CURRENT_TIMESTAMP WHERE id = ?').run(summary, tags, pasteId);++ return getById(db, pasteId);++}++++function setScreenshot(db, pasteId, url, mediaRef) {++ db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(url, mediaRef, pasteId);++ return getById(db, pasteId);++}++++function setVisibility(db, pasteId, visibility) {++ return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;++}++++/** Media's admin stats shape. */++function stats(db) {++ const r = db.prepare(`++ SELECT COUNT(*) AS total,++ SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes,++ SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,++ SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,++ COALESCE(SUM(views), 0) AS totalViews,++ COALESCE(SUM(copies), 0) AS totalCopies,++ COALESCE(SUM(likes), 0) AS totalLikes++ FROM pastes WHERE deleted_at IS NULL`).get() || {};++ return {++ total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,++ totalViews: r.totalViews || 0, totalCopies: r.totalCopies || 0, totalLikes: r.totalLikes || 0,++ };++}++++function listForks(db, limit, offset) {++ const forks = db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at++ FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL++ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);++ const total = db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get().c;++ return { forks, total };++}++++function deleteAllForks(db) {++ return db.transaction(() => {++ const ids = db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all().map((r) => r.id);++ for (const id of ids) softDelete(db, id);++ return ids.length;++ })();++}++++// ── Comments ─────────────────────────────────────────────────++++function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {++ const info = db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?)')++ .run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);++ return getComment(db, info.lastInsertRowid);++}++++function getComment(db, id) {++ return db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;++}++++/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */++function listComments(db, pasteId, limit = 50, offset = 0) {++ const top = db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL++ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);++ const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');++ for (const c of top) { c.replies = replies.all(c.id); c.reply_count = c.replies.length; }++ return top;++}++++function countComments(db, pasteId) {++ return db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId).c;++}++++function softDeleteComment(db, id) {++ return db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id).changes;++}++++// ── Subject projection (display cache — never authority) ─────++++function getProjections(db, subjectIds) {++ const out = new Map();++ const ids = [...new Set((subjectIds || []).filter(Boolean))];++ const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');++ for (const id of ids) { const r = stmt.get(id); if (r) out.set(id, r); }++ return out;++}++++/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */++function upsertProjection(db, p) {++ if (!p || !p.subject_id) return;++ db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)++ VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP)++ ON CONFLICT(subject_id) DO UPDATE SET++ username = COALESCE(excluded.username, username),++ display_name = COALESCE(excluded.display_name, display_name),++ avatar_url = COALESCE(excluded.avatar_url, avatar_url),++ profile_color = COALESCE(excluded.profile_color, profile_color),++ refreshed_at = CURRENT_TIMESTAMP`)++ .run({++ subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,++ avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,++ });++}++++/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */++function subjectsByUsername(db, username) {++ return db.prepare('SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC')++ .all(String(username)).map((r) => r.subject_id);++}++++// ── Legacy id map ────────────────────────────────────────────++++function mapGet(db, system, type, id) {++ return db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')++ .get(String(system), String(type), String(id)) || null;++}++++function mapSet(db, system, type, id, targetType, targetId) {++ db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)++ .run(String(system), String(type), String(id), String(targetType), String(targetId));++}++++module.exports = {++ generateSlug, sanitizeTitle, detectLanguage,++ getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,++ countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,++ setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,++ createComment, getComment, listComments, countComments, softDeleteComment,++ getProjections, upsertProjection, subjectsByUsername,++ mapGet, mapSet,++ SLUG_ADJECTIVES, SLUG_NOUNS,++};@@@ -217,7 +217,10 @@ function pastePage({ paste: p, related, user }) {const description = pasteDescription(p);const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);-- const isOwner = !!(user && p.user_id != null && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)));++ // Community-authority pastes name their owner by Network subject; Live-proxied ones by name.++ const isOwner = !!(user && (p.is_owner === true++ || (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)++ || (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));const hl = isShot ? null : highlight(p.content, p.language);const ext = extensionFor(p.language);@@@ -3,10 +3,17 @@const http = require('http');const mockLive = require('./mock-live');const mockNetwork = require('./mock-network');++const mockMedia = require('./mock-media');--async function boot() {++/**++ * opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a++ * fresh in-memory database, with Network and Media mocks behind it.++ * opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).++ */++async function boot(opts = {}) {const liveSrv = await mockLive.start();const netSrv = await mockNetwork.start();++ const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });process.env.NODE_ENV = 'test';process.env.BASE_URL = 'https://openvibe.community';process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;@@@ -19,9 +26,17 @@ async function boot() {process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.community/auth/callback';process.env.COOKIE_SECURE = 'true';process.env.TRUST_PROXY = '1';++ process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;++ process.env.PASTES_AUTHORITY = opts.authority || 'live';++ process.env.COMMUNITY_DB_PATH = ':memory:';for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];const { createApp } = require('../../server/app');-- const app = createApp();++ const appOpts = {};++ if (opts.authority === 'community') {++ appOpts.db = require('../../server/db').openDb(':memory:');++ appOpts.pasteLimits = opts.pasteLimits;++ }++ const app = createApp(appOpts);const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -41,8 +56,8 @@ async function boot() {return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };}return {-- app, base, get, jar, live: liveSrv, network: netSrv,-- close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); },++ app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,++ close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close(); },};}nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++/**++ * A stand-in for OpenVibe.Media's file store: POST /api/v1/community/files (multipart `file`)++ * with a Network service token for openvibe.media holding media.object.upload. Keeps each++ * upload's raw bytes so tests can check what left Community.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++function start({ publicPem, issuer }) {++ const uploads = [];++ let failNext = false;++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });++ if (!v.ok) return json(401, { code: v.code, error: v.reason });++ if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });++ if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }++ const body = Buffer.concat(chunks);++ // Pull the `file` part out of the multipart body.++ const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);++ const boundary = m && (m[1] || m[2]);++ const parts = body.toString('latin1').split(`--${boundary}`);++ const part = parts.find((p) => /name="file"/.test(p)) || '';++ const headerEnd = part.indexOf('\r\n\r\n');++ const head = part.slice(0, headerEnd);++ const bytes = Buffer.from(part.slice(headerEnd + 4, part.length - 2), 'latin1');++ const name = (head.match(/filename="([^"]*)"/) || [])[1] || 'upload.bin';++ const mime = ((head.match(/Content-Type:\s*([^\r\n]+)/i) || [])[1] || 'application/octet-stream').trim();++ const key = `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 12)}-${name}`;++ uploads.push({ key, name, mime, bytes, claims: v.claims });++ json(201, { key, url: `/f/${key}`, size: bytes.length, mime });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`,++ uploads,++ failNext: () => { failNext = true; },++ close: () => new Promise((r) => server.close(r)),++ })));++}++++module.exports = { start };@@@ -1,25 +1,54 @@'use strict';--/** A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair. */++/**++ * A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair.++ *++ * Also the Wave-1 service-principal surface Community uses in 'community' authority mode:++ * - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)++ * mint service tokens with the requested audience and scope as capabilities;++ * - POST /internal/identity/resolve-batch answers from `directory` and insists on a service++ * token for openvibe.network holding identity.subject.resolve.++ * signService() mints tokens as another service (e.g. Live) would present them to Community.++ */const http = require('http');const crypto = require('crypto');const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');function start() {const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });const grants = [];++ const resolveCalls = [];let issuer = 'http://network.test';++++ // Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus++ // legacy ids other services registered: legacy[system][id] = subject_id.++ const directory = { users: [], legacy: { live: {} }, down: false };++ function addUser(u) {++ const user = { subject_id: ids.newId('user'), display_name: u.username, avatar_url: null, ...u };++ directory.users.push(user);++ return user;++ }++ const projection = (u) => (u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.network_user_id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, banned: false } : null);++const server = http.createServer((req, res) => {const chunks = [];req.on('data', (c) => chunks.push(c));req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });if (req.url === '/oauth/token' && req.method === 'POST') {-- let body = {}; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }++ let body = {};++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));++ else { try { body = JSON.parse(raw); } catch { /* */ } }grants.push(body);if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });++ if (body.grant_type === 'client_credentials') {++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);++ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });++ }if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {@@@ -31,16 +60,44 @@ function start() {return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });}if (req.url === '/oauth/revoke') return json(200, { ok: true });++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return json(401, { code: v.code, error: v.reason });++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });++ let body = {}; try { body = JSON.parse(raw); } catch { /* */ }++ resolveCalls.push(body);++ if (directory.down) return json(503, { error: 'down' });++ const results = {};++ if (Array.isArray(body.subject_ids)) {++ for (const s of body.subject_ids) results[s] = projection(directory.users.find((u) => u.subject_id === s));++ } else {++ for (const id of body.ids || []) {++ const k = String(id);++ if (body.system === 'network') results[k] = projection(directory.users.find((u) => String(u.network_user_id) === k));++ else {++ const sid = (directory.legacy[body.system] || {})[k];++ results[k] = projection(sid ? directory.users.find((u) => u.subject_id === sid) : null);++ }++ }++ }++ return json(200, { results });++ }json(404, { error: 'not found' });});});function sign(claims, opts = {}) {return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });}++ /** A client-credentials service token (identity.service-token-claims@1). */++ function signService({ sub = 'svc:live', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, key);++ }return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {const url = `http://127.0.0.1:${server.address().port}`;issuer = url;-- resolve({ url, grants, sign, publicPem, close: () => new Promise((r) => server.close(r)) });++ resolve({ url, grants, resolveCalls, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });}));}nnew file mode 100644@@@ -0,0 +1,263 @@++'use strict';++/**++ * scripts/import-pastes.js + server/pastes/importer.js: bundle verification, owner mapping across++ * the id-space cutoff (ambiguous / unmapped → holds), AI pastes, fork and reply remapping, likes,++ * idempotent re-runs, newer bundles, dry runs, and the CLI's exit codes.++ */++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++const { openDb } = require('../server/db');++const { importBundle, ImportError } = require('../server/pastes/importer');++const { createNetworkIdentity } = require('../server/identity/network');++const mockNetwork = require('./helpers/mock-network');++const { check, done } = require('./helpers/app');++++const PRE = '2026-08-01 10:00:00'; // before the id-space fix (2026-08-20T03:00:26Z)++const POST = '2026-09-01 10:00:00';++++function seal(bundle) {++ const { pastes, comments, likes } = bundle;++ return {++ format: 'openvibe.media.pastes-export', version: 1, app: 'live', generated_at: '2026-09-22T12:00:00Z',++ since_id: 0, max_id: Math.max(0, ...pastes.map((p) => p.id)),++ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },++ sha256: crypto.createHash('sha256').update(JSON.stringify({ pastes, comments, likes })).digest('hex'),++ pastes, comments, likes,++ };++}++++const paste = (o) => ({++ id: 1, slug: 'amber-fox-11', user_id: null, type: 'paste', title: 'T', content: 'c', language: 'text', visibility: 'public',++ stream_id: null, screenshot_url: null, metadata: null, burn_after_read: 0, forked_from: null, pinned: 0, views: 0, unique_views: 0,++ copies: 0, likes: 0, is_nsfw: 0, ai_summary: null, ai_tags: null, ai_analyzed_at: null, created_at: POST, updated_at: POST, ...o,++});++++(async () => {++ const net = await mockNetwork.start();++ // A: network 7 = live 107. B: network 9 = live 109. C: live 9 (so "9" is ambiguous before the cutoff).++ const A = net.addUser({ network_user_id: 7, username: 'alex' });++ const B = net.addUser({ network_user_id: 9, username: 'sam' });++ const C = net.addUser({ network_user_id: 30, username: 'cleo' });++ net.directory.legacy.live = { 107: A.subject_id, 109: B.subject_id, 9: C.subject_id };++ const config = { networkInternalUrl: net.url, networkUrl: net.url, oauth: { clientId: 'community', clientSecret: 'shh' } };++++ const db = openDb(':memory:');++ const identity = createNetworkIdentity({ config, db });++ const run = (bundle, opts = {}) => importBundle(db, bundle, { resolveLegacy: identity.resolveLegacy, source: 'test', ...opts });++ const count = (table) => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c;++ const bySlug = (slug) => db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);++ const holds = () => db.prepare('SELECT source_type, source_id, reason FROM import_hold ORDER BY source_type, source_id, reason').all();++++ const pastes = [++ paste({ id: 6, slug: 'fork-of-one-66', user_id: 107, title: 'Fork of One', forked_from: 1 }), // fork listed before its original++ paste({ id: 1, slug: 'amber-fox-11', user_id: 107, title: 'One', content: 'hello', language: 'javascript', views: 50, unique_views: 40, copies: 3, likes: 2, visibility: 'public', is_nsfw: 1, ai_summary: 'greets', ai_tags: ['hi'], ai_analyzed_at: '2026-09-02 00:00:00', created_at: POST, updated_at: '2026-09-03 00:00:00' }),++ paste({ id: 2, slug: 'blue-lake-22', user_id: 7, created_at: PRE, updated_at: PRE, visibility: 'unlisted' }), // pre-cutoff, only the network mapping exists → A++ paste({ id: 3, slug: 'cold-ridge-33', user_id: 9, created_at: PRE, updated_at: PRE }), // pre-cutoff, live 9 = C, network 9 = B → ambiguous++ paste({ id: 4, slug: 'dark-owl-44', user_id: 555 }), // post-cutoff, unmapped++ paste({ id: 5, slug: 'wild-moment-5555', user_id: 109, type: 'screenshot', content: '', screenshot_url: 'https://openvibe.media/p/wild-moment-5555/screenshot', metadata: { ai_moment: true, stream_id: 77 }, ai_summary: 'hype' }),++ paste({ id: 7, slug: 'epic-frame-7777', user_id: 109, type: 'screenshot', screenshot_url: 'https://openvibe.media/p/epic-frame-7777/screenshot', stream_id: 12, metadata: { original_name: 'ai-moment-vod31-340.jpg', mime_type: 'image/jpeg' } }),++ paste({ id: 8, slug: 'gold-gem-88', user_id: 777, created_at: PRE, updated_at: PRE, visibility: 'private' }), // pre-cutoff, no mapping at all++ paste({ id: 9, slug: 'gray-dove-99', user_id: null, title: 'anon' }),++ ];++ const comments = [++ { id: 11, paste_id: 1, user_id: 107, parent_id: null, anon_name: null, message: 'nice', is_deleted: 0, created_at: POST, updated_at: POST },++ { id: 12, paste_id: 1, user_id: null, parent_id: 11, anon_name: 'Visitor', message: 'agreed', is_deleted: 0, created_at: POST, updated_at: POST },++ { id: 13, paste_id: 1, user_id: 555, parent_id: null, anon_name: null, message: 'who am i', is_deleted: 0, created_at: POST, updated_at: POST },++ { id: 14, paste_id: 99, user_id: 107, parent_id: null, anon_name: null, message: 'orphan', is_deleted: 0, created_at: POST, updated_at: POST },++ ];++ const likes = [++ { paste_id: 1, user_id: 107, created_at: POST },++ { paste_id: 1, user_id: 555, created_at: POST },++ { paste_id: 1, user_id: 9, created_at: PRE },++ ];++ const bundle = seal({ pastes, comments, likes });++++ await check('refuses a bundle whose format, version, counts or sha256 do not check out', async () => {++ for (const [mutate, re] of [++ [(b) => { b.sha256 = '0'.repeat(64); }, /sha256 mismatch/],++ [(b) => { b.counts.pastes += 1; }, /count mismatch for pastes/],++ [(b) => { b.format = 'something-else'; }, /unexpected format/],++ [(b) => { b.version = 2; }, /unsupported version/],++ [(b) => { b.pastes[0].title = 'tampered'; }, /sha256 mismatch/],++ ]) {++ const b = JSON.parse(JSON.stringify(bundle));++ mutate(b);++ await assert.rejects(run(b), (err) => err instanceof ImportError && re.test(err.message));++ }++ assert.strictEqual(count('pastes'), 0);++ });++++ await check('--dry-run reports everything and leaves the database untouched', async () => {++ const r = await run(bundle, { dryRun: true });++ assert.strictEqual(r.dry_run, true);++ assert.strictEqual(r.pastes.inserted, 9);++ for (const t of ['pastes', 'paste_comments', 'paste_likes', 'import_hold', 'legacy_id_map', 'migration_runs']) assert.strictEqual(count(t), 0, t);++ });++++ let first;++ await check('import: counters, timestamps, visibility, NSFW and AI fields kept; owners mapped across the cutoff', async () => {++ first = await run(bundle);++ assert.deepStrictEqual(first.pastes, { bundle: 9, inserted: 9, updated: 0, unchanged: 0, held: 3, skipped: 0, ai: 2, forks_remapped: 1, forks_unresolved: 0 });++ const one = bySlug('amber-fox-11');++ assert.strictEqual(one.owner_subject, A.subject_id);++ assert.strictEqual(one.legacy_media_id, 1);++ assert.strictEqual(one.legacy_user_id, 107);++ assert.strictEqual(one.views, 50);++ assert.strictEqual(one.unique_views, 40);++ assert.strictEqual(one.copies, 3);++ assert.strictEqual(one.likes, 2);++ assert.strictEqual(one.is_nsfw, 1);++ assert.strictEqual(one.ai_tags, '["hi"]');++ assert.strictEqual(one.created_at, POST);++ assert.strictEqual(one.updated_at, '2026-09-03 00:00:00');++ assert.strictEqual(one.origin, 'user');++ assert.strictEqual(bySlug('blue-lake-22').owner_subject, A.subject_id, 'pre-cutoff network id');++ assert.strictEqual(bySlug('blue-lake-22').visibility, 'unlisted');++ assert.strictEqual(bySlug('cold-ridge-33').owner_subject, null, 'ambiguous');++ assert.strictEqual(bySlug('cold-ridge-33').legacy_user_id, 9);++ assert.strictEqual(bySlug('dark-owl-44').owner_subject, null, 'unmapped');++ assert.strictEqual(bySlug('gold-gem-88').owner_subject, null);++ assert.strictEqual(bySlug('gray-dove-99').owner_subject, null, 'anonymous is not a hold');++ assert.strictEqual(bySlug('fork-of-one-66').forked_from, one.id, 'forked_from remapped to the Community id');++ });++++ await check('AI moment pastes: origin ai, no owner, stream in stream_ref, screenshot link kept, no hold', async () => {++ const ai = bySlug('wild-moment-5555');++ assert.strictEqual(ai.origin, 'ai');++ assert.strictEqual(ai.owner_subject, null);++ assert.strictEqual(ai.legacy_user_id, 109);++ assert.deepStrictEqual(JSON.parse(ai.stream_ref), { service: 'live', type: 'stream', id: '77' });++ assert.strictEqual(ai.screenshot_url, 'https://openvibe.media/p/wild-moment-5555/screenshot');++ assert.strictEqual(ai.media_ref, 'legacy:live:paste:5');++ const byName = bySlug('epic-frame-7777');++ assert.strictEqual(byName.origin, 'ai', 'recognised by the AI frame file name Media kept');++ assert.deepStrictEqual(JSON.parse(byName.stream_ref), { service: 'live', type: 'stream', id: '12' });++ assert.ok(!holds().some((h) => h.source_id === '5' || h.source_id === '7'));++ });++++ await check('holds: ambiguous / unmapped owners, unmappable likes and orphan comments', async () => {++ assert.deepStrictEqual(holds(), [++ { source_type: 'comment', source_id: '13', reason: 'owner_unmapped' },++ { source_type: 'comment', source_id: '14', reason: 'paste_missing' },++ { source_type: 'like', source_id: '1:555', reason: 'owner_unmapped' },++ { source_type: 'like', source_id: '1:9', reason: 'ambiguous_owner' },++ { source_type: 'paste', source_id: '3', reason: 'ambiguous_owner' },++ { source_type: 'paste', source_id: '4', reason: 'owner_unmapped' },++ { source_type: 'paste', source_id: '8', reason: 'owner_unmapped' },++ ]);++ const detail = JSON.parse(db.prepare("SELECT detail FROM import_hold WHERE source_type = 'paste' AND source_id = '3'").get().detail);++ assert.strictEqual(detail.live_subject, C.subject_id);++ assert.strictEqual(detail.network_subject, B.subject_id);++ assert.deepStrictEqual(first.holds, { ambiguous_owner: 2, owner_unmapped: 4, paste_missing: 1 });++ });++++ await check('comments and likes: authors mapped, reply parents remapped, only mappable likes imported', async () => {++ const cs = db.prepare('SELECT * FROM paste_comments ORDER BY legacy_media_id').all();++ assert.strictEqual(cs.length, 3);++ const [c11, c12, c13] = cs;++ assert.strictEqual(c11.author_subject, A.subject_id);++ assert.strictEqual(c12.anon_name, 'Visitor');++ assert.strictEqual(c12.parent_id, c11.id);++ assert.strictEqual(c13.author_subject, null);++ assert.deepStrictEqual(first.comments, { bundle: 4, inserted: 3, updated: 0, unchanged: 0, held: 1, skipped: 1 });++ const ls = db.prepare('SELECT subject_id FROM paste_likes').all();++ assert.deepStrictEqual(ls, [{ subject_id: A.subject_id }]);++ assert.deepStrictEqual(first.likes, { bundle: 3, inserted: 1, updated: 0, unchanged: 0, held: 2, skipped: 0 });++ });++++ await check('mappings and the run are recorded', async () => {++ const m = (sys, type, id) => db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(sys, type, id);++ assert.deepStrictEqual(m('media', 'paste', '1'), { target_type: 'paste', target_id: String(bySlug('amber-fox-11').id) });++ assert.deepStrictEqual(m('live', 'user', '107'), { target_type: 'subject', target_id: A.subject_id });++ assert.deepStrictEqual(m('network', 'user', '7'), { target_type: 'subject', target_id: A.subject_id });++ assert.ok(m('media', 'comment', '12'));++ const runs = db.prepare('SELECT * FROM migration_runs').all();++ assert.strictEqual(runs.length, 1);++ assert.strictEqual(JSON.parse(runs[0].report).pastes.inserted, 9);++ assert.ok(runs[0].finished_at);++ });++++ await check('re-running the same bundle changes nothing', async () => {++ const before = { pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') };++ const again = await run(bundle);++ assert.deepStrictEqual([again.pastes.inserted, again.pastes.updated, again.pastes.unchanged], [0, 0, 9]);++ assert.deepStrictEqual([again.comments.inserted, again.comments.updated, again.comments.unchanged], [0, 0, 3]);++ assert.deepStrictEqual([again.likes.inserted, again.likes.unchanged, again.likes.held], [0, 1, 2]);++ assert.deepStrictEqual({ pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') }, before);++ assert.strictEqual(count('migration_runs'), 2);++ });++++ await check('a newer bundle adds and updates; Community-side edits, counts and deletes are not undone; late mappings clear holds', async () => {++ // Community-side activity since the first import.++ db.prepare("UPDATE pastes SET views = 80 WHERE slug = 'amber-fox-11'").run();++ const store = require('../server/pastes/store');++ store.updatePaste(db, bySlug('blue-lake-22').id, { content: 'edited in Community' }, A.subject_id);++ store.softDelete(db, bySlug('gray-dove-99').id);++ // Someone registered live user 555 with the Network in the meantime.++ const D = net.addUser({ network_user_id: 40, username: 'dana' });++ net.directory.legacy.live[555] = D.subject_id;++++ const newer = seal({++ pastes: pastes.map((p) => (p.id === 1 ? { ...p, title: 'One (renamed on Live)', views: 60, updated_at: '2026-09-10 00:00:00' }++ : p.id === 2 ? { ...p, content: 'changed on Live' } : p))++ .concat([paste({ id: 10, slug: 'new-wave-10', user_id: 109, created_at: '2026-09-20 00:00:00', updated_at: '2026-09-20 00:00:00' })]),++ comments: comments.concat([{ id: 15, paste_id: 10, user_id: 109, parent_id: null, anon_name: null, message: 'fresh', is_deleted: 0, created_at: POST, updated_at: POST }]),++ likes,++ });++ const r = await run(newer);++ assert.strictEqual(r.pastes.inserted, 1);++ assert.strictEqual(r.comments.inserted, 1);++ const one = bySlug('amber-fox-11');++ assert.strictEqual(one.title, 'One (renamed on Live)');++ assert.strictEqual(one.views, 80, 'counters never go down');++ assert.strictEqual(bySlug('blue-lake-22').content, 'edited in Community', 'local edits win');++ assert.ok(bySlug('gray-dove-99').deleted_at, 'deleted stays deleted');++ assert.strictEqual(bySlug('new-wave-10').owner_subject, B.subject_id);++ assert.strictEqual(bySlug('dark-owl-44').owner_subject, D.subject_id, 'late mapping fills the owner');++ assert.ok(!holds().some((h) => h.source_type === 'paste' && h.source_id === '4'), 'and clears its hold');++ assert.ok(!holds().some((h) => h.source_type === 'like' && h.source_id === '1:555'));++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM paste_likes').get().c, 2);++ assert.strictEqual(db.prepare("SELECT author_subject FROM paste_comments WHERE legacy_media_id = 13").get().author_subject, D.subject_id);++ assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');++ });++++ await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));++ const dbPath = path.join(dir, 'c.db');++ const good = path.join(dir, 'good.json');++ const bad = path.join(dir, 'bad.json');++ fs.writeFileSync(good, JSON.stringify(bundle));++ fs.writeFileSync(bad, JSON.stringify({ ...bundle, sha256: 'f'.repeat(64) }));++ const env = { ...process.env, COMMUNITY_DB_PATH: dbPath, OV_NETWORK_INTERNAL_URL: net.url, OV_NETWORK_URL: net.url, OV_OAUTH_CLIENT_ID: 'community', OV_OAUTH_CLIENT_SECRET: 'shh' };++ const script = path.join(__dirname, '..', 'scripts', 'import-pastes.js');++ const refused = spawnSync(process.execPath, [script, bad], { env, encoding: 'utf8' });++ assert.strictEqual(refused.status, 2, refused.stderr);++ assert.match(refused.stderr, /Refused: sha256 mismatch/);++ // Run the child asynchronously: the mock Network lives in this process's event loop.++ const { spawn } = require('child_process');++ const out = await new Promise((resolve) => {++ const child = spawn(process.execPath, [script, good, '--dry-run', '--id-fix-cutoff', '2026-08-20T03:00:26Z'], { env });++ let stdout = '', stderr = '';++ child.stdout.on('data', (c) => { stdout += c; });++ child.stderr.on('data', (c) => { stderr += c; });++ child.on('close', (code) => resolve({ code, stdout, stderr }));++ });++ assert.strictEqual(out.code, 0, out.stderr);++ assert.match(out.stdout, /DRY RUN — rolled back/);++ // Live user 555 is mapped by now (previous check), so two pastes are held, not three.++ assert.match(out.stdout, /pastes\s+9\s+9\s+0\s+0\s+2\s+0/, out.stdout);++ const check2 = openDb(dbPath);++ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM pastes').get().c, 0);++ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM subject_projection').get().c, 0, 'not even the display cache');++ check2.close();++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ db.close();++ await net.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,323 @@++'use strict';++/**++ * Native /api/pastes (PASTES_AUTHORITY=community): who may call what.++ * Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /++ * X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via++ * the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.++ */++const assert = require('assert');++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });++ const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });++ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });++ const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';++++ const call = (path, { method = 'GET', token, cookie, headers = {}, json, body, ip } = {}) => {++ const h = { ...headers };++ if (token) h.authorization = `Bearer ${token}`;++ if (ip) h['x-forwarded-for'] = ip;++ let b = body;++ if (json !== undefined) { h['content-type'] = 'application/json'; b = JSON.stringify(json); }++ return t.get(path, { method, headers: h, body: b, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++++ let alexSlug;++++ await check('browser create: owner from the JWT subject; body identity fields and X-OV-* headers ignored', async () => {++ const r = await call('/api/pastes', {++ method: 'POST', cookie: alexJwt, ip: '192.0.2.10',++ headers: { 'x-ov-subject': sam.subject_id, 'x-ov-origin': 'ai' },++ json: { title: 'Mine', content: 'print(1)', language: 'python', user_id: 9, owner_subject: sam.subject_id, author_id: 9, origin: 'ai', slug: 'my-chosen-slug' },++ });++ assert.strictEqual(r.status, 201, r.text);++ const out = r.json();++ assert.ok(out.id && out.slug && out.url === `/p/${out.slug}` && out.paste);++ assert.notStrictEqual(out.slug, 'my-chosen-slug', 'browsers never pick slugs');++ assert.strictEqual(out.paste.owner_subject, alex.subject_id);++ assert.strictEqual(out.paste.origin, 'user');++ assert.strictEqual(out.paste.username, 'alex');++ assert.strictEqual(out.paste.profile_color, '#0af', 'JWT claims feed the projection');++ alexSlug = out.slug;++ });++++ await check('an older JWT without subject_id is resolved through the Network once, then cached', async () => {++ const before = net.resolveCalls.length;++ const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().paste.owner_subject, sam.subject_id);++ const lookups = net.resolveCalls.slice(before);++ assert.deepStrictEqual(lookups[0], { system: 'network', type: 'user', ids: ['9'] });++ await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'again' } });++ assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 1, 'cached in legacy_id_map');++ const grant = net.grants.find((g) => g.grant_type === 'client_credentials' && g.audience === 'openvibe.network');++ assert.strictEqual(grant.scope, 'identity.subject.resolve');++ });++++ await check('service token: create as the acting subject (X-OV-Subject)', async () => {++ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'via live', user_id: 9 } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().paste.owner_subject, alex.subject_id);++ });++++ await check('service token denials: wrong audience, bad signature, expired, missing capability, bad X-OV-Subject', async () => {++ const wrongAud = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { aud: ['openvibe.live'] }), json: { content: 'x' } });++ assert.strictEqual(wrongAud.status, 401);++ assert.strictEqual(wrongAud.json().code, 'token.wrong_audience');++ assert.match(wrongAud.headers.get('content-type'), /application\/problem\+json/);++ const otherKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });++ const forged = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { key: otherKey }), json: { content: 'x' } });++ assert.strictEqual(forged.status, 401);++ assert.strictEqual(forged.json().code, 'token.bad_signature');++ const expired = await call('/api/pastes', { token: svc([CREATE], { expSec: -120 }) });++ assert.strictEqual(expired.json().code, 'token.expired');++ const noCap = await call('/api/pastes', { method: 'POST', token: svc([WRITE]), json: { content: 'x' } });++ assert.strictEqual(noCap.status, 403);++ assert.strictEqual(noCap.json().code, 'capability.denied');++ const noPasteCaps = await call('/api/pastes', { token: svc(['chat.message.send']) });++ assert.strictEqual(noPasteCaps.status, 403, 'a token for something else is not a pass to read as someone');++ const badSubject = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': '7' }, json: { content: 'x' } });++ assert.strictEqual(badSubject.status, 400);++ assert.strictEqual(badSubject.json().code, 'subject.invalid');++ const badRef = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-source-ref': '{"service":"live"}' }, json: { content: 'x' } });++ assert.strictEqual(badRef.status, 400);++ });++++ await check('service without X-OV-Subject writes anonymously; X-OV-Origin: ai → origin ai, no owner, stream ref kept', async () => {++ const anon = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'nobody' } });++ assert.strictEqual(anon.json().paste.owner_subject, null);++ assert.strictEqual(anon.json().paste.origin, 'user');++ const ref = { service: 'live', type: 'stream', id: '42' };++ const ai = await call('/api/pastes', {++ method: 'POST', token: svc([CREATE]),++ headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id, 'x-ov-source-ref': JSON.stringify(ref) },++ json: { title: 'A wild moment', content: 'the chat exploded', slug: 'wild-moment-4242', metadata: { ai_moment: true, stream_id: 42 }, ai_summary: 'chat exploded', ai_tags: ['hype'] },++ });++ assert.strictEqual(ai.status, 201, ai.text);++ const p = ai.json().paste;++ assert.strictEqual(p.slug, 'wild-moment-4242', 'services may bring their own slug');++ assert.strictEqual(p.origin, 'ai');++ assert.strictEqual(p.owner_subject, null, 'AI output is never attributed to a person');++ assert.strictEqual(p.user_id, null);++ assert.strictEqual(p.display_name, 'OpenVibe AI');++ assert.deepStrictEqual(p.stream_ref, ref);++ assert.strictEqual(p.stream_id, 42);++ assert.deepStrictEqual(JSON.parse(p.metadata), { ai_moment: true, stream_id: 42 });++ assert.strictEqual(p.ai_summary, 'chat exploded');++ const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });++ assert.strictEqual(dup.status, 409);++ const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });++ assert.strictEqual(bad.status, 400);++ const onlyAi = (await call('/api/pastes?origin=ai')).json();++ assert.ok(onlyAi.pastes.length >= 1 && onlyAi.pastes.every((x) => x.origin === 'ai'));++ const browserMeta = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'meta?', metadata: { ai_moment: true } } });++ assert.strictEqual(browserMeta.json().paste.metadata, null, 'browsers cannot set metadata');++ });++++ await check('write capability: edits as the acting subject are owner-checked by Community', async () => {++ const asAlex = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Edited via Live' } });++ assert.strictEqual(asAlex.status, 200, asAlex.text);++ assert.strictEqual(asAlex.json().paste.title, 'Edited via Live');++ const asSam = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'hijack' } });++ assert.strictEqual(asSam.status, 403);++ assert.deepStrictEqual(asSam.json(), { error: 'Not authorized for this paste' });++ const noSubject = await call(`/api/pastes/${alexSlug}`, { method: 'DELETE', token: svc([WRITE]) });++ assert.strictEqual(noSubject.status, 401);++ const createOnly = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'x' } });++ assert.strictEqual(createOnly.status, 403);++ assert.strictEqual(createOnly.json().code, 'capability.denied');++ });++++ await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {++ const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samOldJwt, json: { message: 'spam spam' } })).json().comment;++ assert.strictEqual(c.username, 'sam');++ const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });++ assert.strictEqual(notStaff.status, 403);++ const fakeStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-staff': '1' } });++ assert.strictEqual(fakeStaff.status, 403);++ assert.strictEqual(fakeStaff.json().code, 'capability.denied');++ const staff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE, MOD]), headers: { 'x-ov-staff': '1', 'x-ov-subject': kim.subject_id } });++ assert.strictEqual(staff.status, 200, staff.text);++ assert.deepStrictEqual(staff.json(), { message: 'Comment deleted' });++ const bulkUser = await call('/api/pastes/bulk', { method: 'POST', cookie: alexJwt, json: { slugs: [alexSlug], action: 'private' } });++ assert.strictEqual(bulkUser.status, 403);++ const bulkAdmin = await call('/api/pastes/admin/stats', { cookie: adminJwt });++ assert.strictEqual(bulkAdmin.status, 200);++ assert.ok(bulkAdmin.json().stats.total > 0);++ const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });++ assert.strictEqual(bulkSvc.status, 403);++ });++++ await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {++ const hidden = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'hidden work', visibility: 'private' } })).json().slug;++ const q = await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) });++ assert.strictEqual(q.status, 200, q.text);++ const queue = q.json().pastes.map((p) => p.slug);++ assert.ok(queue.includes(hidden), 'the queue spans visibility');++ assert.ok(!queue.includes('wild-moment-4242'), 'already annotated');++ for (const who of [{ cookie: adminJwt }, {}, { token: svc([WRITE, CREATE]) }]) {++ const denied = await call('/api/pastes?needs_ai=1', who);++ assert.strictEqual(denied.status, 403, `needs_ai refused for ${JSON.stringify(Object.keys(who))}`);++ const write = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', json: { ai_summary: 'x' }, ...who });++ assert.strictEqual(write.status, 403);++ }++ const w = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', token: svc([MOD]), json: { ai_summary: 'a secret note', ai_tags: ['notes', 'private'] } });++ assert.strictEqual(w.status, 200, w.text);++ assert.strictEqual(w.json().ok, true);++ assert.strictEqual(w.json().paste.ai_summary, 'a secret note');++ assert.strictEqual(w.json().paste.ai_tags, '["notes","private"]');++ assert.ok(w.json().paste.ai_analyzed_at);++ const again = (await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) })).json().pastes.map((p) => p.slug);++ assert.ok(!again.includes(hidden));++ });++++ await check('author display: unknown subjects are resolved through resolve-batch; stale ones refresh', async () => {++ const before = net.resolveCalls.length;++ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': kim.subject_id }, json: { content: 'kim was here' } });++ const p = r.json().paste;++ assert.strictEqual(p.username, 'kim');++ assert.strictEqual(p.display_name, 'Kim');++ assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');++ assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));++ kim.display_name = 'Kim Renamed';++ t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);++ const stale = await call(`/api/pastes/${p.slug}?no_view=1`);++ assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');++ await new Promise((res) => setTimeout(res, 100));++ const fresh = await call(`/api/pastes/${p.slug}?no_view=1`);++ assert.strictEqual(fresh.json().paste.display_name, 'Kim Renamed');++ net.directory.down = true;++ const ghost = ids.newId('user');++ const down = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': ghost }, json: { content: 'network down' } });++ assert.strictEqual(down.status, 201, 'a Network outage costs names, not the write');++ assert.strictEqual(down.json().paste.username, null);++ net.directory.down = false;++ });++++ await check('anonymous writes: 20 per 10 minutes per address; signed-in and other addresses unaffected', async () => {++ const ip = '203.0.113.77';++ for (let i = 0; i < 20; i++) {++ const r = await call('/api/pastes', { method: 'POST', ip, json: { content: `anon ${i}` } });++ assert.strictEqual(r.status, 201, `#${i}: ${r.text}`);++ }++ const over = await call('/api/pastes', { method: 'POST', ip, json: { content: 'one too many' } });++ assert.strictEqual(over.status, 429);++ assert.deepStrictEqual(over.json(), { error: 'Too many anonymous posts — sign in or try again later' });++ const comment = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip, json: { message: 'also limited' } });++ assert.strictEqual(comment.status, 429, 'comments share the budget');++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, cookie: alexJwt, json: { content: 'signed in' } })).status, 201);++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip: '203.0.113.78', json: { content: 'elsewhere' } })).status, 201);++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, token: svc([CREATE]), json: { content: 'service' } })).status, 201);++ });++++ // A JPEG whose APP1 segment carries EXIF (with a fake GPS marker).++ const jpeg = Buffer.concat([++ Buffer.from([0xff, 0xd8]),++ Buffer.from([0xff, 0xe0, 0x00, 0x10]), Buffer.from('JFIF\0\x01\x01\0\0\x01\0\x01\0\0', 'latin1'),++ Buffer.from([0xff, 0xe1, 0x00, 0x17]), Buffer.from('Exif\0\0GPS-51.5N-0.12W', 'latin1'),++ Buffer.from([0xff, 0xdb, 0x00, 0x04, 0x00, 0x00]),++ Buffer.from([0xff, 0xda, 0x00, 0x04, 0x00, 0x00]), Buffer.from('pixels', 'latin1'), Buffer.from([0xff, 0xd9]),++ ]);++ let shotSlug;++++ await check('screenshot upload: metadata stripped, bytes stored in Media with a service token, URL + media_ref kept', async () => {++ const fd = new FormData();++ fd.append('title', 'My desk');++ fd.append('description', 'look');++ fd.append('page_url', 'https://example.com/x');++ fd.append('user_id', '9');++ fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'desk.jpg');++ const r = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: fd });++ assert.strictEqual(r.status, 201, r.text);++ const out = r.json();++ const up = t.media.uploads[t.media.uploads.length - 1];++ assert.ok(!up.bytes.includes(Buffer.from('GPS')), 'EXIF never leaves Community');++ assert.ok(up.bytes.includes(Buffer.from('pixels')) && up.bytes.includes(Buffer.from('JFIF')));++ assert.strictEqual(up.mime, 'image/jpeg');++ assert.ok(up.claims.cap.includes('media.object.upload'));++ const p = out.paste;++ assert.strictEqual(out.slug, p.slug);++ assert.strictEqual(p.type, 'screenshot');++ assert.strictEqual(p.owner_subject, alex.subject_id);++ assert.strictEqual(p.screenshot_url, `https://openvibe.media/f/${up.key}`);++ assert.strictEqual(p.title, 'My desk');++ assert.strictEqual(p.content, 'look');++ const meta = JSON.parse(p.metadata);++ assert.strictEqual(meta.page_url, 'https://example.com/x');++ assert.strictEqual(meta.original_name, 'desk.jpg');++ const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);++ assert.strictEqual(row.media_ref, `legacy:community:file:${up.key}`);++ shotSlug = p.slug;++ const viaCreate = new FormData();++ viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');++ const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });++ assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');++ assert.strictEqual(r2.json().paste.title, 'Screenshot');++ const bad = new FormData();++ bad.append('screenshot', new Blob(['hello'], { type: 'text/plain' }), 'a.txt');++ assert.strictEqual((await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: bad })).status, 400);++ t.media.failNext();++ const again = new FormData();++ again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');++ const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });++ assert.strictEqual(failed.status, 502);++ assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });++ });++++ await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot → stored URL', async () => {++ const raw = await call(`/p/${alexSlug}/raw`);++ assert.strictEqual(raw.status, 200);++ assert.strictEqual(raw.headers.get('content-type'), 'text/plain; charset=utf-8');++ assert.strictEqual(raw.headers.get('x-content-type-options'), 'nosniff');++ assert.strictEqual(raw.text, 'print(1)');++ const priv = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'eyes only', visibility: 'private' } })).json().slug;++ assert.strictEqual((await call(`/p/${priv}/raw`)).status, 404);++ assert.strictEqual((await call(`/p/${priv}/raw`, { cookie: alexJwt })).text, 'eyes only');++ const api = await call(`/api/pastes/${alexSlug}/raw`);++ assert.strictEqual(api.status, 302);++ assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);++ const shot = await call(`/p/${shotSlug}/screenshot`);++ assert.strictEqual(shot.status, 302);++ assert.match(shot.headers.get('location'), /^https:\/\/openvibe\.media\/f\//);++ assert.strictEqual((await call(`/p/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);++ assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);++ const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;++ assert.strictEqual((await call(`/p/${burn}/raw`)).text, 'once');++ assert.strictEqual((await call(`/p/${burn}/raw`)).status, 410);++ });++++ await check('likes need a person; comments allow anonymous names; set-avatar is the Network\'s; unknown routes 404', async () => {++ const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });++ assert.strictEqual(like.status, 401);++ assert.deepStrictEqual(like.json(), { error: 'Authentication required' });++ const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samOldJwt });++ assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });++ const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });++ assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });++ const c = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip: '198.51.100.40', json: { message: 'nice', anon_name: 'Visitor' } });++ assert.strictEqual(c.status, 201, c.text);++ assert.strictEqual(c.json().comment.anon_name, 'Visitor');++ const list = (await call(`/api/pastes/${alexSlug}/comments`)).json();++ assert.ok(list.comments.some((x) => x.message === 'nice' && x.user_id === null));++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/comments/${c.json().comment.id}`, { method: 'DELETE' })).status, 401);++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/set-avatar`, { method: 'POST', cookie: alexJwt })).status, 501);++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/nope/deeper`)).status, 404);++ const cfg = (await call('/api/pastes/config', { cookie: alexJwt })).json();++ assert.strictEqual(cfg.maxSizeKb, 512);++ assert.ok(cfg.todayCount > 0);++ const malformed = await call('/api/pastes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' });++ assert.strictEqual(malformed.status, 400);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++/**++ * Server-rendered pages with PASTES_AUTHORITY=community: everything reads from Community's own++ * store (never from OpenVibe.Live), with the same visibility rules as the API.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 } });++ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);++ const svc = t.app.locals.pastes;++ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });++ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };++ const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };++++ const js = (await svc.createText(asAlex, { title: 'Hello world in JavaScript', content: 'const greet = (name) => `Hi ${name}`;\n', language: 'javascript' })).slug;++ const nginx = (await svc.createText(anon, { title: 'nginx snippet', content: 'server {\n listen 80;\n}\n', language: 'nginx' })).slug;++ const py = (await svc.createText(asAlex, { title: 'python helper', content: 'def add(a, b):\n return a + b\n', language: 'python' })).slug;++ const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;++ const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;++ const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;++ t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);++ t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);++++ await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ has(r.text, `href="/p/${js}"`);++ const trendingAt = r.text.indexOf('id="trending"');++ assert.ok(r.text.indexOf(nginx, trendingAt) < r.text.indexOf(js, trendingAt), 'most viewed first');++ assert.ok(!r.text.includes(unl) && !r.text.includes(priv));++ assert.strictEqual(t.live.calls.filter((c) => c.path.startsWith('/api/pastes')).length, 0);++ });++++ await check('paste page: rendered from the store, view counted, author from the projection', async () => {++ const r = await t.get(`/p/${js}`, { headers: { 'x-forwarded-for': '198.51.100.20' } });++ assert.strictEqual(r.status, 200);++ has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');++ has(r.text, 'class="hljs-keyword">const</span>');++ const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));++ assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');++ has(r.text, 'https://openvibe.media/avatars/alex.png');++ assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js).views, 1);++ assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');++ });++++ await check('unlisted: noindex and escaped; private: 404 for visitors, the owner gets the page and delete', async () => {++ const u = await t.get(`/p/${unl}`);++ has(u.text, '<meta name="robots" content="noindex,follow">');++ has(u.text, '<script>alert(1)</script>');++ assert.ok(!u.text.includes('<script>alert(1)</script>'));++ assert.strictEqual((await t.get(`/p/${priv}`)).status, 404);++ const owner = await t.get(`/p/${priv}`, { cookies: [`ov_token=${token}`] });++ assert.strictEqual(owner.status, 200);++ has(owner.text, `data-delete="${priv}"`);++ // A service token is no identity on a page.++ const svcTok = t.network.signService({ cap: ['community.paste.write', 'community.paste.moderate'] });++ assert.strictEqual((await t.get(`/p/${priv}`, { headers: { authorization: `Bearer ${svcTok}`, 'x-ov-subject': alex.subject_id, 'x-ov-staff': '1' } })).status, 404);++ });++++ await check('burn after read: the first visitor sees it, the next gets the 410 page', async () => {++ assert.strictEqual((await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.30' } })).status, 200);++ const gone = await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.31' } });++ assert.strictEqual(gone.status, 410);++ has(gone.text, 'This paste has burned');++ });++++ await check('download reads the store without a view; screenshots use the stored Media URL', async () => {++ const before = t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views;++ const dl = await t.get(`/p/${py}/download`);++ assert.strictEqual(dl.status, 200);++ assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);++ assert.ok(dl.text.startsWith('def add'));++ assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views, before);++ const shot = await t.get('/p/fair-moon-23');++ has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');++ const dls = await t.get('/p/fair-moon-23/download');++ assert.strictEqual(dls.headers.get('location'), 'https://openvibe.media/f/abc-desk.png');++ });++++ await check('browse: search, views sort and language filter over the store', async () => {++ const q = await t.get('/pastes?q=nginx');++ has(q.text, `href="/p/${nginx}"`);++ assert.ok(!q.text.includes(`href="/p/${js}"`));++ const views = await t.get('/pastes?sort=views');++ const res = views.text.slice(views.text.indexOf('id="results"'));++ assert.ok(res.indexOf(nginx) < res.indexOf(js));++ const lang = await t.get('/pastes?lang=python');++ has(lang.text, `href="/p/${py}"`);++ assert.ok(!lang.text.includes(`href="/p/${js}"`));++ });++++ await check('/my lists the signed-in person\'s pastes, private and unlisted included', async () => {++ const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });++ assert.strictEqual(mine.status, 200);++ for (const slug of [js, py, unl, priv]) has(mine.text, `href="/p/${slug}"`);++ assert.ok(!mine.text.includes(nginx));++ });++++ await check('no-JS form creates in the store as the signed-in subject; anonymous posts share the 20/10min budget', async () => {++ const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'private' }).toString();++ const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });++ assert.strictEqual(post.status, 303);++ const slug = post.headers.get('location').replace('/p/', '');++ const row = t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);++ assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });++ const anonBody = new URLSearchParams({ content: 'hi' }).toString();++ const ip = '203.0.113.200';++ for (let i = 0; i < 20; i++) {++ const r = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });++ assert.strictEqual(r.status, 303, `#${i}`);++ }++ const over = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });++ assert.strictEqual(over.status, 429);++ has(over.text, 'Too many anonymous posts');++ const api = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ content: 'x' }), headers: { 'content-type': 'application/json', 'x-forwarded-for': ip } });++ assert.strictEqual(api.status, 429, 'the API shares the budget');++ });++++ await check('feed and sitemap list store pastes', async () => {++ const feed = await t.get('/feed.xml');++ has(feed.text, `/p/${js}`);++ assert.ok(!feed.text.includes(priv));++ const map = await t.get('/sitemap.xml');++ has(map.text, `/p/${js}`);++ assert.ok(!map.text.includes(unl) && !map.text.includes(priv));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,280 @@++'use strict';++/**++ * Paste store + service semantics on an in-memory database (no HTTP): create/get/list,++ * visibility, burn-after-read, forks, likes, copies, comments and replies, deletes, versions,++ * limits and view counting.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { openDb } = require('../server/db');++const store = require('../server/pastes/store');++const { createPasteService, PasteError } = require('../server/pastes/service');++const { check, done } = require('./helpers/app');++++const db = openDb(':memory:');++const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });++++const alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++const bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++const mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };++const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };++store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });++++async function rejects(p, status, message) {++ try { await p; } catch (err) {++ assert.ok(err instanceof PasteError, `expected PasteError, got ${err && err.stack}`);++ assert.strictEqual(err.status, status, `status: ${JSON.stringify(err.body)}`);++ if (message) assert.strictEqual(err.body.error, message);++ return err;++ }++ throw new Error(`expected ${status}`);++}++++(async () => {++ let pub, unl, priv;++++ await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {++ const out = await svc.createText(alice, { title: ' Hello ', content: 'const x = 1;\n ', language: 'auto', visibility: 'public' });++ assert.match(out.slug, /^[a-z]+-[a-z]+-\d{2}$/);++ assert.deepStrictEqual(Object.keys(out), ['id', 'slug', 'url', 'paste']);++ assert.strictEqual(out.url, `/p/${out.slug}`);++ const p = out.paste;++ assert.strictEqual(p.title, 'Hello');++ assert.strictEqual(p.content, 'const x = 1;');++ assert.strictEqual(p.language, 'javascript');++ assert.strictEqual(p.owner_subject, alice.subject);++ assert.strictEqual(p.user_id, alice.subject, 'user_id carries the subject id');++ assert.strictEqual(p.origin, 'user');++ assert.strictEqual(p.username, 'alice');++ assert.strictEqual(p.display_name, 'Alice');++ assert.strictEqual(p.avatar_url, 'https://openvibe.media/a.png');++ assert.strictEqual(p.profile_color, '#f0a');++ assert.strictEqual(p.raw_url, `/p/${out.slug}/raw`);++ assert.strictEqual(p.is_owner, true);++ for (const k of ['views', 'copies', 'likes', 'unique_views']) assert.strictEqual(p[k], 0);++ pub = out.slug;++ unl = (await svc.createText(alice, { title: 'unl', content: 'u', visibility: 'unlisted' })).slug;++ priv = (await svc.createText(alice, { title: 'priv', content: 'p', visibility: 'private' })).slug;++ });++++ await check('create: validation errors match Media', async () => {++ await rejects(svc.createText(alice, { content: ' ' }), 400, 'Content is required');++ await rejects(svc.createText(alice, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Paste too large (max 512 KB)');++ const untitled = await svc.createText(alice, { content: 'y' });++ assert.strictEqual(untitled.paste.title, 'Untitled');++ // An anonymous private paste could never be opened: it becomes unlisted.++ const anonPriv = await svc.createText(anon, { content: 'z', visibility: 'private' });++ assert.strictEqual(anonPriv.paste.visibility, 'unlisted');++ assert.strictEqual(anonPriv.paste.owner_subject, null);++ assert.strictEqual(anonPriv.paste.user_id, null);++ });++++ await check('visibility: public listed; unlisted by slug only; private owner/staff only (404 otherwise)', async () => {++ const list = await svc.list(anon, { limit: 50 });++ const slugs = list.pastes.map((p) => p.slug);++ assert.ok(slugs.includes(pub));++ assert.ok(!slugs.includes(unl) && !slugs.includes(priv));++ assert.strictEqual((await svc.get(bob, unl, { noView: true })).paste.slug, unl);++ await rejects(svc.get(bob, priv), 404, 'Paste not found');++ await rejects(svc.get(anon, priv), 404, 'Paste not found');++ assert.strictEqual((await svc.get(alice, priv)).paste.visibility, 'private');++ assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');++ // Private pastes can't be forked, liked, copied or commented by others either.++ await rejects(svc.fork(bob, priv), 404);++ await rejects(Promise.resolve().then(() => svc.like(bob, priv)), 404);++ await rejects(Promise.resolve().then(() => svc.copy(anon, priv)), 404);++ await rejects(svc.comments(anon, priv), 404);++ });++++ await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {++ const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });++ store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });++ const list = await svc.list(anon, { search: 'long one' });++ assert.strictEqual(list.total, 1);++ assert.strictEqual(list.pastes[0].content.length, 300);++ assert.deepStrictEqual(Object.keys(list).sort(), ['limit', 'offset', 'pastes', 'total']);++ const mine = await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 100 });++ assert.ok(mine.pastes.some((p) => p.slug === priv), 'owner sees own private with include_unlisted');++ const theirs = await svc.list(bob, { username: 'alice', include_unlisted: '1', limit: 100 });++ assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');++ assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });++ const wildcard = await svc.list(anon, { search: '%' });++ assert.strictEqual(wildcard.total, 0, 'LIKE wildcards are literal');++ assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);++ assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');++ });++++ await check('by-user: 404 for unknown, own hidden pastes only for the owner', async () => {++ await rejects(svc.byUser(anon, 'ghost'), 404, 'User not found');++ const self = await svc.byUser(alice, 'ALICE');++ assert.strictEqual(self.username, 'alice');++ assert.ok(self.pastes.some((p) => p.slug === priv));++ const other = await svc.byUser(bob, 'alice');++ assert.ok(!other.pastes.some((p) => p.slug === priv || p.slug === unl));++ assert.strictEqual(other.total, other.pastes.length);++ });++++ await check('views: counted once per visitor (cooldown), never for the owner, never for bots, not with noView', async () => {++ const slug = (await svc.createText(alice, { content: 'views' })).slug;++ assert.strictEqual((await svc.get(alice, slug)).paste.views, 0, 'owner');++ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1);++ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1, 'cooldown');++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.1' })).paste.views, 2);++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.2', userAgent: 'Googlebot/2.1' })).paste.views, 2, 'bot');++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.3', noView: true })).paste.views, 2, 'no_view');++ const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;++ assert.strictEqual(p.views, 3);++ assert.strictEqual(p.unique_views, 3);++ const visits = db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id).map((r) => r.visitor);++ assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');++ });++++ await check('burn after read: the owner reads freely; the first other read shows it, the second burns it (410)', async () => {++ const slug = (await svc.createText(alice, { content: 'secret', burn_after_read: true })).slug;++ assert.strictEqual((await svc.get(alice, slug)).paste.content, 'secret');++ const first = await svc.get(bob, slug);++ assert.strictEqual(first.paste.content, 'secret');++ assert.strictEqual(first.paste.burn_after_read, true);++ await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');++ await rejects(svc.get(alice, slug), 404);++ const row = db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);++ assert.strictEqual(row.content, null, 'content scrubbed');++ assert.ok(row.deleted_at);++ // Raw text: Media's rule — the first read serves it, the next one burns it.++ const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;++ assert.strictEqual(svc.raw(bob, raw).content, 'raw secret');++ await rejects(Promise.resolve().then(() => svc.raw(bob, raw)), 410);++ });++++ await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {++ const f = await svc.fork(bob, pub);++ assert.strictEqual(f.paste.title, 'Fork of Hello');++ assert.strictEqual(f.paste.visibility, 'public');++ assert.strictEqual(f.paste.owner_subject, bob.subject);++ assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);++ db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();++ await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');++ assert.deepStrictEqual(svc.like(bob, pub), { liked: true, likes: 1 });++ assert.deepStrictEqual(svc.like(alice, pub), { liked: true, likes: 2 });++ assert.deepStrictEqual(svc.like(bob, pub), { liked: false, likes: 1 });++ await rejects(Promise.resolve().then(() => svc.like(anon, pub)), 401, 'Authentication required');++ assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);++ assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);++ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 1 });++ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 2 });++ });++++ await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {++ const a = (await svc.addComment(anon, pub, { message: ' hi ', anon_name: 'Guest <b>!' }, { ip: '1.2.3.4' })).comment;++ assert.strictEqual(a.message, 'hi');++ assert.strictEqual(a.anon_name, 'Guest b');++ assert.strictEqual(a.user_id, null);++ const b = (await svc.addComment(bob, pub, { message: 'first!' })).comment;++ assert.strictEqual(b.user_id, bob.subject);++ assert.strictEqual(b.username, 'bob');++ const r = (await svc.addComment(alice, pub, { message: 'reply', parent_id: b.id })).comment;++ assert.strictEqual(r.parent_id, b.id);++ await rejects(svc.addComment(anon, pub, { message: 'deep', parent_id: r.id }, { ip: '1.2.3.5' }), 400, 'Cannot reply to a reply — reply to the original comment instead');++ await rejects(svc.addComment(anon, pub, { message: '' }), 400, 'Comment cannot be empty');++ await rejects(svc.addComment(anon, pub, { message: 'x'.repeat(2001) }), 400, 'Comment must be under 2000 characters');++ await rejects(svc.addComment(anon, pub, { message: 'bad parent', parent_id: 99999 }), 400, 'Invalid parent comment');++ const list = await svc.comments(anon, pub);++ assert.strictEqual(list.total, 3);++ assert.strictEqual(list.comments[0].id, b.id, 'newest top-level first');++ assert.strictEqual(list.comments[0].reply_count, 1);++ assert.strictEqual(list.comments[0].replies[0].username, 'alice');++ assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');++ // Delete: author, paste owner or staff; everyone else 403; anonymous 401.++ await rejects(Promise.resolve().then(() => svc.deleteComment(anon, pub, b.id)), 401);++ await rejects(Promise.resolve().then(() => svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');++ assert.deepStrictEqual(svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });++ assert.deepStrictEqual(svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');++ await rejects(Promise.resolve().then(() => svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');++ assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');++ });++++ await check('comment limits for people: cooldown, then duplicate', async () => {++ const strict = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 10 } });++ const carol = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++ await strict.addComment(carol, pub, { message: 'one' });++ await rejects(strict.addComment(carol, pub, { message: 'two' }), 429);++ const loose = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 0 } });++ await loose.addComment(carol, pub, { message: 'same' });++ await rejects(loose.addComment(carol, pub, { message: 'same' }), 400, 'Duplicate comment');++ });++++ await check('paste limits for people: cooldown with seconds to wait, daily cap', async () => {++ const strict = createPasteService({ db, config: {}, limits: { cooldownSeconds: 30, maxPerUserPerDay: 200 } });++ const dave = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++ await strict.createText(dave, { content: 'a' });++ const err = await rejects(strict.createText(dave, { content: 'b' }), 429);++ assert.ok(err.body.cooldown > 0 && err.body.cooldown <= 30);++ assert.match(err.body.error, /^Please wait \d+s before creating another paste$/);++ const capped = createPasteService({ db, config: {}, limits: { cooldownSeconds: 0, maxPerUserPerDay: 1 } });++ await rejects(capped.createText(dave, { content: 'c' }), 429, 'Daily paste limit reached (1/day)');++ // Anonymous callers aren't limited here (the per-address limiter in api.js is).++ await strict.createText(anon, { content: 'x' });++ await strict.createText(anon, { content: 'y' });++ assert.deepStrictEqual(Object.keys(capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);++ assert.strictEqual(capped.config(dave).todayCount, 1);++ });++++ await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {++ await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');++ await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');++ await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');++ await rejects(Promise.resolve().then(() => svc.remove(bob, priv)), 404, 'Paste not found');++ await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');++ await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');++ const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });++ assert.strictEqual(u.paste.language, 'python');++ assert.strictEqual(u.paste.pinned, false, 'owners cannot pin');++ assert.strictEqual(u.paste.revision, 2);++ const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });++ assert.strictEqual(m.paste.pinned, true);++ assert.strictEqual(m.paste.revision, 3);++ const v = svc.versions(alice, pub);++ assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);++ assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');++ assert.strictEqual(v.versions[1].edited_by, alice.subject);++ assert.strictEqual(v.versions[2].edited_by, mod.subject);++ await svc.update(alice, pub, { visibility: 'unlisted' });++ assert.strictEqual(svc.versions(alice, pub).revision, 3, 'visibility changes are not text revisions');++ await rejects(Promise.resolve().then(() => svc.versions(bob, unl)), 403);++ });++++ await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {++ await rejects(Promise.resolve().then(() => svc.remove(bob, unl)), 403);++ assert.deepStrictEqual(svc.remove(alice, unl), { success: true });++ await rejects(svc.get(alice, unl), 404);++ assert.deepStrictEqual(svc.remove(mod, priv), { success: true }, 'staff');++ const row = db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);++ assert.ok(row && row.deleted_at && row.content === null);++ assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));++ });++++ await check('staff tools: stats, forks, bulk, AI write-back', async () => {++ const stats = svc.stats().stats;++ assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);++ assert.ok(svc.forks({}).forks.length >= 1);++ const b = svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });++ assert.deepStrictEqual(b, { done: 1, skipped: 1 });++ assert.throws(() => svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);++ assert.throws(() => svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);++ const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });++ assert.strictEqual(ai.ok, true);++ assert.strictEqual(ai.paste.ai_summary.length, 2000);++ assert.strictEqual(ai.paste.ai_tags, '["a","b"]');++ assert.ok(ai.paste.ai_analyzed_at);++ const del = svc.deleteForks();++ assert.ok(del.deleted >= 1 && del.success === true);++ });++++ await check('generateSlug never reuses a slug, even a deleted one', async () => {++ const seen = new Set(db.prepare('SELECT slug FROM pastes').all().map((r) => r.slug));++ for (let i = 0; i < 200; i++) { const s = store.generateSlug(db); assert.ok(!seen.has(s)); }++ });++++ db.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/** Uploaded images lose EXIF/XMP/text metadata without being re-encoded (server/media/strip-metadata.js). */++const assert = require('assert');++const zlib = require('zlib');++const { stripImageMetadata } = require('../server/media/strip-metadata');++const { check, done } = require('./helpers/app');++++function crc32(buf) {++ let c, crc = 0xffffffff;++ for (let n = 0; n < buf.length; n++) {++ c = (crc ^ buf[n]) & 0xff;++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;++ crc = (crc >>> 8) ^ c;++ }++ return (crc ^ 0xffffffff) >>> 0;++}++function pngChunk(type, data) {++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);++ const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);++ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));++ return Buffer.concat([len, body, crc]);++}++function riffChunk(type, data) {++ const head = Buffer.alloc(8); head.write(type, 0, 'latin1'); head.writeUInt32LE(data.length, 4);++ return Buffer.concat([head, data, data.length & 1 ? Buffer.alloc(1) : Buffer.alloc(0)]);++}++++(async () => {++ await check('PNG: tEXt / eXIf / iTXt chunks removed, image chunks kept byte-for-byte', async () => {++ const ihdr = pngChunk('IHDR', Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0]));++ const idat = pngChunk('IDAT', zlib.deflateSync(Buffer.from([0, 0])));++ const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ihdr,++ pngChunk('tEXt', Buffer.from('Author\0Secret Person', 'latin1')), pngChunk('eXIf', Buffer.from('MM\0*GPS', 'latin1')),++ idat, pngChunk('iTXt', Buffer.from('XML:com.adobe.xmp\0\0\0\0\0<gps/>', 'latin1')), pngChunk('IEND', Buffer.alloc(0))]);++ const out = stripImageMetadata(png, 'image/png');++ assert.ok(!out.includes(Buffer.from('Secret')) && !out.includes(Buffer.from('GPS')) && !out.includes(Buffer.from('<gps/>')));++ assert.ok(out.includes(ihdr) && out.includes(idat));++ assert.ok(out.subarray(out.length - 12).equals(pngChunk('IEND', Buffer.alloc(0))));++ });++++ await check('WebP: EXIF and XMP chunks removed, VP8X flags cleared, RIFF size fixed', async () => {++ const vp8x = Buffer.alloc(10); vp8x[0] = 0x0c | 0x10; // EXIF + XMP + alpha flags++ const body = Buffer.concat([riffChunk('VP8X', vp8x), riffChunk('VP8L', Buffer.from('imagebytes')), riffChunk('EXIF', Buffer.from('GPS-DATA')), riffChunk('XMP ', Buffer.from('<x/>'))]);++ const head = Buffer.alloc(12); head.write('RIFF', 0, 'latin1'); head.writeUInt32LE(body.length + 4, 4); head.write('WEBP', 8, 'latin1');++ const out = stripImageMetadata(Buffer.concat([head, body]), 'image/webp');++ assert.ok(!out.includes(Buffer.from('GPS-DATA')) && !out.includes(Buffer.from('<x/>')));++ assert.ok(out.includes(Buffer.from('imagebytes')));++ assert.strictEqual(out.readUInt32LE(4), out.length - 8);++ assert.strictEqual(out[20] & 0x0c, 0, 'EXIF/XMP flags cleared');++ assert.strictEqual(out[20] & 0x10, 0x10, 'other flags kept');++ });++++ await check('GIFs and anything unparseable pass through unchanged', async () => {++ const gif = Buffer.from('GIF89a....');++ assert.strictEqual(stripImageMetadata(gif, 'image/gif'), gif);++ const junk = Buffer.from([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff]);++ assert.strictEqual(stripImageMetadata(junk, 'image/jpeg'), junk);++ });++++ done();++})();
@@ -33,3 +33,16 @@ OV_LIVE_URL=https://openvibe.live
@@ -33,3 +33,16 @@ OV_LIVE_URL=https://openvibe.live
# OpenVibe.Media — public host for raw paste text and screenshots
# OpenVibe.Media — public host for raw paste text and screenshots
OV_MEDIA_URL=https://openvibe.media
OV_MEDIA_URL=https://openvibe.media
+# Media's internal address: new screenshot uploads (community authority) go to its file store
+# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
+OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+
+# Who owns pastes:
+# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live
+# community this site's own database is the authority (native API, pages, raw text)
+# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
+PASTES_AUTHORITY=live
+# SQLite database (production unit: /var/lib/openvibe-community/community.db)
+COMMUNITY_DB_PATH=./data/community.db
+# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
+#VIEW_HASH_SECRET=
@@ -14,4 +14,5 @@ jobs:
@@ -14,4 +14,5 @@ jobs:
cache: npm
cache: npm
- run: npm ci --no-audit --no-fund
- run: npm ci --no-audit --no-fund
- run: npm test
- run: npm test
+ - run: npx openvibe-contracts-check --service community --src server
@@ -6,12 +6,19 @@ The community hub of the OpenVibe network: community-run, open source, free spee
@@ -6,12 +6,19 @@ The community hub of the OpenVibe network: community-run, open source, free spee
the rules. Today it is the home of **pastes** (code, text and screenshots with a link);
the rules. Today it is the home of **pastes** (code, text and screenshots with a link);
spaces, threads and submissions follow.
spaces, threads and submissions follow.
-It is a small Node/Express app (CommonJS, no framework, no database of its own) that
+It is a small Node/Express app (CommonJS, no framework, one SQLite database) that
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
little progressive JavaScript for pagination, copy buttons and the upload path.
little progressive JavaScript for pagination, copy buttons and the upload path.
## How it fits the network
## How it fits the network
+Pastes are moving to Community (roadmap Wave 5). `PASTES_AUTHORITY` picks who owns them:
+
+- **`live`** (default, today's production): the table below. Deploying the new code changes
+ nothing until the flag flips.
+- **`community`**: Community's own database is the authority — see
+ [Community as the paste authority](#community-as-the-paste-authority).
+
| Concern | Where it lives | How Community reaches it |
| Concern | Where it lives | How Community reaches it |
| --- | --- | --- |
| --- | --- | --- |
| Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |
| Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |
@@ -27,6 +34,50 @@ writes, which only Live can do, so Community proxies `/api/pastes/*` to Live and
@@ -27,6 +34,50 @@ writes, which only Live can do, so Community proxies `/api/pastes/*` to Live and
visitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,
visitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,
anonymous-post limits, ownership and author names are therefore identical to Live's.
anonymous-post limits, ownership and author names are therefore identical to Live's.
+### Community as the paste authority
+
+With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
+(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
+response shapes the browser clients already use, and every page, `/p/:slug/raw`,
+`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
+redirects its old paste URLs here after cutover).
+
+- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
+ owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
+ cached in `legacy_id_map`). In responses `user_id` carries that subject id, next to
+ `owner_subject` and `origin`; names/avatars come from `subject_projection`, a cache
+ refreshed through the Network's `POST /internal/identity/resolve-batch` (service token,
+ `identity.subject.resolve`).
+- **Service callers** (Live's adapter, Live's AI jobs) send a Network service token for
+ audience `openvibe.community` and need the route's capability: `community.paste.create`
+ (create), `community.paste.write` (edit, delete, fork, like, copy, comment as someone),
+ `community.paste.moderate` (`/admin/*`, `/bulk`, `/:slug/censor`, the AI queue
+ `?needs_ai=1` and `POST /:slug/ai`). Headers: `X-OV-Subject: usr_…|gst_…` names the acting
+ person (no subject = anonymous); `X-OV-Origin: ai` stores AI output with origin `ai` and no
+ owner; `X-OV-Source-Ref: <EntityRef JSON>` records e.g. the stream; `X-OV-Staff: 1` (needs
+ moderate) vouches that the acting person is staff. Services may bring a `slug` and
+ `metadata`; browsers never. Identity is never read from bodies or queries.
+- **Limits** as before: 20 anonymous writes per 10 minutes per address (API and the no-JS
+ form share it); people get Media's 30 s paste cooldown, 200/day and comment limits.
+- **Screenshots**: new uploads lose their EXIF/XMP metadata and go to Media's file store
+ (`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
+ `media.object.upload`); the paste keeps the public URL and `media_ref`
+ `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
+- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
+
+Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff
+2026-08-20T03:00:26Z]` imports Media's `openvibe.media.pastes-export` bundle. It checks the
+bundle's format, counts and sha256 first, is idempotent (upsert by `legacy_media_id`; a newer
+bundle adds and updates, counters never go down, local edits and deletes stay), maps Live user
+ids to subjects through the Network (rows before the cutoff are also checked as Network ids;
+disagreement or no answer imports the row ownerless and records an `import_hold`), keeps AI
+moment pastes ownerless with origin `ai`, and prints a reconciliation report (also stored in
+`migration_runs`). `--dry-run` rolls everything back.
+
+`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network
+has no service-token lookup by username), so a person appears there once Community has seen
+them — signed in here, or named as an author in a resolved projection.
+
## Endpoints
## Endpoints
Pages (server-rendered HTML):
Pages (server-rendered HTML):
@@ -36,9 +87,9 @@ Pages (server-rendered HTML):
@@ -36,9 +87,9 @@ Pages (server-rendered HTML):
| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
| `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
| `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
-| `GET /p/:slug/raw` | 302 → `https://openvibe.media/p/:slug/raw` |
-| `GET /p/:slug/screenshot` | 302 → `https://openvibe.media/p/:slug/screenshot` |
-| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to Media |
+| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |
+| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |
+| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
@@ -46,7 +97,7 @@ API and machine endpoints:
@@ -46,7 +97,7 @@ API and machine endpoints:
| Route | What |
| Route | What |
| --- | --- |
| --- | --- |
-| `ANY /api/pastes/*` | Transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched |
+| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
@@ -84,6 +135,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -84,6 +135,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` |
| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
+| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
+| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
+| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |
+| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
## Run
## Run
@@ -91,7 +146,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -91,7 +146,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
```
```
npm install
npm install
npm start # http://127.0.0.1:4200
npm start # http://127.0.0.1:4200
-npm test # mocks Live + Network in-process; no network needed
+npm test # mocks Live, Network and Media in-process; no network needed
```
```
## Deploy
## Deploy
@@ -104,8 +159,15 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
@@ -104,8 +159,15 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
# /etc/letsencrypt/live/openvibe.community/
# /etc/letsencrypt/live/openvibe.community/
```
```
-Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. Nothing is
-written to disk by the app; there is no migration step.
+Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. The schema
+is created idempotently at boot (only when `PASTES_AUTHORITY=community`; in `live` mode the
+app writes nothing to disk). The database lives in the unit's `StateDirectory`
+(`/var/lib/openvibe-community`), so the code tree stays read-only.
+
+Before flipping to `community`, the Network's `community` OAuth client needs the
+`identity.subject.resolve` capability (audience `openvibe.network`) and
+`media.object.upload` (audience `openvibe.media`), and Live's service client needs the
+`community.paste.*` capabilities it will use.
The Network must have the OAuth client `community` registered with redirect
The Network must have the OAuth client `community` registered with redirect
`https://openvibe.community/auth/callback`, and serve the current `openvibe-shared`
`https://openvibe.community/auth/callback`, and serve the current `openvibe-shared`
@@ -119,10 +181,20 @@ server/
@@ -119,10 +181,20 @@ server/
index.js process entry (listen, shutdown)
index.js process entry (listen, shutdown)
app.js Express app factory: middleware, routes
app.js Express app factory: middleware, routes
config.js env → config
config.js env → config
+ db.js SQLite (better-sqlite3): schema, opened at COMMUNITY_DB_PATH
auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
- live-client.js server-side reads of Live's /api/pastes for rendered pages
- pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address)
- pastes/catalog.js cached window of recent public pastes: trending, related, language filter
+ identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
+ identity/network.js Network resolve-batch client + subject_projection cache
+ media/files.js new screenshot bytes → Media's file store (service token)
+ media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding
+ live-client.js server-side reads of Live's /api/pastes for rendered pages ('live')
+ pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address) ('live')
+ pastes/api.js native /api/pastes/* ('community')
+ pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
+ pastes/store.js pure SQL over pastes / versions / likes / comments / projections
+ pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
+ pastes/source.js where pages read pastes from (Live or the store)
+ pastes/catalog.js recent public pastes: trending, related, language filter
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/pages.js home / browse / paste / new / my / error templates
render/pages.js home / browse / paste / new / my / error templates
render/highlight.js highlight.js wrapper, language list, download extensions
render/highlight.js highlight.js wrapper, language list, download extensions
@@ -130,14 +202,15 @@ server/
@@ -130,14 +202,15 @@ server/
public/ css/community.css, js/community.js, favicon.svg, og-default.png
public/ css/community.css, js/community.js, favicon.svg, og-default.png
vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-shared
vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-shared
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
-test/ run.js + *.test.js (mock Live + mock Network with a real RS256 key)
+scripts/import-pastes.js Media paste bundle importer
+test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
```
```
## What is next (phase 2)
## What is next (phase 2)
Spaces (per-streamer / per-topic communities), threads (long-form forum discussion) and
Spaces (per-streamer / per-topic communities), threads (long-form forum discussion) and
submissions (clips, art, ideas, reports for community review). These need storage of their
submissions (clips, art, ideas, reports for community review). These need storage of their
-own — Community has none today — and moderation tooling; the paste pages, auth layer and
+own — the paste database is the first — and moderation tooling; the paste pages, auth layer and
chrome are built to be reused by them.
chrome are built to be reused by them.
## Related services
## Related services
@@ -21,7 +21,10 @@ NoNewPrivileges=true
@@ -21,7 +21,10 @@ NoNewPrivileges=true
PrivateTmp=true
PrivateTmp=true
ProtectSystem=strict
ProtectSystem=strict
ProtectHome=true
ProtectHome=true
-# Nothing is written to disk — pastes live in OpenVibe.Media, sessions are cookies.
+# Community's own database (used once PASTES_AUTHORITY=community; untouched before that).
+# The code tree stays read-only; the database lives in /var/lib/openvibe-community.
+StateDirectory=openvibe-community
+Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db
[Install]
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
@@ -9,12 +9,15 @@
@@ -9,12 +9,15 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
+ "multer": "^2.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared"
"openvibe-shared": "file:./vendor/openvibe-shared"
},
},
"engines": {
"engines": {
@@ -34,12 +37,102 @@
@@ -34,12 +37,102 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "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": {
"node_modules/array-flatten": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"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/body-parser": {
"node_modules/body-parser": {
"version": "1.20.8",
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -64,12 +157,47 @@
@@ -64,12 +157,47 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "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": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
+ "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": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -108,6 +236,12 @@
@@ -108,6 +236,12 @@
"url": "https://github.com/sponsors/ljharb"
"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/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -166,6 +300,30 @@
@@ -166,6 +300,30 @@
"ms": "2.0.0"
"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": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -185,6 +343,15 @@
@@ -185,6 +343,15 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -235,6 +402,15 @@
@@ -235,6 +402,15 @@
"node": ">= 0.8"
"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": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -280,6 +456,15 @@
@@ -280,6 +456,15 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "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": {
"node_modules/express": {
"version": "4.22.3",
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@ -341,6 +526,34 @@
@@ -341,6 +526,34 @@
"express": ">= 4.11"
"express": ">= 4.11"
}
}
},
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "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": {
"node_modules/finalhandler": {
"version": "1.3.2",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -377,6 +590,12 @@
@@ -377,6 +590,12 @@
"node": ">= 0.6"
"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": {
"node_modules/function-bind": {
"version": "1.1.2",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -423,6 +642,12 @@
@@ -423,6 +642,12 @@
"node": ">= 0.4"
"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": {
"node_modules/gopd": {
"version": "1.2.0",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -512,12 +737,38 @@
@@ -512,12 +737,38 @@
"node": ">=0.10.0"
"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": {
"node_modules/inherits": {
"version": "2.0.4",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
"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": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -527,6 +778,12 @@
@@ -527,6 +778,12 @@
"node": ">= 0.10"
"node": ">= 0.10"
}
}
},
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/jsonwebtoken": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -687,12 +944,63 @@
@@ -687,12 +944,63 @@
"node": ">= 0.6"
"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-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": {
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/multer": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
+ "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -702,6 +1010,18 @@
@@ -702,6 +1010,18 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -726,6 +1046,31 @@
@@ -726,6 +1046,31 @@
"node": ">= 0.8"
"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-contracts": {
+ "version": "0.5.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
+ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"resolved": "vendor/openvibe-shared",
"resolved": "vendor/openvibe-shared",
"link": true
"link": true
@@ -745,6 +1090,33 @@
@@ -745,6 +1090,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"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/proxy-addr": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -762,6 +1134,16 @@
@@ -762,6 +1134,16 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "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": {
"node_modules/qs": {
"version": "6.16.0",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@ -802,6 +1184,44 @@
@@ -802,6 +1184,44 @@
"node": ">= 0.8"
"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": "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/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -963,6 +1383,51 @@
@@ -963,6 +1383,51 @@
"url": "https://github.com/sponsors/ljharb"
"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/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -972,6 +1437,60 @@
@@ -972,6 +1437,60 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "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.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "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/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -981,6 +1500,18 @@
@@ -981,6 +1500,18 @@
"node": ">=0.6"
"node": ">=0.6"
}
}
},
},
+ "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": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1003,6 +1534,12 @@
@@ -1003,6 +1534,12 @@
"node": ">= 0.8"
"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": {
"node_modules/utils-merge": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1021,6 +1558,12 @@
@@ -1021,6 +1558,12 @@
"node": ">= 0.8"
"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"
+ },
"vendor/openvibe-shared": {
"vendor/openvibe-shared": {
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-community",
"name": "openvibe-community",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"private": true,
- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (pastes stored by OpenVibe.Media via OpenVibe.Live, identity by OpenVibe.Network).",
+ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -13,15 +13,18 @@
@@ -13,15 +13,18 @@
"author": "OpenVibe Community",
"author": "OpenVibe Community",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
+ "multer": "^2.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared"
"openvibe-shared": "file:./vendor/openvibe-shared"
},
},
"engines": {
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * Import OpenVibe.Media's paste export into Community's database.
+ *
+ * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z]
+ *
+ * Reads the same environment as the server (.env / /etc/openvibe/community.env):
+ * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
+ * client needs identity.subject.resolve). Safe to re-run: see server/pastes/importer.js.
+ *
+ * Exit codes: 0 imported (or dry run finished), 2 bundle refused, 1 anything else.
+ */
+const fs = require('fs');
+const path = require('path');
+
+function usage(msg) {
+ if (msg) console.error(msg);
+ console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>]');
+ process.exit(msg ? 1 : 0);
+}
+
+function parseArgs(argv) {
+ const out = { file: null, dryRun: false, cutoff: null };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--dry-run') out.dryRun = true;
+ else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }
+ else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);
+ else if (a === '-h' || a === '--help') usage();
+ else if (a.startsWith('-')) usage(`unknown option ${a}`);
+ else if (!out.file) out.file = a;
+ else usage(`unexpected argument ${a}`);
+ }
+ if (!out.file) usage('missing <bundle.json>');
+ return out;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const config = require('../server/config');
+ const { openDb } = require('../server/db');
+ const { createNetworkIdentity } = require('../server/identity/network');
+ const { importBundle, formatReport, ImportError, DEFAULT_CUTOFF } = require('../server/pastes/importer');
+
+ let bundle;
+ try { bundle = JSON.parse(fs.readFileSync(args.file, 'utf8')); }
+ catch (err) { console.error(`Cannot read ${args.file}: ${err.message}`); process.exit(2); }
+
+ const db = openDb(config.dbPath);
+ const network = createNetworkIdentity({ config, db });
+ try {
+ const report = await importBundle(db, bundle, {
+ // A dry run writes nothing at all, not even the display cache.
+ resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),
+ cutoff: args.cutoff || DEFAULT_CUTOFF,
+ dryRun: args.dryRun,
+ source: `${bundle.app || 'media'}:${path.basename(args.file)}`,
+ });
+ console.log(formatReport(report));
+ } catch (err) {
+ if (err instanceof ImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }
+ throw err;
+ } finally {
+ db.close();
+ }
+}
+
+main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
@@ -6,14 +6,20 @@
@@ -6,14 +6,20 @@
* Express app factory (server/index.js listens; tests build their own instance).
* Express app factory (server/index.js listens; tests build their own instance).
*
*
* Pages (server-rendered) API / machine
* Pages (server-rendered) API / machine
- * GET / home ALL /api/pastes/* → OpenVibe.Live proxy
+ * GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below
* GET /pastes browse GET /api/health, /api/ready
* GET /pastes browse GET /api/health, /api/ready
* GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml
* GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml
- * GET /p/:slug/raw → Media /auth/login|callback|logout|me|refresh
- * GET /p/:slug/screenshot → Media
+ * GET /p/:slug/raw raw text /auth/login|callback|logout|me|refresh
+ * GET /p/:slug/screenshot → image
* GET /p/:slug/download
* GET /p/:slug/download
* GET|POST /new create
* GET|POST /new create
* GET /my signed-in user's pastes
* GET /my signed-in user's pastes
+ *
+ * PASTES_AUTHORITY (config.pastesAuthority):
+ * 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
+ * Live's API, raw text and screenshots bounce to OpenVibe.Media.
+ * 'community' this site's own database is the authority: the native API
+ * (pastes/api.js), pages, raw text and screenshots all come from the store.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -24,6 +30,7 @@ const rateLimit = require('express-rate-limit');
@@ -24,6 +30,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const live = require('./live-client');
const live = require('./live-client');
const catalog = require('./pastes/catalog');
const catalog = require('./pastes/catalog');
+const source = require('./pastes/source');
const seo = require('./seo');
const seo = require('./seo');
const pages = require('./render/pages');
const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
@@ -74,9 +81,41 @@ function createApp(opts = {}) {
@@ -74,9 +81,41 @@ function createApp(opts = {}) {
app.use('/auth', createAuthRoutes(config, auth));
app.use('/auth', createAuthRoutes(config, auth));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
- // ── /api/pastes → OpenVibe.Live (before any body parser: bodies stream through) ──
+ // ── Paste authority ──────────────────────────────────────
+ const community = config.pastesAuthority === 'community';
+ let viewers = null;
+ if (community) {
+ const { openDb, getDb } = require('./db');
+ const { createNetworkIdentity } = require('./identity/network');
+ const { createViewerResolver } = require('./identity/viewer');
+ const { createMediaFiles } = require('./media/files');
+ const { createPasteService } = require('./pastes/service');
+ const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
+ const network = opts.network || createNetworkIdentity({ config, db });
+ const media = opts.media || createMediaFiles({ config });
+ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits });
+ viewers = createViewerResolver({ auth, config, network });
+ source.use(service);
+ app.locals.db = db;
+ app.locals.pastes = service;
+ } else {
+ source.use(null);
+ }
+ // One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
+ const anonWriteLimiter = rateLimit({
+ windowMs: 10 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
+ skip: (req) => !!(req.viewer ? req.viewer.kind !== 'anonymous' : req.user),
+ handler: (req, res) => {
+ const error = 'Too many anonymous posts — sign in or try again later';
+ if (req.originalUrl.startsWith('/api/')) return res.status(429).json({ error });
+ res.status(429).type('html').set('Cache-Control', 'no-cache').send(pages.newPage({ user: req.user, error }));
+ },
+ });
+
+ // ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
- app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
+ if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));
+ else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
@@ -99,8 +138,9 @@ function createApp(opts = {}) {
@@ -99,8 +138,9 @@ function createApp(opts = {}) {
app.get('/feed.xml', wrap(seo.feedHandler));
app.get('/feed.xml', wrap(seo.feedHandler));
// ── Pages ────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────
- const withUser = optionalAuth(auth);
- const ctxOf = (req) => ({ token: req.token, ip: req.ip });
+ // Pages are for browsers: in community mode the viewer (subject, staff) is resolved too.
+ const withUser = community ? viewers.middleware({ services: false }) : optionalAuth(auth);
+ const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
app.get('/', withUser, wrap(async (req, res) => {
app.get('/', withUser, wrap(async (req, res) => {
@@ -121,7 +161,7 @@ function createApp(opts = {}) {
@@ -121,7 +161,7 @@ function createApp(opts = {}) {
if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);
if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);
let paste;
let paste;
try {
try {
- paste = await live.getPaste(slug, ctxOf(req));
+ paste = await source.getPaste(slug, ctxOf(req));
} catch (err) {
} catch (err) {
if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);
if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);
@@ -131,17 +171,41 @@ function createApp(opts = {}) {
@@ -131,17 +171,41 @@ function createApp(opts = {}) {
html(res, pages.pastePage({ paste, related, user: req.user }));
html(res, pages.pastePage({ paste, related, user: req.user }));
}));
}));
- // Raw text and screenshots are public on OpenVibe.Media — bounce there.
- app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
- app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
+ if (community) {
+ // The store is the authority: raw text is served here, the screenshot link is the stored
+ // Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)
+ const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
+ app.get('/p/:slug/raw', withUser, (req, res) => {
+ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
+ try {
+ const out = app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
+ if (out.redirect) return res.redirect(302, out.redirect);
+ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
+ res.type('text/plain; charset=utf-8').send(out.content);
+ } catch (err) {
+ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
+ if (err.status === 404) return notFound(res);
+ throw err;
+ }
+ });
+ app.get('/p/:slug/screenshot', withUser, (req, res) => {
+ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
+ try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
+ catch (err) { if (err.status === 404) return notFound(res); throw err; }
+ });
+ } else {
+ // Raw text and screenshots are public on OpenVibe.Media — bounce there.
+ app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
+ app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
+ }
app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
const { slug } = req.params;
const { slug } = req.params;
if (!SLUG_RE.test(slug)) return res.status(404).type('text/plain').send('Not found');
if (!SLUG_RE.test(slug)) return res.status(404).type('text/plain').send('Not found');
let paste;
let paste;
- try { paste = await live.getPaste(slug, { ...ctxOf(req), noView: true }); }
+ try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
- if (paste.type === 'screenshot') return res.redirect(302, live.screenshotUrl(slug));
+ if (paste.type === 'screenshot') return res.redirect(302, community ? (paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`) : live.screenshotUrl(slug));
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', 'private, no-cache');
res.set('Cache-Control', 'private, no-cache');
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
@@ -150,13 +214,15 @@ function createApp(opts = {}) {
@@ -150,13 +214,15 @@ function createApp(opts = {}) {
app.get('/new', withUser, wrap(async (req, res) => {
app.get('/new', withUser, wrap(async (req, res) => {
let fork = null;
let fork = null;
if (req.query.fork && SLUG_RE.test(String(req.query.fork))) {
if (req.query.fork && SLUG_RE.test(String(req.query.fork))) {
- try { const p = await live.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }
+ try { const p = await source.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }
}
}
html(res, pages.newPage({ user: req.user, fork }));
html(res, pages.newPage({ user: req.user, fork }));
}));
}));
// No-JS fallback: a plain form post becomes the same JSON create the API path uses.
// No-JS fallback: a plain form post becomes the same JSON create the API path uses.
- app.post('/new', withUser, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
+ // In community mode the anonymous-write budget applies here too (in 'live' mode Live enforces it).
+ const formLimiter = community ? anonWriteLimiter : (_req, _res, next) => next();
+ app.post('/new', withUser, formLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const values = {
const values = {
title: String(b.title || '').slice(0, 200),
title: String(b.title || '').slice(0, 200),
@@ -169,7 +235,7 @@ function createApp(opts = {}) {
@@ -169,7 +235,7 @@ function createApp(opts = {}) {
if (values.visibility === 'private' && !req.user) values.visibility = 'unlisted';
if (values.visibility === 'private' && !req.user) values.visibility = 'unlisted';
if (!values.content.trim()) return html(res, pages.newPage({ user: req.user, values, error: 'Paste something first — the content is empty.' }), 400);
if (!values.content.trim()) return html(res, pages.newPage({ user: req.user, values, error: 'Paste something first — the content is empty.' }), 400);
try {
try {
- const out = await live.createPaste({
+ const out = await source.createPaste({
title: values.title, content: values.content, language: values.language, visibility: values.visibility,
title: values.title, content: values.content, language: values.language, visibility: values.visibility,
burn_after_read: values.burn_after_read, is_nsfw: values.is_nsfw,
burn_after_read: values.burn_after_read, is_nsfw: values.is_nsfw,
}, ctxOf(req));
}, ctxOf(req));
@@ -187,7 +253,7 @@ function createApp(opts = {}) {
@@ -187,7 +253,7 @@ function createApp(opts = {}) {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
let pastes = [], total = 0, error = null;
try {
try {
- const out = await live.listByUser(req.user.username, { limit: 100 }, ctxOf(req));
+ const out = await source.listByUser(req.user.username, { limit: 100 }, ctxOf(req));
pastes = (out && out.pastes) || [];
pastes = (out && out.pastes) || [];
total = Number(out && out.total) || pastes.length;
total = Number(out && out.total) || pastes.length;
} catch (err) {
} catch (err) {
@@ -44,4 +44,13 @@ module.exports = {
@@ -44,4 +44,13 @@ module.exports = {
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
// OpenVibe.Media — public host that serves raw paste text and screenshots.
// OpenVibe.Media — public host that serves raw paste text and screenshots.
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
+ // Media's internal address: new screenshot uploads go to its file store (community app).
+ mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),
+
+ // Who answers /api/pastes/* and the paste pages:
+ // 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
+ // 'community' — this site's own database is the authority (server/pastes/api.js).
+ pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
+ // SQLite file for Community's own data (pastes once authority=community).
+ dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+
+/**
+ * Community's own database (SQLite, better-sqlite3).
+ *
+ * Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
+ * history, likes and comments live here. People are referenced by Network subject ids
+ * (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
+ * of what the Network says about them.
+ *
+ * Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS pastes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT UNIQUE NOT NULL,
+ owner_subject TEXT,
+ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),
+ type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
+ title TEXT NOT NULL DEFAULT 'Untitled',
+ content TEXT,
+ language TEXT DEFAULT 'text',
+ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
+ screenshot_url TEXT,
+ media_ref TEXT,
+ stream_ref TEXT, -- JSON EntityRef, e.g. {"service":"live","type":"stream","id":"123"}
+ metadata TEXT, -- JSON
+ burn_after_read INTEGER NOT NULL DEFAULT 0,
+ forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,
+ pinned INTEGER NOT NULL DEFAULT 0,
+ views INTEGER NOT NULL DEFAULT 0,
+ unique_views INTEGER NOT NULL DEFAULT 0,
+ copies INTEGER NOT NULL DEFAULT 0,
+ likes INTEGER NOT NULL DEFAULT 0,
+ is_nsfw INTEGER NOT NULL DEFAULT 0,
+ ai_summary TEXT,
+ ai_tags TEXT, -- JSON array
+ ai_analyzed_at DATETIME,
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME,
+ legacy_media_id INTEGER UNIQUE,
+ legacy_user_id INTEGER
+);
+CREATE INDEX IF NOT EXISTS idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner_subject, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_pastes_forked ON pastes(forked_from);
+
+CREATE TABLE IF NOT EXISTS paste_versions (
+ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ revision INTEGER NOT NULL,
+ title TEXT,
+ content TEXT,
+ language TEXT,
+ edited_by TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (paste_id, revision)
+);
+
+CREATE TABLE IF NOT EXISTS paste_likes (
+ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ subject_id TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (paste_id, subject_id)
+);
+
+CREATE TABLE IF NOT EXISTS paste_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ author_subject TEXT,
+ anon_name TEXT,
+ parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,
+ message TEXT NOT NULL,
+ is_deleted INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ legacy_media_id INTEGER UNIQUE
+);
+CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id, parent_id);
+
+-- Per-visitor view bookkeeping (cooldown + unique counts). Visitors are 'u:<subject>' or an
+-- HMAC of the address ('ip:<hash>'); no raw addresses are stored.
+CREATE TABLE IF NOT EXISTS paste_visits (
+ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ visitor TEXT NOT NULL,
+ first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ visits INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (paste_id, visitor)
+);
+CREATE INDEX IF NOT EXISTS idx_paste_visits_last ON paste_visits(last_at);
+
+CREATE TABLE IF NOT EXISTS subject_projection (
+ subject_id TEXT PRIMARY KEY,
+ username TEXT,
+ display_name TEXT,
+ avatar_url TEXT,
+ profile_color TEXT,
+ refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_subject_projection_username ON subject_projection(username COLLATE NOCASE);
+
+CREATE TABLE IF NOT EXISTS migration_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ finished_at DATETIME,
+ report TEXT
+);
+
+CREATE TABLE IF NOT EXISTS legacy_id_map (
+ source_system TEXT NOT NULL,
+ source_type TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ PRIMARY KEY (source_system, source_type, source_id)
+);
+
+CREATE TABLE IF NOT EXISTS import_hold (
+ source_type TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ reason TEXT NOT NULL,
+ detail TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (source_type, source_id, reason)
+);
+`;
+
+/** Open (creating the directory and schema if needed). ':memory:' works for tests. */
+function openDb(file) {
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
+ const db = new Database(file);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ return db;
+}
+
+let _shared = null;
+/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
+function getDb() {
+ if (!_shared) _shared = openDb(require('./config').dbPath);
+ return _shared;
+}
+
+module.exports = { openDb, getDb, SCHEMA };
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+
+/**
+ * OpenVibe.Network identity client — who a subject is, for display.
+ *
+ * Community stores people as Network subject ids (usr_… / gst_…). Names and pictures come from
+ * the Network's resolve-batch endpoint (capability identity.subject.resolve, called with a
+ * client-credentials service token) and are cached in subject_projection. The cache is never
+ * authority: an entry older than PROJECTION_TTL_MS is refreshed in the background, a missing one
+ * is fetched before the response (bounded by RESOLVE_WAIT_MS so a slow Network only costs names).
+ *
+ * POST /internal/identity/resolve-batch
+ * { subject_ids: [...] } or { system, type: 'user', ids: [...] } (≤ 500 per call)
+ * → { results: { <key>: { subject: {type,id}, network_user_id, username, display_name, avatar_url, banned } | null } }
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const store = require('../pastes/store');
+
+const BATCH = 500;
+const PROJECTION_TTL_MS = 6 * 60 * 60 * 1000;
+const RESOLVE_WAIT_MS = 2500;
+
+function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}) {
+ const base = config.networkInternalUrl;
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve',
+ fetchImpl,
+ });
+
+ /** Relative avatar paths the Network hands out are relative to the Network. */
+ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
+
+ async function post(body, retried = false) {
+ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(8000),
+ });
+ // A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.
+ if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data.results !== 'object') {
+ const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data.results;
+ }
+
+ /** Resolve many keys in ≤500 chunks. `shape(chunk)` builds the request body. → Map key → projection|null */
+ async function resolveChunks(keys, shape) {
+ const out = new Map();
+ const list = [...new Set(keys.map(String))];
+ for (let i = 0; i < list.length; i += BATCH) {
+ const chunk = list.slice(i, i + BATCH);
+ const results = await post(shape(chunk));
+ for (const k of chunk) out.set(k, results[k] || null);
+ }
+ return out;
+ }
+
+ /** Store a Network projection in the cache. */
+ function remember(p) {
+ if (!p || !p.subject || !p.subject.id) return;
+ store.upsertProjection(db, {
+ subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,
+ avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,
+ });
+ }
+
+ /** Subject ids → projections (and cache them). */
+ async function resolveSubjects(subjectIds) {
+ const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));
+ for (const p of map.values()) remember(p);
+ return map;
+ }
+
+ /** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */
+ async function resolveLegacy(system, ids, { cache = true } = {}) {
+ const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));
+ if (cache) for (const p of map.values()) remember(p);
+ return map;
+ }
+
+ /**
+ * The subject of a Network account id, for user JWTs issued before tokens carried
+ * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).
+ */
+ async function subjectForNetworkUser(networkUserId) {
+ if (networkUserId == null || networkUserId === '') return null;
+ const hit = store.mapGet(db, 'network', 'user', networkUserId);
+ if (hit && hit.target_type === 'subject') return hit.target_id;
+ const map = await resolveLegacy('network', [networkUserId]);
+ const p = map.get(String(networkUserId));
+ if (!p || !p.subject || p.subject.type !== 'user') return null;
+ store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
+ return p.subject.id;
+ }
+
+ /** What a verified user JWT says about its holder is a fresh projection too. */
+ function rememberClaims(subjectId, claims) {
+ if (!subjectId || !claims) return;
+ store.upsertProjection(db, {
+ subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,
+ avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,
+ });
+ }
+
+ let inflight = null;
+ const pending = new Set();
+ /** Background refresh of stale entries, batched and deduplicated. */
+ function refreshLater(ids) {
+ for (const id of ids) pending.add(id);
+ if (inflight) return;
+ inflight = Promise.resolve().then(async () => {
+ while (pending.size) {
+ const batch = [...pending].slice(0, BATCH);
+ batch.forEach((id) => pending.delete(id));
+ try { await resolveSubjects(batch); } catch (err) { console.warn('[Identity] projection refresh failed:', err.message); pending.clear(); }
+ }
+ }).finally(() => { inflight = null; });
+ }
+
+ /**
+ * Projections for display. Missing entries are fetched now (bounded wait); stale ones are
+ * served as they are and refreshed behind the response. → Map subject_id → projection row
+ */
+ async function projections(subjectIds) {
+ const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];
+ if (!ids.length) return new Map();
+ let map = store.getProjections(db, ids);
+ const missing = ids.filter((id) => !map.has(id));
+ const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);
+ if (stale.length) refreshLater(stale);
+ if (missing.length) {
+ let timer;
+ const wait = new Promise((resolve) => { timer = setTimeout(resolve, RESOLVE_WAIT_MS); });
+ // The lookup keeps running (and fills the cache) if the wait runs out first.
+ const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));
+ await Promise.race([lookup, wait]);
+ clearTimeout(timer);
+ map = store.getProjections(db, ids);
+ }
+ return map;
+ }
+
+ return { resolveSubjects, resolveLegacy, subjectForNetworkUser, rememberClaims, projections, tokens };
+}
+
+module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+
+/**
+ * Who is calling — resolved once per request into `req.viewer`:
+ *
+ * { kind: 'anonymous' }
+ * { kind: 'user', subject: 'usr_…'|null, staff, user: <JWT claims> }
+ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from
+ * the token's subject_id; older tokens without one are resolved through the Network.
+ * role admin/global_mod makes the viewer staff. X-OV-* headers are ignored for browsers.
+ * { kind: 'service', service: 'svc:live', claims, subject: 'usr_…'|'gst_…'|null, origin, sourceRef, staff }
+ * A first-party service holding a Network client-credentials token for audience
+ * openvibe.community. It names the person it acts for in X-OV-Subject; with no subject the
+ * write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a
+ * person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
+ * X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate.
+ *
+ * Identity never comes from a request body or query. A request that presents a service token is
+ * judged on that token alone: a bad one is refused, never downgraded to anonymous.
+ */
+const contracts = require('openvibe-contracts');
+const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
+
+const { ids, capabilities, serviceAuth, http } = contracts;
+const STAFF_ROLES = new Set(['admin', 'global_mod']);
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.community';
+
+class ViewerError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
+
+function isActingSubject(v) {
+ return typeof v === 'string' && (ids.isSubjectId('user', v) || ids.isSubjectId('guest', v));
+}
+
+/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */
+function hasCap(viewer, capabilityId) {
+ return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);
+}
+
+function createViewerResolver({ auth, config, network }) {
+ async function fromServiceToken(req, token) {
+ const publicKey = await auth.ensureKey();
+ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
+ if (!r.ok) throw new ViewerError(401, r.code, r.reason);
+ const claims = r.claims;
+
+ const originHeader = req.get('x-ov-origin');
+ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
+ const origin = originHeader === 'ai' ? 'ai' : 'user';
+
+ const subjectHeader = req.get('x-ov-subject');
+ let subject = null;
+ if (subjectHeader) {
+ if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
+ // AI output is never attributed to a person, even if the caller also names one.
+ subject = origin === 'ai' ? null : subjectHeader;
+ }
+
+ let sourceRef = null;
+ const refHeader = req.get('x-ov-source-ref');
+ if (refHeader) {
+ try { sourceRef = JSON.parse(refHeader); } catch { sourceRef = undefined; }
+ if (!sourceRef || !contracts.validate('common.entity-ref@1', sourceRef).valid) {
+ throw new ViewerError(400, 'request.invalid_source_ref', 'X-OV-Source-Ref must be a JSON EntityRef {service, type, id}');
+ }
+ }
+
+ let staff = false;
+ if (req.get('x-ov-staff') === '1') {
+ const c = capabilities.check(claims, 'community.paste.moderate');
+ if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs community.paste.moderate');
+ staff = true;
+ }
+ return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff };
+ }
+
+ async function fromUserToken(token) {
+ const claims = await auth.verify(token);
+ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
+ let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject && network && claims.sub != null) {
+ try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
+ }
+ if (subject && network) { try { network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ const user = claimsToUser(claims);
+ if (subject && !user.subject_id) user.subject_id = subject;
+ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user, token };
+ }
+
+ /**
+ * Resolve the caller. opts.services=false (server-rendered pages) treats a service token as
+ * no identity at all: pages are for browsers.
+ */
+ async function resolve(req, opts = {}) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodeJwtPayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ if (opts.services === false) return ANONYMOUS;
+ return fromServiceToken(req, token);
+ }
+ }
+ const token = extractToken(req);
+ if (!token) return ANONYMOUS;
+ return (await fromUserToken(token)) || ANONYMOUS;
+ }
+
+ /** Express middleware: sets req.viewer (and req.user/req.token for the page renderers). */
+ function middleware(opts = {}) {
+ return async (req, res, next) => {
+ try {
+ req.viewer = await resolve(req, opts);
+ if (req.viewer.kind === 'user') { req.user = req.viewer.user; req.token = req.viewer.token; }
+ next();
+ } catch (err) {
+ if (!(err instanceof ViewerError)) return next(err);
+ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ };
+ }
+
+ return { resolve, middleware };
+}
+
+module.exports = { createViewerResolver, hasCap, isActingSubject, ANONYMOUS, STAFF_ROLES, ViewerError };
@@ -10,7 +10,9 @@ const { createApp } = require('./app');
@@ -10,7 +10,9 @@ const { createApp } = require('./app');
const app = createApp();
const app = createApp();
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
- console.log(`[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
+ console.log(config.pastesAuthority === 'community'
+ ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`
+ : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Media file store client — where new screenshot bytes go.
+ *
+ * Community keeps only the link: an upload is POSTed as multipart (field `file`) to Media's
+ * community file store with a service token (capability media.object.upload), and Media answers
+ * { key, url, size, mime }. The paste stores the absolute public URL and a transitional media
+ * reference `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot
+ * URLs; no bytes are ever copied here.
+ */
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.media',
+ scope: 'media.object.upload',
+ fetchImpl,
+ });
+ const absolute = (u) => (/^https?:\/\//i.test(u) ? u : `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`);
+
+ /** Upload one file. → { key, url (absolute), size, mime, media_ref } */
+ async function upload({ buffer, filename, mime }, retried = false) {
+ const fd = new FormData();
+ fd.append('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), filename || 'upload.bin');
+ const res = await fetchImpl(`${config.mediaInternalUrl}/api/v1/community/files`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: fd,
+ signal: AbortSignal.timeout(60_000),
+ });
+ if (res.status === 401 && !retried) { tokens.invalidate(); return upload({ buffer, filename, mime }, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.key || !data.url) {
+ const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
+ err.status = res.status;
+ throw err;
+ }
+ return { key: data.key, url: absolute(data.url), size: data.size, mime: data.mime, media_ref: ids.legacyMediaId('community', 'file', data.key) };
+ }
+
+ return { upload, tokens };
+}
+
+module.exports = { createMediaFiles };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Drop embedded metadata (EXIF incl. GPS, XMP, text chunks) from an uploaded image without
+ * re-encoding it. Media used to do this by re-encoding with sharp; Community has no image
+ * library, and the pixels don't need touching — only the side-channel segments do.
+ *
+ * JPEG — APP1 (EXIF/XMP) and APP13 (IPTC/Photoshop) segments and COM comments are removed;
+ * APP0 (JFIF), APP2 (ICC colour profile) and APP14 (Adobe) stay.
+ * PNG — eXIf, tEXt, zTXt, iTXt and tIME chunks are removed.
+ * WebP — EXIF and XMP chunks are removed and the VP8X flags updated.
+ * GIF — left alone (animations survive; GIF carries no EXIF).
+ * Anything that doesn't parse cleanly is returned unchanged.
+ */
+
+function stripJpeg(buf) {
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return buf;
+ const parts = [buf.subarray(0, 2)];
+ let i = 2;
+ while (i + 4 <= buf.length) {
+ if (buf[i] !== 0xff) return buf;
+ const marker = buf[i + 1];
+ if (marker === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts); } // start of scan: rest is image data
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue; }
+ const len = buf.readUInt16BE(i + 2);
+ if (len < 2 || i + 2 + len > buf.length) return buf;
+ const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
+ if (!drop) parts.push(buf.subarray(i, i + 2 + len));
+ i += 2 + len;
+ }
+ return buf;
+}
+
+const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+const PNG_DROP = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);
+function stripPng(buf) {
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) return buf;
+ const parts = [PNG_SIG];
+ let i = 8;
+ while (i + 12 <= buf.length) {
+ const len = buf.readUInt32BE(i);
+ const type = buf.toString('latin1', i + 4, i + 8);
+ const end = i + 12 + len;
+ if (end > buf.length) return buf;
+ if (!PNG_DROP.has(type)) parts.push(buf.subarray(i, end));
+ i = end;
+ if (type === 'IEND') return Buffer.concat(parts);
+ }
+ return buf;
+}
+
+function stripWebp(buf) {
+ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf;
+ const chunks = [];
+ let i = 12;
+ while (i + 8 <= buf.length) {
+ const type = buf.toString('latin1', i, i + 4);
+ const len = buf.readUInt32LE(i + 4);
+ const end = i + 8 + len + (len & 1);
+ if (i + 8 + len > buf.length) return buf;
+ if (type !== 'EXIF' && type !== 'XMP ') chunks.push(Buffer.from(buf.subarray(i, Math.min(end, buf.length))));
+ i = end;
+ }
+ for (const c of chunks) {
+ // VP8X flags byte: bit 3 = EXIF present, bit 2 = XMP present.
+ if (c.toString('latin1', 0, 4) === 'VP8X' && c.length > 8) c[8] &= ~0x0c;
+ }
+ const body = Buffer.concat(chunks);
+ const head = Buffer.alloc(12);
+ head.write('RIFF', 0, 'latin1');
+ head.writeUInt32LE(body.length + 4, 4);
+ head.write('WEBP', 8, 'latin1');
+ return Buffer.concat([head, body]);
+}
+
+function stripImageMetadata(buf, mime) {
+ try {
+ if (mime === 'image/jpeg') return stripJpeg(buf);
+ if (mime === 'image/png') return stripPng(buf);
+ if (mime === 'image/webp') return stripWebp(buf);
+ } catch { /* fall through: unchanged */ }
+ return buf;
+}
+
+module.exports = { stripImageMetadata, stripJpeg, stripPng, stripWebp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+
+/**
+ * /api/pastes/* — Community's native paste API (PASTES_AUTHORITY=community).
+ *
+ * Same paths, bodies, status codes and response shapes the browser clients already use against
+ * OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
+ *
+ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)
+ * ?needs_ai=1 — AI work queue, community.paste.moderate only
+ * POST / create (JSON text paste, or multipart with a `screenshot` file)
+ * GET /config limits
+ * POST /screenshot create an image paste (multipart `screenshot`)
+ * GET /by-user/:username someone's pastes (their own hidden ones for them)
+ * GET /:slug one paste (counts a view unless ?no_view=1)
+ * PUT /:slug DELETE /:slug owner or staff
+ * GET /:slug/raw → /p/:slug/raw
+ * GET /:slug/versions edit history (owner or staff)
+ * POST /:slug/fork|like|copy
+ * GET /:slug/comments POST /:slug/comments DELETE /:slug/comments/:id
+ * Staff: GET /admin/stats, GET|DELETE /admin/forks, POST /bulk, POST /:slug/censor, POST /:slug/ai
+ *
+ * Callers (identity/viewer.js): browsers with the Network user JWT, anonymous browsers, and
+ * first-party services with a Network service token. A service must hold the capability of the
+ * route it calls: community.paste.create (create), community.paste.write (everything done as the
+ * acting subject), community.paste.moderate (staff routes, needs_ai, X-OV-Staff). Anonymous writes
+ * are limited to 20 per 10 minutes per address.
+ */
+const express = require('express');
+const multer = require('multer');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const { PasteError } = require('./service');
+const { hasCap } = require('../identity/viewer');
+
+const PASTE_CAPS = ['community.paste.create', 'community.paste.write', 'community.paste.moderate'];
+
+function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = null }) {
+ const router = express.Router();
+ router.use(contracts.http.middleware());
+ router.use(viewers.middleware());
+
+ const json = express.json({ limit: '1mb' });
+ const jsonErrors = (err, _req, res, next) => (err ? res.status(400).json({ error: 'Malformed JSON body' }) : next());
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 30 } }).single('screenshot');
+ const withFile = (req, res, next) => upload(req, res, (err) => (err ? res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large' : (err.message || 'Upload failed') }) : next()));
+ const body = (req, res, next) => (req.is('multipart/form-data') ? withFile(req, res, next) : json(req, res, (err) => jsonErrors(err, req, res, next)));
+
+ // Anonymous posts are allowed; people acting as themselves and services have their own limits.
+ const anonWriteLimiter = sharedLimiter || rateLimit({
+ windowMs: 10 * 60 * 1000,
+ max: 20,
+ standardHeaders: true,
+ legacyHeaders: false,
+ skip: (req) => !req.viewer || req.viewer.kind !== 'anonymous',
+ message: { error: 'Too many anonymous posts — sign in or try again later' },
+ });
+
+ /** A service must hold the route's capability; browsers are judged by identity instead. */
+ const guard = (cap) => (req, res, next) => {
+ const v = req.viewer;
+ if (v.kind !== 'service') return next();
+ const c = contracts.capabilities.check(v.claims, cap);
+ if (c.allowed) return next();
+ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+ };
+ /** Reads: any community.paste.* grant (a service token with none of them isn't meant for us). */
+ const anyCap = (req, res, next) => {
+ const v = req.viewer;
+ if (v.kind !== 'service' || PASTE_CAPS.some((c) => hasCap(v, c))) return next();
+ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'no community.paste capability granted', ctx: req.ov });
+ };
+ /** Staff-only routes: a service with community.paste.moderate, or an admin/global_mod browser. */
+ const staffOnly = (req, res, next) => {
+ const v = req.viewer;
+ if (v.kind === 'service') return guard('community.paste.moderate')(req, res, next);
+ if (v.kind === 'user' && v.staff) return next();
+ if (v.kind === 'anonymous') return res.status(401).json({ error: 'Authentication required' });
+ return res.status(403).json({ error: 'Admin access required' });
+ };
+ /** The AI pass (needs_ai queue, /:slug/ai) is for services only, never a browser — not even staff. */
+ const serviceModerator = (req, res, next) => {
+ if (req.viewer.kind !== 'service') return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'service token with community.paste.moderate required', ctx: req.ov });
+ return guard('community.paste.moderate')(req, res, next);
+ };
+
+ const ctx = (req) => ({ ip: req.ip, userAgent: req.get('user-agent') || '' });
+ const run = (fn, status = 200) => async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out === undefined || res.headersSent) return;
+ res.status(status).json(out);
+ } catch (err) {
+ if (err instanceof PasteError) return res.status(err.status).json(err.body);
+ console.error('[Pastes]', err && err.stack ? err.stack : err);
+ if (!res.headersSent) res.status(500).json({ error: 'Internal error' });
+ }
+ };
+ const slug = (req) => req.params.slug;
+
+ // ── collection + literal paths (before /:slug) ───────────
+ router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'
+ ? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
+ : next()), run((req) => service.list(req.viewer, req.query)));
+
+ router.post('/', guard('community.paste.create'), anonWriteLimiter, body,
+ run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));
+
+ router.get('/config', anyCap, run((req) => service.config(req.viewer)));
+
+ router.get('/admin/stats', staffOnly, run(() => service.stats()));
+ router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
+ router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));
+ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));
+
+ router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
+ run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
+
+ router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));
+
+ // ── one paste ────────────────────────────────────────────
+ router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
+ router.put('/:slug', guard('community.paste.write'), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));
+ router.delete('/:slug', guard('community.paste.write'), run((req) => service.remove(req.viewer, slug(req))));
+
+ router.post('/:slug/censor', staffOnly, withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));
+ router.post('/:slug/ai', serviceModerator, json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));
+
+ router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));
+ router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));
+
+ router.post('/:slug/fork', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));
+ router.post('/:slug/like', guard('community.paste.write'), run((req) => service.like(req.viewer, slug(req))));
+ router.post('/:slug/copy', guard('community.paste.write'), run((req) => service.copy(req.viewer, slug(req))));
+
+ router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));
+ router.post('/:slug/comments', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors,
+ run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));
+ // Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
+ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));
+
+ // Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
+ router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
+
+ router.use((req, res) => res.status(404).json({ error: 'Not found' }));
+ return router;
+}
+
+module.exports = { createPastesApi };
@@ -8,11 +8,15 @@
@@ -8,11 +8,15 @@
* public pastes (Media's page cap) in memory for a short while and answers trending,
* public pastes (Media's page cap) in memory for a short while and answers trending,
* related, language counts and the views/language browse modes from it. Everything here is
* related, language counts and the views/language browse modes from it. Everything here is
* fetched anonymously, so nothing unlisted or private can ever appear on a shared page.
* fetched anonymously, so nothing unlisted or private can ever appear on a shared page.
+ *
+ * With the community authority (source.local) the store answers and the window is kept only
+ * LOCAL_TTL_MS — long enough that one page's latest/trending/languages share a single query.
*/
*/
-const live = require('../live-client');
+const source = require('./source');
const WINDOW = 200; // Media's maximum page size
const WINDOW = 200; // Media's maximum page size
const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live
const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live
+const LOCAL_TTL_MS = 2000;
const PER_PAGE = 24;
const PER_PAGE = 24;
let _recent = { at: 0, promise: null, pastes: [] };
let _recent = { at: 0, promise: null, pastes: [] };
@@ -25,8 +29,8 @@ function _textOnlyPublic(rows) {
@@ -25,8 +29,8 @@ function _textOnlyPublic(rows) {
async function recent() {
async function recent() {
const now = Date.now();
const now = Date.now();
if (_recent.promise) return _recent.promise;
if (_recent.promise) return _recent.promise;
- if (now - _recent.at < TTL_MS) return _recent.pastes;
- _recent.promise = live.listPastes({ limit: WINDOW, offset: 0 })
+ if (now - _recent.at < (source.local ? LOCAL_TTL_MS : TTL_MS)) return _recent.pastes;
+ _recent.promise = source.listPastes({ limit: WINDOW, offset: 0 })
.then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
.then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
.catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });
.catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });
return _recent.promise;
return _recent.promise;
@@ -76,13 +80,13 @@ async function browse(query = {}, ctx = {}) {
@@ -76,13 +80,13 @@ async function browse(query = {}, ctx = {}) {
let pastes, total, windowed = false;
let pastes, total, windowed = false;
if (sort === 'new' && !lang) {
if (sort === 'new' && !lang) {
- const out = await live.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
+ const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
pastes = _textOnlyPublic(out && out.pastes);
pastes = _textOnlyPublic(out && out.pastes);
total = Number(out && out.total) || pastes.length;
total = Number(out && out.total) || pastes.length;
} else {
} else {
let rows;
let rows;
if (q) {
if (q) {
- const out = await live.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
+ const out = await source.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
rows = _textOnlyPublic(out && out.pastes);
rows = _textOnlyPublic(out && out.pastes);
} else {
} else {
rows = await recent();
rows = await recent();
new file mode 100644
new file mode 100644
@@ -0,0 +1,319 @@
@@ -0,0 +1,319 @@
+'use strict';
+
+/**
+ * Paste importer — OpenVibe.Media's paste export bundle → Community's store.
+ *
+ * Bundle (produced by Media, format openvibe.media.pastes-export v1):
+ * { format, version, app, generated_at, since_id, max_id, sha256, counts: {pastes, comments, likes},
+ * pastes: [...], comments: [...], likes: [...] }
+ * sha256 is over JSON.stringify({ pastes, comments, likes }).
+ *
+ * Rules:
+ * - The bundle is verified (format, version, counts, sha256) before anything is touched.
+ * - Idempotent: rows are upserted by legacy_media_id, so a re-run changes nothing and a newer
+ * bundle adds and updates. A paste deleted in Community stays deleted.
+ * - Slugs, timestamps, counters, visibility, NSFW and AI fields are kept. Counters never go
+ * down (Community may have counted views since the last run).
+ * - Owners: bundle user_ids are Live user ids, except rows created before the id-space fix
+ * (cutoff) which may hold a Network user id instead. Every id is resolved through the Network
+ * (system=live, and system=network for pre-cutoff rows). Pre-cutoff with two different
+ * answers → imported ownerless + hold 'ambiguous_owner'; no answer → ownerless + hold
+ * 'owner_unmapped'. A like that can't be mapped is held and not imported.
+ * - AI moment pastes (Live's AI jobs) get origin 'ai' and no owner: AI output is never
+ * attributed to a person. Their stream goes into stream_ref.
+ * - Every mapping lands in legacy_id_map; every run in migration_runs with a reconciliation report.
+ * - dryRun does all of it inside a transaction and rolls back.
+ */
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const store = require('./store');
+
+const FORMAT = 'openvibe.media.pastes-export';
+const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';
+// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image
+// pastes; the frame file names Live's AI jobs upload are the remaining marker.
+const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;
+
+class ImportError extends Error {}
+class Rollback extends Error {}
+
+function toMs(ts) {
+ if (!ts) return NaN;
+ const s = String(ts);
+ return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
+}
+
+function objectOf(v) {
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
+ if (typeof v === 'string') { try { const o = JSON.parse(v); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; } }
+ return null;
+}
+
+function isAiPaste(p) {
+ const meta = objectOf(p.metadata);
+ if (meta && meta.ai_moment === true) return true;
+ return p.type === 'screenshot' && !!meta && typeof meta.original_name === 'string' && AI_FRAME_NAME.test(meta.original_name);
+}
+
+/** Refuse a bundle that isn't exactly what it says it is. */
+function verifyBundle(bundle) {
+ if (!bundle || typeof bundle !== 'object') throw new ImportError('bundle is not a JSON object');
+ if (bundle.format !== FORMAT) throw new ImportError(`unexpected format ${JSON.stringify(bundle.format)} (want ${FORMAT})`);
+ if (bundle.version !== 1) throw new ImportError(`unsupported version ${JSON.stringify(bundle.version)} (want 1)`);
+ for (const k of ['pastes', 'comments', 'likes']) {
+ if (!Array.isArray(bundle[k])) throw new ImportError(`bundle.${k} is not an array`);
+ const want = bundle.counts && bundle.counts[k];
+ if (want !== bundle[k].length) throw new ImportError(`count mismatch for ${k}: counts says ${want}, bundle has ${bundle[k].length}`);
+ }
+ const sha = crypto.createHash('sha256').update(JSON.stringify({ pastes: bundle.pastes, comments: bundle.comments, likes: bundle.likes })).digest('hex');
+ if (sha !== String(bundle.sha256 || '').toLowerCase()) throw new ImportError(`sha256 mismatch: bundle says ${bundle.sha256}, content is ${sha}`);
+}
+
+/**
+ * Import a verified bundle.
+ * opts.resolveLegacy(system, ids) → Map<String(id), projection|null> (Network resolve-batch)
+ * opts.cutoff ISO time of the id-space fix
+ * opts.dryRun roll everything back at the end
+ * opts.source label for migration_runs
+ * → report
+ */
+async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, dryRun = false, source = 'media' } = {}) {
+ verifyBundle(bundle);
+ const cutoffMs = toMs(cutoff);
+ if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);
+ const startedAt = new Date().toISOString();
+
+ // ── 1. Resolve every legacy user id before the transaction (network calls) ──
+ const liveIds = new Set();
+ const preCutoffIds = new Set();
+ const note = (userId, createdAt) => {
+ if (userId == null) return;
+ liveIds.add(String(userId));
+ if (!(toMs(createdAt) >= cutoffMs)) preCutoffIds.add(String(userId));
+ };
+ for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);
+ for (const c of bundle.comments) note(c.user_id, c.created_at);
+ for (const l of bundle.likes) note(l.user_id, l.created_at);
+ const subjectOf = (proj) => (proj && proj.subject && proj.subject.type === 'user' ? proj.subject.id : null);
+ const liveMap = liveIds.size ? await resolveLegacy('live', [...liveIds]) : new Map();
+ const netMap = preCutoffIds.size ? await resolveLegacy('network', [...preCutoffIds]) : new Map();
+
+ function owner(userId, createdAt) {
+ if (userId == null) return { subject: null, hold: null };
+ const key = String(userId);
+ const live = subjectOf(liveMap.get(key));
+ const pre = !(toMs(createdAt) >= cutoffMs);
+ if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };
+ const net = subjectOf(netMap.get(key));
+ if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };
+ if (live || net) return { subject: live || net, hold: null };
+ return { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'pre-cutoff' } };
+ }
+
+ const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });
+ const report = {
+ source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,
+ cutoff, dry_run: !!dryRun,
+ pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },
+ comments: { ...blank(), bundle: bundle.comments.length },
+ likes: { ...blank(), bundle: bundle.likes.length },
+ holds: {},
+ users: { live_ids: liveIds.size, live_mapped: [...liveMap.values()].filter(subjectOf).length, network_checked: preCutoffIds.size, network_mapped: [...netMap.values()].filter(subjectOf).length },
+ };
+ const countHold = (reason) => { report.holds[reason] = (report.holds[reason] || 0) + 1; };
+
+ const hold = db.prepare('INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)');
+ const clearOwnerHolds = db.prepare("DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')");
+ const byLegacy = db.prepare('SELECT * FROM pastes WHERE legacy_media_id = ?');
+ const slugOwner = db.prepare('SELECT id, legacy_media_id FROM pastes WHERE slug = ?');
+ const commentByLegacy = db.prepare('SELECT * FROM paste_comments WHERE legacy_media_id = ?');
+
+ const run = db.transaction(() => {
+ // Users seen: record the mappings the Network gave us.
+ for (const [id, proj] of liveMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'live', 'user', id, 'subject', s); }
+ for (const [id, proj] of netMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'network', 'user', id, 'subject', s); }
+
+ // ── 2. Pastes ──
+ const PASTE_TEXT_FIELDS = ['type', 'title', 'content', 'language', 'visibility', 'screenshot_url', 'media_ref', 'stream_ref', 'metadata',
+ 'burn_after_read', 'pinned', 'is_nsfw', 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'origin', 'updated_at'];
+ const COUNTERS = ['views', 'unique_views', 'copies', 'likes'];
+ for (const p of bundle.pastes) {
+ const ai = isAiPaste(p);
+ if (ai) report.pastes.ai++;
+ const who = ai ? { subject: null, hold: null } : owner(p.user_id, p.created_at);
+ const meta = objectOf(p.metadata);
+ const streamId = p.stream_id != null ? p.stream_id : (meta && meta.stream_id != null ? meta.stream_id : null);
+ const row = {
+ slug: String(p.slug),
+ origin: ai ? 'ai' : 'user',
+ type: p.type === 'screenshot' ? 'screenshot' : 'paste',
+ title: store.sanitizeTitle(p.title),
+ content: p.content == null ? null : String(p.content),
+ language: p.language || 'text',
+ visibility: ['public', 'unlisted', 'private'].includes(p.visibility) ? p.visibility : 'public',
+ screenshot_url: p.screenshot_url || null,
+ media_ref: p.type === 'screenshot' ? ids.legacyMediaId(bundle.app || 'live', 'paste', p.id) : null,
+ stream_ref: streamId != null && streamId !== '' ? JSON.stringify({ service: 'live', type: 'stream', id: String(streamId) }) : null,
+ metadata: meta ? JSON.stringify(meta) : null,
+ burn_after_read: p.burn_after_read ? 1 : 0,
+ pinned: p.pinned ? 1 : 0,
+ is_nsfw: p.is_nsfw ? 1 : 0,
+ ai_summary: p.ai_summary || null,
+ ai_tags: p.ai_tags == null ? null : (typeof p.ai_tags === 'string' ? p.ai_tags : JSON.stringify(p.ai_tags)),
+ ai_analyzed_at: p.ai_analyzed_at || null,
+ views: Number(p.views) || 0, unique_views: Number(p.unique_views) || 0, copies: Number(p.copies) || 0, likes: Number(p.likes) || 0,
+ created_at: p.created_at, updated_at: p.updated_at || p.created_at,
+ };
+ const existing = byLegacy.get(p.id);
+ let communityId;
+ if (!existing) {
+ const clash = slugOwner.get(row.slug);
+ if (clash) {
+ hold.run('paste', String(p.id), 'slug_conflict', JSON.stringify({ slug: row.slug, community_id: clash.id }));
+ countHold('slug_conflict'); report.pastes.skipped++;
+ continue;
+ }
+ const cols = Object.keys(row).concat(['owner_subject', 'legacy_media_id', 'legacy_user_id']);
+ const vals = Object.values(row).concat([who.subject, p.id, p.user_id == null ? null : Number(p.user_id)]);
+ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...vals);
+ communityId = info.lastInsertRowid;
+ report.pastes.inserted++;
+ } else {
+ communityId = existing.id;
+ if (existing.deleted_at) { report.pastes.unchanged++; store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId); continue; }
+ const patch = {};
+ // Edited here since the last import? Then Community's text wins over the bundle's.
+ const localNewer = existing.revision > 1 || toMs(existing.updated_at) > toMs(row.updated_at);
+ if (!localNewer) for (const k of PASTE_TEXT_FIELDS) if (row[k] !== existing[k]) patch[k] = row[k];
+ for (const k of COUNTERS) if (row[k] > existing[k]) patch[k] = row[k];
+ if (who.subject && !existing.owner_subject) patch.owner_subject = who.subject;
+ if (row.origin === 'ai' && existing.owner_subject && existing.origin !== 'ai') patch.owner_subject = null;
+ const legacyUser = p.user_id == null ? null : Number(p.user_id);
+ if (legacyUser !== existing.legacy_user_id) patch.legacy_user_id = legacyUser;
+ const keys = Object.keys(patch);
+ if (keys.length) {
+ db.prepare(`UPDATE pastes SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), communityId);
+ report.pastes.updated++;
+ } else {
+ report.pastes.unchanged++;
+ }
+ }
+ store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId);
+ if (who.hold) {
+ hold.run('paste', String(p.id), who.hold, JSON.stringify(who.detail));
+ countHold(who.hold); report.pastes.held++;
+ } else {
+ clearOwnerHolds.run('paste', String(p.id));
+ }
+ }
+ // Forks point at Community ids (second pass: the original may come later in the bundle).
+ for (const p of bundle.pastes) {
+ if (p.forked_from == null) continue;
+ const target = store.mapGet(db, 'media', 'paste', p.forked_from);
+ const self = store.mapGet(db, 'media', 'paste', p.id);
+ if (!self) continue;
+ if (!target) { report.pastes.forks_unresolved++; continue; }
+ const changed = db.prepare('UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?').run(Number(target.target_id), Number(self.target_id), Number(target.target_id)).changes;
+ if (changed) report.pastes.forks_remapped++;
+ }
+
+ // ── 3. Comments ──
+ const comments = bundle.comments.slice().sort((a, b) => a.id - b.id);
+ for (const c of comments) {
+ const paste = store.mapGet(db, 'media', 'paste', c.paste_id);
+ if (!paste) {
+ hold.run('comment', String(c.id), 'paste_missing', JSON.stringify({ paste_id: c.paste_id }));
+ countHold('paste_missing'); report.comments.skipped++;
+ continue;
+ }
+ const who = owner(c.user_id, c.created_at);
+ const row = {
+ paste_id: Number(paste.target_id), anon_name: c.anon_name || null, message: String(c.message == null ? '' : c.message),
+ is_deleted: c.is_deleted ? 1 : 0, created_at: c.created_at, updated_at: c.updated_at || c.created_at,
+ };
+ const existing = commentByLegacy.get(c.id);
+ let cid;
+ if (!existing) {
+ const info = db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(row.paste_id, who.subject, row.anon_name, row.message, row.is_deleted, row.created_at, row.updated_at, c.id);
+ cid = info.lastInsertRowid;
+ report.comments.inserted++;
+ } else {
+ cid = existing.id;
+ const patch = {};
+ for (const k of ['message', 'anon_name', 'updated_at']) if (row[k] !== existing[k]) patch[k] = row[k];
+ // Deletion is one-way: a comment removed on either side stays removed.
+ if (row.is_deleted && !existing.is_deleted) patch.is_deleted = 1;
+ if (who.subject && !existing.author_subject) patch.author_subject = who.subject;
+ const keys = Object.keys(patch);
+ if (keys.length) {
+ db.prepare(`UPDATE paste_comments SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), cid);
+ report.comments.updated++;
+ } else {
+ report.comments.unchanged++;
+ }
+ }
+ store.mapSet(db, 'media', 'comment', c.id, 'comment', cid);
+ if (who.hold) {
+ hold.run('comment', String(c.id), who.hold, JSON.stringify(who.detail));
+ countHold(who.hold); report.comments.held++;
+ } else {
+ clearOwnerHolds.run('comment', String(c.id));
+ }
+ }
+ for (const c of comments) {
+ if (c.parent_id == null) continue;
+ const self = store.mapGet(db, 'media', 'comment', c.id);
+ const parent = store.mapGet(db, 'media', 'comment', c.parent_id);
+ if (self && parent) db.prepare('UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?').run(Number(parent.target_id), Number(self.target_id), Number(parent.target_id));
+ }
+
+ // ── 4. Likes (only ever imported for a mapped person) ──
+ for (const l of bundle.likes) {
+ const key = `${l.paste_id}:${l.user_id}`;
+ const paste = store.mapGet(db, 'media', 'paste', l.paste_id);
+ if (!paste) {
+ hold.run('like', key, 'paste_missing', JSON.stringify({ paste_id: l.paste_id, user_id: l.user_id }));
+ countHold('paste_missing'); report.likes.skipped++;
+ continue;
+ }
+ const who = l.user_id == null ? { subject: null, hold: 'owner_unmapped', detail: { user_id: null } } : owner(l.user_id, l.created_at);
+ if (!who.subject) {
+ hold.run('like', key, who.hold, JSON.stringify(who.detail));
+ countHold(who.hold); report.likes.held++;
+ continue;
+ }
+ clearOwnerHolds.run('like', key);
+ const info = db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)').run(Number(paste.target_id), who.subject, l.created_at || null);
+ if (info.changes) report.likes.inserted++; else report.likes.unchanged++;
+ }
+
+ report.finished_at = new Date().toISOString();
+ db.prepare('INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)')
+ .run(source, startedAt, report.finished_at, JSON.stringify(report));
+ if (dryRun) throw new Rollback();
+ });
+
+ try { run(); } catch (err) { if (!(err instanceof Rollback)) throw err; }
+ return report;
+}
+
+/** Plain-text reconciliation table. */
+function formatReport(r) {
+ const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;
+ const out = [
+ `Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,
+ ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; id-space cutoff ${r.cutoff}`,
+ '',
+ ` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,
+ line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),
+ '',
+ ` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,
+ ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} pre-cutoff ids checked as network ids (${r.users.network_mapped} mapped)`,
+ ` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,
+ ];
+ return out.join('\n');
+}
+
+module.exports = { importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,611 @@
@@ -0,0 +1,611 @@
+'use strict';
+
+/**
+ * Paste service — the rules of the paste system, on top of store.js.
+ *
+ * This reproduces what OpenVibe.Media's paste API did behind OpenVibe.Live's /api/pastes proxy
+ * (status codes, error texts, response shapes, limits), with identity reworked around Network
+ * subjects:
+ * - a "limited" caller is one acting as a person (a signed-in browser, or a service naming
+ * X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as
+ * they applied to Media's user callers. Anonymous writes are limited per address in api.js.
+ * - private pastes are visible to their owner and to staff only; unlisted ones by slug or to
+ * their owner. Nothing private is ever listed for anyone else.
+ * - AI output (service + X-OV-Origin: ai) is stored with origin 'ai' and no owner.
+ *
+ * Every method takes the resolved viewer (identity/viewer.js) and returns a plain object for
+ * the JSON response, or throws PasteError(status, body).
+ */
+const crypto = require('crypto');
+const store = require('./store');
+const { stripImageMetadata } = require('../media/strip-metadata');
+const { capabilities } = require('openvibe-contracts');
+
+const DEFAULT_LIMITS = {
+ maxSizeKb: 512,
+ screenshotMaxSizeMb: 8,
+ cooldownSeconds: 30,
+ maxPerUserPerDay: 200,
+ commentCooldownSeconds: 10,
+ commentMaxLength: 2000,
+ commentsPerMinute: 5,
+ commentAnonAllowed: true,
+ viewCooldownSec: 6 * 3600,
+ viewEventsPerMinPerIp: 60,
+};
+const IMAGE_MIME = /^image\/(png|jpeg|webp|gif)$/;
+const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;
+// Literal /api/pastes/<word> routes: a paste with one of these slugs would be unreachable there.
+const RESERVED_SLUGS = new Set(['config', 'screenshot', 'bulk', 'admin', 'by-user']);
+const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;
+const AI_DISPLAY_NAME = 'OpenVibe AI';
+
+class PasteError extends Error {
+ constructor(status, body) {
+ super(body && body.error);
+ this.name = 'PasteError';
+ this.status = status;
+ this.body = body;
+ }
+}
+const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };
+
+const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';
+const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
+
+function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
+
+function createPasteService({ db, network = null, media = null, config = {}, limits = {} } = {}) {
+ const L = { ...DEFAULT_LIMITS, ...limits };
+ const visitorSecret = process.env.VIEW_HASH_SECRET
+ || crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
+
+ // ── who may do what ──────────────────────────────────────
+ const isOwner = (v, p) => !!(v && v.subject && p.owner_subject && v.subject === p.owner_subject);
+ const isStaff = (v) => !!(v && v.staff);
+ const canSeeHidden = (v, p) => isOwner(v, p) || isStaff(v);
+ // Media applied cooldowns/daily caps to callers acting as a person; not to anonymous app calls.
+ const limited = (v) => !!(v && v.subject);
+ const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);
+ const isService = (v) => !!(v && v.kind === 'service');
+ const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };
+
+ /**
+ * A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone
+ * but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).
+ */
+ function visible(v, slug) {
+ const p = store.getBySlug(db, String(slug));
+ if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');
+ return p;
+ }
+
+ // ── response shapes ──────────────────────────────────────
+ function streamIdOf(ref) {
+ return ref && ref.service === 'live' && ref.type === 'stream' && /^\d+$/.test(String(ref.id)) ? Number(ref.id) : null;
+ }
+
+ function authorFields(subject, origin, projections) {
+ if (origin === 'ai') return { username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null };
+ const p = subject ? projections.get(subject) : null;
+ return {
+ username: p ? p.username : null,
+ display_name: p ? (p.display_name || p.username) : null,
+ avatar_url: p ? p.avatar_url : null,
+ profile_color: p ? p.profile_color : null,
+ };
+ }
+
+ /**
+ * Media's pastePublic shape plus the author projection. `user_id` carries the owner's subject
+ * id — Community has no numeric user ids — so "user_id is falsy ⇒ anonymous" keeps working,
+ * and a comparison against some service's numeric id never matches by accident.
+ */
+ function shape(p, v, projections) {
+ const ref = parseJson(p.stream_ref);
+ return {
+ unique_views: p.unique_views || 0,
+ id: p.id,
+ app_id: 'community',
+ slug: p.slug,
+ user_id: p.owner_subject || null,
+ owner_subject: p.owner_subject || null,
+ origin: p.origin,
+ type: p.type,
+ title: p.title,
+ content: p.content,
+ language: p.language,
+ visibility: p.visibility,
+ stream_id: streamIdOf(ref),
+ stream_ref: ref,
+ screenshot_url: p.screenshot_url || null,
+ metadata: p.metadata || null,
+ burn_after_read: !!p.burn_after_read,
+ forked_from: p.forked_from || null,
+ pinned: !!p.pinned,
+ views: p.views || 0,
+ copies: p.copies || 0,
+ likes: p.likes || 0,
+ is_nsfw: !!p.is_nsfw,
+ ai_summary: p.ai_summary || null,
+ ai_tags: p.ai_tags || null,
+ ai_analyzed_at: p.ai_analyzed_at || null,
+ revision: p.revision,
+ url: `/p/${p.slug}`,
+ raw_url: `/p/${p.slug}/raw`,
+ created_at: p.created_at,
+ updated_at: p.updated_at,
+ is_owner: isOwner(v, p),
+ ...authorFields(p.owner_subject, p.origin, projections),
+ };
+ }
+
+ async function projectionsFor(subjects) {
+ if (!network) return store.getProjections(db, subjects);
+ try { return await network.projections(subjects); } catch { return store.getProjections(db, subjects); }
+ }
+
+ async function shapeMany(rows, v, { preview = false } = {}) {
+ const projections = await projectionsFor(rows.map((r) => r.owner_subject));
+ return rows.map((r) => {
+ const out = shape(r, v, projections);
+ if (preview) out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists
+ return out;
+ });
+ }
+ async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }
+
+ function shapeComment(c, projections) {
+ const out = {
+ id: c.id, paste_id: c.paste_id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
+ parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.is_deleted,
+ created_at: c.created_at, updated_at: c.updated_at,
+ ...authorFields(c.author_subject, 'user', projections),
+ };
+ if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, projections)); out.reply_count = c.reply_count; }
+ return out;
+ }
+
+ // ── rate limits (acting persons) ─────────────────────────
+ function pasteRateCheck(v) {
+ if (!limited(v)) return;
+ if (L.cooldownSeconds > 0) {
+ const elapsed = (Date.now() - store.lastPasteTime(db, v.subject)) / 1000;
+ if (elapsed < L.cooldownSeconds) {
+ const wait = Math.ceil(L.cooldownSeconds - elapsed);
+ fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });
+ }
+ }
+ if (L.maxPerUserPerDay > 0 && store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {
+ fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);
+ }
+ }
+
+ // Comment limits, per acting person (Media keyed them by address, which behind a proxy is the proxy).
+ const recentComments = new Map(); // key → [{ at, message }] newest first
+ function commentRateCheck(key, message) {
+ const now = Date.now();
+ const list = (recentComments.get(key) || []).filter((c) => now - c.at < 60_000);
+ recentComments.set(key, list);
+ if (list.length && now - list[0].at < L.commentCooldownSeconds * 1000) {
+ fail(429, `Please wait ${Math.ceil((L.commentCooldownSeconds * 1000 - (now - list[0].at)) / 1000)}s before commenting again`);
+ }
+ if (list.length >= L.commentsPerMinute) fail(429, 'Too many comments. Please slow down.');
+ if (list.length && list[0].message === message) fail(400, 'Duplicate comment');
+ }
+ function commentRecorded(key, message) {
+ const list = recentComments.get(key) || [];
+ list.unshift({ at: Date.now(), message });
+ recentComments.set(key, list.slice(0, 20));
+ }
+
+ // ── view counting (Media's rules: per visitor, cooldown, no owner/bot views) ──
+ const ipBudget = new Map();
+ function overBudget(ip) {
+ const now = Date.now();
+ const b = ipBudget.get(ip);
+ if (!b || now - b.start >= 60_000) { ipBudget.set(ip, { start: now, n: 1 }); return false; }
+ b.n += 1;
+ return b.n > L.viewEventsPerMinPerIp;
+ }
+ const sweep = setInterval(() => {
+ const now = Date.now();
+ for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);
+ for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);
+ try { store.pruneVisits(db, 30); } catch { /* db closed in tests */ }
+ }, 10 * 60_000);
+ if (sweep.unref) sweep.unref();
+
+ function countView(v, p, { ip, userAgent } = {}) {
+ if (BOT_UA.test(String(userAgent || ''))) return;
+ if (overBudget(ip || 'unknown')) return;
+ const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
+ const r = store.recordVisit(db, p.id, visitor, L.viewCooldownSec);
+ p.views = r.views; p.unique_views = r.unique_views;
+ }
+
+ /** Burn-after-read: the paste is gone the moment this read makes it spent. */
+ function burn(p) {
+ store.softDelete(db, p.id);
+ fail(410, 'This paste has been burned after reading.');
+ }
+
+ // ── validation helpers ───────────────────────────────────
+ function visibilityOf(value, owned) {
+ const vis = ['unlisted', 'private'].includes(value) ? value : 'public';
+ // Nobody could ever open an ownerless private paste; it becomes unlisted instead.
+ return vis === 'private' && !owned ? 'unlisted' : vis;
+ }
+
+ function streamRefFor(v, body) {
+ if (v.kind === 'service' && v.sourceRef) return JSON.stringify(v.sourceRef);
+ const sid = body.stream_id;
+ if (sid != null && /^\d{1,18}$/.test(String(sid))) return JSON.stringify({ service: 'live', type: 'stream', id: String(sid) });
+ return null;
+ }
+
+ /** Service callers may carry structured metadata (AI moments); browsers may not. */
+ function serviceMetadata(v, body) {
+ if (!isService(v) || body.metadata == null || body.metadata === '') return null;
+ const obj = typeof body.metadata === 'string' ? parseJson(body.metadata) : body.metadata;
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) fail(400, 'metadata must be a JSON object');
+ const text = JSON.stringify(obj);
+ if (text.length > 16 * 1024) fail(400, 'metadata too large (max 16 KB)');
+ return obj;
+ }
+
+ function aiFields(v, body) {
+ if (!isService(v) || body.ai_summary == null) return {};
+ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
+ return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
+ }
+
+ function slugFor(v, body) {
+ if (!isService(v) || body.slug == null || body.slug === '') return store.generateSlug(db);
+ const slug = String(body.slug);
+ if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
+ if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
+ return slug;
+ }
+
+ function created(row, extra = {}) {
+ return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
+ }
+
+ // ═════════════════════════════════════════════════════════
+ return {
+ PasteError,
+ limits: L,
+
+ /** GET /api/pastes */
+ async list(v, q = {}) {
+ const needsAi = truthy(q.needs_ai);
+ if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');
+ const limit = intIn(q.limit, 50, 1, 200);
+ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
+ const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };
+ // ?username= lists one person's pastes (Live resolved it to its own id; we go through
+ // the projection cache). Their own unlisted/private ones only for them (or staff).
+ if (q.username && q.username !== 'all') {
+ const subject = store.subjectsByUsername(db, q.username)[0];
+ if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };
+ opts.ownerSubject = subject;
+ if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;
+ }
+ const { rows, total } = store.listPastes(db, opts);
+ return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };
+ },
+
+ /** GET /api/pastes/by-user/:username */
+ async byUser(v, username, q = {}) {
+ const subject = store.subjectsByUsername(db, username)[0];
+ if (!subject) fail(404, 'User not found');
+ const limit = intIn(q.limit, 30, 1, 100);
+ const includeHidden = v.subject === subject;
+ const { rows, total } = store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });
+ const proj = store.getProjections(db, [subject]).get(subject);
+ return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };
+ },
+
+ /** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
+ async get(v, slug, ctx = {}) {
+ const p = visible(v, slug);
+ if (!ctx.noView && !isOwner(v, p)) {
+ if (p.burn_after_read) {
+ p.views = store.bumpViews(db, p.id);
+ if (p.views > 1) burn(p);
+ } else {
+ countView(v, p, ctx);
+ }
+ }
+ const liked = v.subject ? store.hasLiked(db, p.id, v.subject) : false;
+ return { paste: { ...(await shapeOne(p, v)), liked } };
+ },
+
+ /** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
+ raw(v, slug, ctx = {}) {
+ const p = store.getBySlug(db, String(slug));
+ if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
+ if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
+ if (!isOwner(v, p)) {
+ if (p.burn_after_read) {
+ if (p.views > 0) burn(p);
+ store.bumpViews(db, p.id);
+ } else {
+ countView(v, p, ctx);
+ }
+ }
+ return { content: String(p.content || '') };
+ },
+
+ /** GET /p/:slug/screenshot target (the stored Media URL). */
+ screenshotUrl(v, slug) {
+ const p = store.getBySlug(db, String(slug));
+ if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
+ return p.screenshot_url;
+ },
+
+ /** POST /api/pastes (text) */
+ async createText(v, body = {}) {
+ pasteRateCheck(v);
+ const content = body.content;
+ if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
+ if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
+ const metadata = serviceMetadata(v, body);
+ const row = store.insertPaste(db, {
+ slug: slugFor(v, body),
+ owner_subject: v.subject || null,
+ origin: v.origin === 'ai' ? 'ai' : 'user',
+ type: 'paste',
+ title: store.sanitizeTitle(body.title),
+ content: content.trim(),
+ language: store.detectLanguage(content, body.language),
+ visibility: visibilityOf(body.visibility, !!v.subject),
+ stream_ref: streamRefFor(v, body),
+ metadata: metadata ? JSON.stringify(metadata) : null,
+ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
+ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
+ ...aiFields(v, body),
+ });
+ return created(row, { paste: await shapeOne(row, v) });
+ },
+
+ /** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
+ async createScreenshot(v, body = {}, file, ctx = {}) {
+ pasteRateCheck(v);
+ if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
+ if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
+ if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
+ if (!media) fail(503, 'Media service unavailable');
+ const slug = slugFor(v, body);
+ const extra = serviceMetadata(v, body);
+ const bytes = stripImageMetadata(file.buffer, file.mimetype);
+ let stored;
+ try {
+ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype });
+ } catch (err) {
+ console.warn('[Pastes] screenshot upload failed:', err.message);
+ fail(502, 'Media service unavailable');
+ }
+ const metadata = {
+ ...(extra || {}),
+ page_url: body.page_url || (extra && extra.page_url) || null,
+ user_agent: body.user_agent || ctx.userAgent || null,
+ original_name: file.originalname || null,
+ size_bytes: bytes.length,
+ mime_type: file.mimetype,
+ };
+ const row = store.insertPaste(db, {
+ slug,
+ owner_subject: v.subject || null,
+ origin: v.origin === 'ai' ? 'ai' : 'user',
+ type: 'screenshot',
+ title: store.sanitizeTitle(body.title || 'Screenshot'),
+ content: String(body.description || body.content || ''),
+ language: 'text',
+ visibility: visibilityOf(body.visibility, !!v.subject),
+ screenshot_url: stored.url,
+ media_ref: stored.media_ref,
+ stream_ref: streamRefFor(v, body),
+ metadata: JSON.stringify(metadata),
+ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
+ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
+ ...aiFields(v, body),
+ });
+ return created(row, { paste: await shapeOne(row, v) });
+ },
+
+ /** PUT /api/pastes/:slug — owner (or staff) only. */
+ async update(v, slug, body = {}) {
+ needIdentity(v);
+ const p = visible(v, slug);
+ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
+ const patch = {};
+ if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);
+ if (body.content !== undefined && p.type === 'paste') {
+ const content = String(body.content);
+ if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');
+ patch.content = content;
+ patch.language = store.detectLanguage(content, body.language);
+ }
+ if (body.visibility !== undefined) patch.visibility = visibilityOf(body.visibility, !!p.owner_subject);
+ if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
+ if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
+ if (!Object.keys(patch).length) fail(400, 'Nothing to update');
+ const row = store.updatePaste(db, p.id, patch, v.subject || null);
+ return { paste: await shapeOne(row, v) };
+ },
+
+ /** DELETE /api/pastes/:slug — owner (or staff) only. */
+ remove(v, slug) {
+ needIdentity(v);
+ const p = visible(v, slug);
+ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
+ store.softDelete(db, p.id);
+ return { success: true };
+ },
+
+ /** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */
+ versions(v, slug) {
+ needIdentity(v);
+ const p = visible(v, slug);
+ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
+ const list = store.listVersions(db, p.id);
+ return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };
+ },
+
+ /** POST /api/pastes/:slug/fork */
+ async fork(v, slug) {
+ const original = visible(v, slug);
+ if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
+ pasteRateCheck(v);
+ const row = store.insertPaste(db, {
+ slug: store.generateSlug(db),
+ owner_subject: v.subject || null,
+ origin: v.origin === 'ai' ? 'ai' : 'user',
+ type: 'paste',
+ title: store.sanitizeTitle(`Fork of ${original.title}`),
+ content: original.content,
+ language: original.language,
+ visibility: 'public',
+ forked_from: original.id,
+ });
+ return created(row, { paste: await shapeOne(row, v) });
+ },
+
+ /** POST /api/pastes/:slug/like — toggles; needs a person. */
+ like(v, slug) {
+ if (!v.subject) fail(401, 'Authentication required');
+ const p = visible(v, slug);
+ return store.toggleLike(db, p.id, v.subject);
+ },
+
+ /** POST /api/pastes/:slug/copy */
+ copy(v, slug) {
+ const p = visible(v, slug);
+ return { copies: store.incrementCopies(db, p.id) };
+ },
+
+ /** GET /api/pastes/:slug/comments */
+ async comments(v, slug, q = {}) {
+ const p = visible(v, slug);
+ const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
+ const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
+ const list = store.listComments(db, p.id, limit, offset);
+ const subjects = [];
+ for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
+ const projections = await projectionsFor(subjects);
+ return { comments: list.map((c) => shapeComment(c, projections)), total: store.countComments(db, p.id) };
+ },
+
+ /** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
+ async addComment(v, slug, body = {}, ctx = {}) {
+ const p = visible(v, slug);
+ const author = v.subject || null;
+ if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
+ const message = String(body.message || '').trim();
+ if (!message) fail(400, 'Comment cannot be empty');
+ if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);
+ let anonName = null;
+ if (!author) {
+ anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';
+ anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
+ }
+ const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
+ if (limited(v)) commentRateCheck(rateKey, message);
+ const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
+ if (parentId) {
+ const parent = store.getComment(db, parentId);
+ if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
+ if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
+ }
+ const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
+ if (limited(v)) commentRecorded(rateKey, message);
+ return { comment: shapeComment(c, await projectionsFor([author])) };
+ },
+
+ /** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
+ deleteComment(v, slug, commentId) {
+ needIdentity(v);
+ const p = visible(v, slug);
+ const c = store.getComment(db, parseInt(commentId, 10));
+ if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
+ const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
+ if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
+ store.softDeleteComment(db, c.id);
+ return { message: 'Comment deleted' };
+ },
+
+ /** GET /api/pastes/config */
+ config(v) {
+ return {
+ maxSizeKb: L.maxSizeKb,
+ screenshotMaxSizeMb: L.screenshotMaxSizeMb,
+ cooldownSeconds: L.cooldownSeconds,
+ maxPerUserPerDay: L.maxPerUserPerDay,
+ todayCount: v.subject ? store.countOwnerSince(db, v.subject, '-1 day') : 0,
+ };
+ },
+
+ // ── staff (api.js has already checked the viewer is staff) ──
+ stats() { return { stats: store.stats(db) }; },
+
+ forks(q = {}) {
+ const limit = intIn(q.limit, 100, 1, 500);
+ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
+ const { forks, total } = store.listForks(db, limit, offset);
+ return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
+ },
+
+ deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },
+
+ bulk(body = {}) {
+ const { slugs, action } = body;
+ if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
+ if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
+ let done = 0, skipped = 0;
+ db.transaction(() => {
+ for (const slug of slugs.slice(0, 500)) {
+ const p = store.getBySlug(db, String(slug));
+ if (!p) { skipped++; continue; }
+ if (action === 'delete') store.softDelete(db, p.id);
+ else store.setVisibility(db, p.id, action);
+ done++;
+ }
+ })();
+ return { done, skipped };
+ },
+
+ /** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */
+ async censor(v, slugOrId, file) {
+ let p = store.getBySlug(db, String(slugOrId));
+ if (!p && /^\d+$/.test(String(slugOrId))) p = store.getById(db, parseInt(slugOrId, 10));
+ if (!p) fail(404, 'Paste not found');
+ if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
+ if (!file || !file.buffer) fail(400, 'Censored image is required');
+ if (!/^image\/(png|jpeg|webp)$/.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, or WebP images allowed');
+ if (!media) fail(503, 'Media service unavailable');
+ let stored;
+ try {
+ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype });
+ } catch (err) {
+ console.warn('[Pastes] censor upload failed:', err.message);
+ fail(502, 'Media service unavailable');
+ }
+ const row = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
+ return { paste: await shapeOne(row, v) };
+ },
+
+ /** POST /api/pastes/:slug/ai — the AI pass writes its results back. */
+ async setAi(v, slug, body = {}) {
+ const p = store.getBySlug(db, String(slug));
+ if (!p) fail(404, 'Paste not found');
+ const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
+ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
+ const row = store.setAi(db, p.id, summary, tags);
+ return { ok: true, paste: await shapeOne(row, v) };
+ },
+ };
+}
+
+module.exports = { createPasteService, PasteError, DEFAULT_LIMITS, AI_DISPLAY_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+
+/**
+ * Where server-rendered pages read pastes from.
+ *
+ * 'live' authority → live-client.js (OpenVibe.Live's API, Media behind it) — the default.
+ * 'community' authority → this site's own store, through the same service the API uses, so a
+ * page and the API can never disagree about visibility or view counts.
+ *
+ * Both expose the live-client call shapes (listPastes / getPaste / listByUser / createPaste);
+ * `ctx` carries the visitor ({ token, ip, userAgent, viewer, noView }).
+ */
+const live = require('../live-client');
+const { ANONYMOUS } = require('../identity/viewer');
+
+let current = { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
+
+function localSource(service) {
+ const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;
+ return {
+ local: true,
+ service,
+ listPastes: (query, ctx = {}) => service.list(who(ctx), query || {}),
+ getPaste: async (slug, ctx = {}) => (await service.get(who(ctx), slug, { noView: !!ctx.noView, ip: ctx.ip, userAgent: ctx.userAgent })).paste,
+ listByUser: (username, query, ctx = {}) => service.byUser(who(ctx), username, query || {}),
+ createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),
+ };
+}
+
+/** Switch to the store (community authority); passing nothing goes back to Live. */
+function use(service) {
+ current = service ? localSource(service) : { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
+ return current;
+}
+
+module.exports = {
+ use,
+ get local() { return current.local; },
+ listPastes: (...a) => current.listPastes(...a),
+ getPaste: (...a) => current.getPaste(...a),
+ listByUser: (...a) => current.listByUser(...a),
+ createPaste: (...a) => current.createPaste(...a),
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,393 @@
@@ -0,0 +1,393 @@
+'use strict';
+
+/**
+ * Paste store — pure database functions over Community's own tables (server/db.js).
+ *
+ * No HTTP, no identity resolution, no policy: every function takes the database handle first
+ * and does exactly the SQL its name says (parameterized, always). Who may call what, rate
+ * limits and response shaping live in service.js.
+ *
+ * Deleted pastes are soft-deleted (deleted_at set, content scrubbed) so their slug and legacy id
+ * stay reserved and a re-import can never bring them back; every read below skips them.
+ */
+const crypto = require('crypto');
+
+// ── Slugs (the same adjective-noun-number style OpenVibe.Media has always minted) ──
+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',
+];
+
+/** A free adj-noun-NN slug. Deleted pastes keep theirs, so an old link never points somewhere new. */
+function generateSlug(db) {
+ const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
+ for (let i = 0; i < 10; i++) {
+ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;
+ if (!taken.get(slug)) return slug;
+ }
+ // ~180k combinations; if ten draws all collide, widen the number rather than fail.
+ for (let i = 0; i < 10; i++) {
+ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;
+ if (!taken.get(slug)) return slug;
+ }
+ throw new Error('Could not generate a unique paste slug');
+}
+
+function sanitizeTitle(title) {
+ return String(title == null ? '' : title).trim().slice(0, 200) || 'Untitled';
+}
+
+/** Media's language sniffing, unchanged: an explicit hint wins unless it is 'auto'. */
+function detectLanguage(content, hint) {
+ if (hint && hint !== 'auto') return String(hint).slice(0, 32);
+ 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';
+}
+
+const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);
+
+// ── Pastes ───────────────────────────────────────────────────
+
+function getBySlug(db, slug) {
+ return db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;
+}
+
+function getById(db, id) {
+ return db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;
+}
+
+const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
+ 'screenshot_url', 'media_ref', 'stream_ref', 'metadata', 'burn_after_read', 'forked_from', 'pinned', 'is_nsfw',
+ 'ai_summary', 'ai_tags', 'ai_analyzed_at'];
+
+/** Insert a new paste (fields already validated); returns the stored row. */
+function insertPaste(db, fields) {
+ const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
+ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
+ .run(...cols.map((c) => fields[c]));
+ return getById(db, info.lastInsertRowid);
+}
+
+/**
+ * Apply an edit. `patch` may hold title/content/language/visibility/is_nsfw/pinned. A change to
+ * title, content or language bumps the revision and appends a paste_versions row; the first
+ * edit also snapshots the original as revision 1, so the history is complete without storing a
+ * copy of every paste that is never edited.
+ */
+function updatePaste(db, id, patch, editedBy = null) {
+ return db.transaction(() => {
+ const cur = getById(db, id);
+ if (!cur) return null;
+ const sets = [];
+ const params = [];
+ for (const k of ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned']) {
+ if (patch[k] === undefined) continue;
+ sets.push(`${k} = ?`);
+ params.push(patch[k]);
+ }
+ const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);
+ if (textChanged) {
+ const hasCur = db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);
+ if (!hasCur) {
+ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);
+ }
+ sets.push('revision = revision + 1');
+ }
+ sets.push('updated_at = CURRENT_TIMESTAMP');
+ db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
+ const next = getById(db, id);
+ if (textChanged) {
+ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(id, next.revision, next.title, next.content, next.language, editedBy);
+ }
+ return next;
+ })();
+}
+
+function listVersions(db, pasteId) {
+ return db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
+}
+
+/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
+function softDelete(db, id) {
+ return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
+ ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
+}
+
+/**
+ * List pastes.
+ * opts.ownerSubject only this owner's pastes
+ * opts.includeHidden also unlisted + private (only ever set for the owner or staff)
+ * opts.type 'paste' | 'screenshot'
+ * opts.search substring of title or content
+ * opts.origin 'user' | 'ai' (default: all)
+ * opts.needsAi pastes without an AI summary, any visibility (staff work queue)
+ * opts.sort 'oldest' | newest (default)
+ * Returns { rows, total }.
+ */
+function listPastes(db, opts = {}) {
+ const where = ['deleted_at IS NULL'];
+ const params = [];
+ // The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
+ if (opts.needsAi) where.push("COALESCE(ai_summary, '') = '' AND ai_analyzed_at IS NULL");
+ else if (!opts.includeHidden) where.push("visibility = 'public'");
+ if (opts.origin === 'user' || opts.origin === 'ai' || opts.origin === 'imported') { where.push('origin = ?'); params.push(opts.origin); }
+ if (opts.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }
+ if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
+ if (opts.search) {
+ const q = `%${likeEscape(opts.search)}%`;
+ where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
+ params.push(q, q);
+ }
+ const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
+ const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
+ const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
+ const clause = where.join(' AND ');
+ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)
+ .all(...params, limit, offset);
+ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
+ return { rows, total };
+}
+
+/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */
+function countOwnerSince(db, subject, sinceSql) {
+ return db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
+}
+
+/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */
+function lastPasteTime(db, subject) {
+ const row = db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);
+ return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;
+}
+
+function hasLiked(db, pasteId, subject) {
+ return !!db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);
+}
+
+/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */
+function toggleLike(db, pasteId, subject) {
+ return db.transaction(() => {
+ const already = hasLiked(db, pasteId, subject);
+ if (already) db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);
+ else db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id) VALUES (?, ?)').run(pasteId, subject);
+ db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);
+ return { liked: !already, likes: db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId).likes };
+ })();
+}
+
+function incrementCopies(db, pasteId) {
+ db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);
+ return db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId).copies;
+}
+
+/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */
+function bumpViews(db, pasteId) {
+ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
+ return db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId).views;
+}
+
+/**
+ * Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a
+ * unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }
+ */
+function recordVisit(db, pasteId, visitor, cooldownSec) {
+ return db.transaction(() => {
+ const row = db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);
+ let counted = false, unique = false;
+ if (!row) {
+ db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);
+ db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);
+ counted = true; unique = true;
+ } else {
+ const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');
+ if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {
+ db.prepare('UPDATE paste_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);
+ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
+ counted = true;
+ }
+ }
+ const c = db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);
+ return { counted, unique, views: c.views, unique_views: c.unique_views };
+ })();
+}
+
+function pruneVisits(db, days = 30) {
+ return db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`).changes;
+}
+
+function setAi(db, pasteId, summary, tags) {
+ db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = CURRENT_TIMESTAMP WHERE id = ?').run(summary, tags, pasteId);
+ return getById(db, pasteId);
+}
+
+function setScreenshot(db, pasteId, url, mediaRef) {
+ db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(url, mediaRef, pasteId);
+ return getById(db, pasteId);
+}
+
+function setVisibility(db, pasteId, visibility) {
+ return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;
+}
+
+/** Media's admin stats shape. */
+function stats(db) {
+ const r = db.prepare(`
+ SELECT COUNT(*) AS total,
+ SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes,
+ SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,
+ SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,
+ COALESCE(SUM(views), 0) AS totalViews,
+ COALESCE(SUM(copies), 0) AS totalCopies,
+ COALESCE(SUM(likes), 0) AS totalLikes
+ FROM pastes WHERE deleted_at IS NULL`).get() || {};
+ return {
+ total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,
+ totalViews: r.totalViews || 0, totalCopies: r.totalCopies || 0, totalLikes: r.totalLikes || 0,
+ };
+}
+
+function listForks(db, limit, offset) {
+ const forks = db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
+ FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
+ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);
+ const total = db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get().c;
+ return { forks, total };
+}
+
+function deleteAllForks(db) {
+ return db.transaction(() => {
+ const ids = db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all().map((r) => r.id);
+ for (const id of ids) softDelete(db, id);
+ return ids.length;
+ })();
+}
+
+// ── Comments ─────────────────────────────────────────────────
+
+function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {
+ const info = db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?)')
+ .run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);
+ return getComment(db, info.lastInsertRowid);
+}
+
+function getComment(db, id) {
+ return db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;
+}
+
+/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */
+function listComments(db, pasteId, limit = 50, offset = 0) {
+ const top = db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
+ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);
+ const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');
+ for (const c of top) { c.replies = replies.all(c.id); c.reply_count = c.replies.length; }
+ return top;
+}
+
+function countComments(db, pasteId) {
+ return db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId).c;
+}
+
+function softDeleteComment(db, id) {
+ return db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id).changes;
+}
+
+// ── Subject projection (display cache — never authority) ─────
+
+function getProjections(db, subjectIds) {
+ const out = new Map();
+ const ids = [...new Set((subjectIds || []).filter(Boolean))];
+ const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');
+ for (const id of ids) { const r = stmt.get(id); if (r) out.set(id, r); }
+ return out;
+}
+
+/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */
+function upsertProjection(db, p) {
+ if (!p || !p.subject_id) return;
+ db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)
+ VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP)
+ ON CONFLICT(subject_id) DO UPDATE SET
+ username = COALESCE(excluded.username, username),
+ display_name = COALESCE(excluded.display_name, display_name),
+ avatar_url = COALESCE(excluded.avatar_url, avatar_url),
+ profile_color = COALESCE(excluded.profile_color, profile_color),
+ refreshed_at = CURRENT_TIMESTAMP`)
+ .run({
+ subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,
+ avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,
+ });
+}
+
+/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */
+function subjectsByUsername(db, username) {
+ return db.prepare('SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC')
+ .all(String(username)).map((r) => r.subject_id);
+}
+
+// ── Legacy id map ────────────────────────────────────────────
+
+function mapGet(db, system, type, id) {
+ return db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
+ .get(String(system), String(type), String(id)) || null;
+}
+
+function mapSet(db, system, type, id, targetType, targetId) {
+ db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)
+ .run(String(system), String(type), String(id), String(targetType), String(targetId));
+}
+
+module.exports = {
+ generateSlug, sanitizeTitle, detectLanguage,
+ getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
+ countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
+ setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
+ createComment, getComment, listComments, countComments, softDeleteComment,
+ getProjections, upsertProjection, subjectsByUsername,
+ mapGet, mapSet,
+ SLUG_ADJECTIVES, SLUG_NOUNS,
+};
@@ -217,7 +217,10 @@ function pastePage({ paste: p, related, user }) {
@@ -217,7 +217,10 @@ function pastePage({ paste: p, related, user }) {
const description = pasteDescription(p);
const description = pasteDescription(p);
const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;
const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;
const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
- const isOwner = !!(user && p.user_id != null && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)));
+ // Community-authority pastes name their owner by Network subject; Live-proxied ones by name.
+ const isOwner = !!(user && (p.is_owner === true
+ || (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
+ || (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
const hl = isShot ? null : highlight(p.content, p.language);
const hl = isShot ? null : highlight(p.content, p.language);
const ext = extensionFor(p.language);
const ext = extensionFor(p.language);
@@ -3,10 +3,17 @@
@@ -3,10 +3,17 @@
const http = require('http');
const http = require('http');
const mockLive = require('./mock-live');
const mockLive = require('./mock-live');
const mockNetwork = require('./mock-network');
const mockNetwork = require('./mock-network');
+const mockMedia = require('./mock-media');
-async function boot() {
+/**
+ * opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a
+ * fresh in-memory database, with Network and Media mocks behind it.
+ * opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
+ */
+async function boot(opts = {}) {
const liveSrv = await mockLive.start();
const liveSrv = await mockLive.start();
const netSrv = await mockNetwork.start();
const netSrv = await mockNetwork.start();
+ const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.BASE_URL = 'https://openvibe.community';
process.env.BASE_URL = 'https://openvibe.community';
process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;
process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;
@@ -19,9 +26,17 @@ async function boot() {
@@ -19,9 +26,17 @@ async function boot() {
process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.community/auth/callback';
process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.community/auth/callback';
process.env.COOKIE_SECURE = 'true';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = '1';
process.env.TRUST_PROXY = '1';
+ process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
+ process.env.PASTES_AUTHORITY = opts.authority || 'live';
+ process.env.COMMUNITY_DB_PATH = ':memory:';
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
- const app = createApp();
+ const appOpts = {};
+ if (opts.authority === 'community') {
+ appOpts.db = require('../../server/db').openDb(':memory:');
+ appOpts.pasteLimits = opts.pasteLimits;
+ }
+ const app = createApp(appOpts);
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -41,8 +56,8 @@ async function boot() {
@@ -41,8 +56,8 @@ async function boot() {
return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
}
}
return {
return {
- app, base, get, jar, live: liveSrv, network: netSrv,
- close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); },
+ app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,
+ close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close(); },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+/**
+ * A stand-in for OpenVibe.Media's file store: POST /api/v1/community/files (multipart `file`)
+ * with a Network service token for openvibe.media holding media.object.upload. Keeps each
+ * upload's raw bytes so tests can check what left Community.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+function start({ publicPem, issuer }) {
+ const uploads = [];
+ let failNext = false;
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
+ if (!v.ok) return json(401, { code: v.code, error: v.reason });
+ if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });
+ if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
+ const body = Buffer.concat(chunks);
+ // Pull the `file` part out of the multipart body.
+ const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);
+ const boundary = m && (m[1] || m[2]);
+ const parts = body.toString('latin1').split(`--${boundary}`);
+ const part = parts.find((p) => /name="file"/.test(p)) || '';
+ const headerEnd = part.indexOf('\r\n\r\n');
+ const head = part.slice(0, headerEnd);
+ const bytes = Buffer.from(part.slice(headerEnd + 4, part.length - 2), 'latin1');
+ const name = (head.match(/filename="([^"]*)"/) || [])[1] || 'upload.bin';
+ const mime = ((head.match(/Content-Type:\s*([^\r\n]+)/i) || [])[1] || 'application/octet-stream').trim();
+ const key = `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 12)}-${name}`;
+ uploads.push({ key, name, mime, bytes, claims: v.claims });
+ json(201, { key, url: `/f/${key}`, size: bytes.length, mime });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`,
+ uploads,
+ failNext: () => { failNext = true; },
+ close: () => new Promise((r) => server.close(r)),
+ })));
+}
+
+module.exports = { start };
@@ -1,25 +1,54 @@
@@ -1,25 +1,54 @@
'use strict';
'use strict';
-/** A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair. */
+/**
+ * A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair.
+ *
+ * Also the Wave-1 service-principal surface Community uses in 'community' authority mode:
+ * - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)
+ * mint service tokens with the requested audience and scope as capabilities;
+ * - POST /internal/identity/resolve-batch answers from `directory` and insists on a service
+ * token for openvibe.network holding identity.subject.resolve.
+ * signService() mints tokens as another service (e.g. Live) would present them to Community.
+ */
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
function start() {
function start() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
+ const resolveCalls = [];
let issuer = 'http://network.test';
let issuer = 'http://network.test';
+
+ // Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
+ // legacy ids other services registered: legacy[system][id] = subject_id.
+ const directory = { users: [], legacy: { live: {} }, down: false };
+ function addUser(u) {
+ const user = { subject_id: ids.newId('user'), display_name: u.username, avatar_url: null, ...u };
+ directory.users.push(user);
+ return user;
+ }
+ const projection = (u) => (u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.network_user_id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, banned: false } : null);
+
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
- let body = {}; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }
+ let body = {};
+ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
+ else { try { body = JSON.parse(raw); } catch { /* */ } }
grants.push(body);
grants.push(body);
if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
+ if (body.grant_type === 'client_credentials') {
+ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
+ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
@@ -31,16 +60,44 @@ function start() {
@@ -31,16 +60,44 @@ function start() {
return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
}
}
if (req.url === '/oauth/revoke') return json(200, { ok: true });
if (req.url === '/oauth/revoke') return json(200, { ok: true });
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return json(401, { code: v.code, error: v.reason });
+ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });
+ let body = {}; try { body = JSON.parse(raw); } catch { /* */ }
+ resolveCalls.push(body);
+ if (directory.down) return json(503, { error: 'down' });
+ const results = {};
+ if (Array.isArray(body.subject_ids)) {
+ for (const s of body.subject_ids) results[s] = projection(directory.users.find((u) => u.subject_id === s));
+ } else {
+ for (const id of body.ids || []) {
+ const k = String(id);
+ if (body.system === 'network') results[k] = projection(directory.users.find((u) => String(u.network_user_id) === k));
+ else {
+ const sid = (directory.legacy[body.system] || {})[k];
+ results[k] = projection(sid ? directory.users.find((u) => u.subject_id === sid) : null);
+ }
+ }
+ }
+ return json(200, { results });
+ }
json(404, { error: 'not found' });
json(404, { error: 'not found' });
});
});
});
});
function sign(claims, opts = {}) {
function sign(claims, opts = {}) {
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
}
}
+ /** A client-credentials service token (identity.service-token-claims@1). */
+ function signService({ sub = 'svc:live', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, key);
+ }
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
const url = `http://127.0.0.1:${server.address().port}`;
issuer = url;
issuer = url;
- resolve({ url, grants, sign, publicPem, close: () => new Promise((r) => server.close(r)) });
+ resolve({ url, grants, resolveCalls, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
}));
}));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+'use strict';
+/**
+ * scripts/import-pastes.js + server/pastes/importer.js: bundle verification, owner mapping across
+ * the id-space cutoff (ambiguous / unmapped → holds), AI pastes, fork and reply remapping, likes,
+ * idempotent re-runs, newer bundles, dry runs, and the CLI's exit codes.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { openDb } = require('../server/db');
+const { importBundle, ImportError } = require('../server/pastes/importer');
+const { createNetworkIdentity } = require('../server/identity/network');
+const mockNetwork = require('./helpers/mock-network');
+const { check, done } = require('./helpers/app');
+
+const PRE = '2026-08-01 10:00:00'; // before the id-space fix (2026-08-20T03:00:26Z)
+const POST = '2026-09-01 10:00:00';
+
+function seal(bundle) {
+ const { pastes, comments, likes } = bundle;
+ return {
+ format: 'openvibe.media.pastes-export', version: 1, app: 'live', generated_at: '2026-09-22T12:00:00Z',
+ since_id: 0, max_id: Math.max(0, ...pastes.map((p) => p.id)),
+ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },
+ sha256: crypto.createHash('sha256').update(JSON.stringify({ pastes, comments, likes })).digest('hex'),
+ pastes, comments, likes,
+ };
+}
+
+const paste = (o) => ({
+ id: 1, slug: 'amber-fox-11', user_id: null, type: 'paste', title: 'T', content: 'c', language: 'text', visibility: 'public',
+ stream_id: null, screenshot_url: null, metadata: null, burn_after_read: 0, forked_from: null, pinned: 0, views: 0, unique_views: 0,
+ copies: 0, likes: 0, is_nsfw: 0, ai_summary: null, ai_tags: null, ai_analyzed_at: null, created_at: POST, updated_at: POST, ...o,
+});
+
+(async () => {
+ const net = await mockNetwork.start();
+ // A: network 7 = live 107. B: network 9 = live 109. C: live 9 (so "9" is ambiguous before the cutoff).
+ const A = net.addUser({ network_user_id: 7, username: 'alex' });
+ const B = net.addUser({ network_user_id: 9, username: 'sam' });
+ const C = net.addUser({ network_user_id: 30, username: 'cleo' });
+ net.directory.legacy.live = { 107: A.subject_id, 109: B.subject_id, 9: C.subject_id };
+ const config = { networkInternalUrl: net.url, networkUrl: net.url, oauth: { clientId: 'community', clientSecret: 'shh' } };
+
+ const db = openDb(':memory:');
+ const identity = createNetworkIdentity({ config, db });
+ const run = (bundle, opts = {}) => importBundle(db, bundle, { resolveLegacy: identity.resolveLegacy, source: 'test', ...opts });
+ const count = (table) => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c;
+ const bySlug = (slug) => db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);
+ const holds = () => db.prepare('SELECT source_type, source_id, reason FROM import_hold ORDER BY source_type, source_id, reason').all();
+
+ const pastes = [
+ paste({ id: 6, slug: 'fork-of-one-66', user_id: 107, title: 'Fork of One', forked_from: 1 }), // fork listed before its original
+ paste({ id: 1, slug: 'amber-fox-11', user_id: 107, title: 'One', content: 'hello', language: 'javascript', views: 50, unique_views: 40, copies: 3, likes: 2, visibility: 'public', is_nsfw: 1, ai_summary: 'greets', ai_tags: ['hi'], ai_analyzed_at: '2026-09-02 00:00:00', created_at: POST, updated_at: '2026-09-03 00:00:00' }),
+ paste({ id: 2, slug: 'blue-lake-22', user_id: 7, created_at: PRE, updated_at: PRE, visibility: 'unlisted' }), // pre-cutoff, only the network mapping exists → A
+ paste({ id: 3, slug: 'cold-ridge-33', user_id: 9, created_at: PRE, updated_at: PRE }), // pre-cutoff, live 9 = C, network 9 = B → ambiguous
+ paste({ id: 4, slug: 'dark-owl-44', user_id: 555 }), // post-cutoff, unmapped
+ paste({ id: 5, slug: 'wild-moment-5555', user_id: 109, type: 'screenshot', content: '', screenshot_url: 'https://openvibe.media/p/wild-moment-5555/screenshot', metadata: { ai_moment: true, stream_id: 77 }, ai_summary: 'hype' }),
+ paste({ id: 7, slug: 'epic-frame-7777', user_id: 109, type: 'screenshot', screenshot_url: 'https://openvibe.media/p/epic-frame-7777/screenshot', stream_id: 12, metadata: { original_name: 'ai-moment-vod31-340.jpg', mime_type: 'image/jpeg' } }),
+ paste({ id: 8, slug: 'gold-gem-88', user_id: 777, created_at: PRE, updated_at: PRE, visibility: 'private' }), // pre-cutoff, no mapping at all
+ paste({ id: 9, slug: 'gray-dove-99', user_id: null, title: 'anon' }),
+ ];
+ const comments = [
+ { id: 11, paste_id: 1, user_id: 107, parent_id: null, anon_name: null, message: 'nice', is_deleted: 0, created_at: POST, updated_at: POST },
+ { id: 12, paste_id: 1, user_id: null, parent_id: 11, anon_name: 'Visitor', message: 'agreed', is_deleted: 0, created_at: POST, updated_at: POST },
+ { id: 13, paste_id: 1, user_id: 555, parent_id: null, anon_name: null, message: 'who am i', is_deleted: 0, created_at: POST, updated_at: POST },
+ { id: 14, paste_id: 99, user_id: 107, parent_id: null, anon_name: null, message: 'orphan', is_deleted: 0, created_at: POST, updated_at: POST },
+ ];
+ const likes = [
+ { paste_id: 1, user_id: 107, created_at: POST },
+ { paste_id: 1, user_id: 555, created_at: POST },
+ { paste_id: 1, user_id: 9, created_at: PRE },
+ ];
+ const bundle = seal({ pastes, comments, likes });
+
+ await check('refuses a bundle whose format, version, counts or sha256 do not check out', async () => {
+ for (const [mutate, re] of [
+ [(b) => { b.sha256 = '0'.repeat(64); }, /sha256 mismatch/],
+ [(b) => { b.counts.pastes += 1; }, /count mismatch for pastes/],
+ [(b) => { b.format = 'something-else'; }, /unexpected format/],
+ [(b) => { b.version = 2; }, /unsupported version/],
+ [(b) => { b.pastes[0].title = 'tampered'; }, /sha256 mismatch/],
+ ]) {
+ const b = JSON.parse(JSON.stringify(bundle));
+ mutate(b);
+ await assert.rejects(run(b), (err) => err instanceof ImportError && re.test(err.message));
+ }
+ assert.strictEqual(count('pastes'), 0);
+ });
+
+ await check('--dry-run reports everything and leaves the database untouched', async () => {
+ const r = await run(bundle, { dryRun: true });
+ assert.strictEqual(r.dry_run, true);
+ assert.strictEqual(r.pastes.inserted, 9);
+ for (const t of ['pastes', 'paste_comments', 'paste_likes', 'import_hold', 'legacy_id_map', 'migration_runs']) assert.strictEqual(count(t), 0, t);
+ });
+
+ let first;
+ await check('import: counters, timestamps, visibility, NSFW and AI fields kept; owners mapped across the cutoff', async () => {
+ first = await run(bundle);
+ assert.deepStrictEqual(first.pastes, { bundle: 9, inserted: 9, updated: 0, unchanged: 0, held: 3, skipped: 0, ai: 2, forks_remapped: 1, forks_unresolved: 0 });
+ const one = bySlug('amber-fox-11');
+ assert.strictEqual(one.owner_subject, A.subject_id);
+ assert.strictEqual(one.legacy_media_id, 1);
+ assert.strictEqual(one.legacy_user_id, 107);
+ assert.strictEqual(one.views, 50);
+ assert.strictEqual(one.unique_views, 40);
+ assert.strictEqual(one.copies, 3);
+ assert.strictEqual(one.likes, 2);
+ assert.strictEqual(one.is_nsfw, 1);
+ assert.strictEqual(one.ai_tags, '["hi"]');
+ assert.strictEqual(one.created_at, POST);
+ assert.strictEqual(one.updated_at, '2026-09-03 00:00:00');
+ assert.strictEqual(one.origin, 'user');
+ assert.strictEqual(bySlug('blue-lake-22').owner_subject, A.subject_id, 'pre-cutoff network id');
+ assert.strictEqual(bySlug('blue-lake-22').visibility, 'unlisted');
+ assert.strictEqual(bySlug('cold-ridge-33').owner_subject, null, 'ambiguous');
+ assert.strictEqual(bySlug('cold-ridge-33').legacy_user_id, 9);
+ assert.strictEqual(bySlug('dark-owl-44').owner_subject, null, 'unmapped');
+ assert.strictEqual(bySlug('gold-gem-88').owner_subject, null);
+ assert.strictEqual(bySlug('gray-dove-99').owner_subject, null, 'anonymous is not a hold');
+ assert.strictEqual(bySlug('fork-of-one-66').forked_from, one.id, 'forked_from remapped to the Community id');
+ });
+
+ await check('AI moment pastes: origin ai, no owner, stream in stream_ref, screenshot link kept, no hold', async () => {
+ const ai = bySlug('wild-moment-5555');
+ assert.strictEqual(ai.origin, 'ai');
+ assert.strictEqual(ai.owner_subject, null);
+ assert.strictEqual(ai.legacy_user_id, 109);
+ assert.deepStrictEqual(JSON.parse(ai.stream_ref), { service: 'live', type: 'stream', id: '77' });
+ assert.strictEqual(ai.screenshot_url, 'https://openvibe.media/p/wild-moment-5555/screenshot');
+ assert.strictEqual(ai.media_ref, 'legacy:live:paste:5');
+ const byName = bySlug('epic-frame-7777');
+ assert.strictEqual(byName.origin, 'ai', 'recognised by the AI frame file name Media kept');
+ assert.deepStrictEqual(JSON.parse(byName.stream_ref), { service: 'live', type: 'stream', id: '12' });
+ assert.ok(!holds().some((h) => h.source_id === '5' || h.source_id === '7'));
+ });
+
+ await check('holds: ambiguous / unmapped owners, unmappable likes and orphan comments', async () => {
+ assert.deepStrictEqual(holds(), [
+ { source_type: 'comment', source_id: '13', reason: 'owner_unmapped' },
+ { source_type: 'comment', source_id: '14', reason: 'paste_missing' },
+ { source_type: 'like', source_id: '1:555', reason: 'owner_unmapped' },
+ { source_type: 'like', source_id: '1:9', reason: 'ambiguous_owner' },
+ { source_type: 'paste', source_id: '3', reason: 'ambiguous_owner' },
+ { source_type: 'paste', source_id: '4', reason: 'owner_unmapped' },
+ { source_type: 'paste', source_id: '8', reason: 'owner_unmapped' },
+ ]);
+ const detail = JSON.parse(db.prepare("SELECT detail FROM import_hold WHERE source_type = 'paste' AND source_id = '3'").get().detail);
+ assert.strictEqual(detail.live_subject, C.subject_id);
+ assert.strictEqual(detail.network_subject, B.subject_id);
+ assert.deepStrictEqual(first.holds, { ambiguous_owner: 2, owner_unmapped: 4, paste_missing: 1 });
+ });
+
+ await check('comments and likes: authors mapped, reply parents remapped, only mappable likes imported', async () => {
+ const cs = db.prepare('SELECT * FROM paste_comments ORDER BY legacy_media_id').all();
+ assert.strictEqual(cs.length, 3);
+ const [c11, c12, c13] = cs;
+ assert.strictEqual(c11.author_subject, A.subject_id);
+ assert.strictEqual(c12.anon_name, 'Visitor');
+ assert.strictEqual(c12.parent_id, c11.id);
+ assert.strictEqual(c13.author_subject, null);
+ assert.deepStrictEqual(first.comments, { bundle: 4, inserted: 3, updated: 0, unchanged: 0, held: 1, skipped: 1 });
+ const ls = db.prepare('SELECT subject_id FROM paste_likes').all();
+ assert.deepStrictEqual(ls, [{ subject_id: A.subject_id }]);
+ assert.deepStrictEqual(first.likes, { bundle: 3, inserted: 1, updated: 0, unchanged: 0, held: 2, skipped: 0 });
+ });
+
+ await check('mappings and the run are recorded', async () => {
+ const m = (sys, type, id) => db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(sys, type, id);
+ assert.deepStrictEqual(m('media', 'paste', '1'), { target_type: 'paste', target_id: String(bySlug('amber-fox-11').id) });
+ assert.deepStrictEqual(m('live', 'user', '107'), { target_type: 'subject', target_id: A.subject_id });
+ assert.deepStrictEqual(m('network', 'user', '7'), { target_type: 'subject', target_id: A.subject_id });
+ assert.ok(m('media', 'comment', '12'));
+ const runs = db.prepare('SELECT * FROM migration_runs').all();
+ assert.strictEqual(runs.length, 1);
+ assert.strictEqual(JSON.parse(runs[0].report).pastes.inserted, 9);
+ assert.ok(runs[0].finished_at);
+ });
+
+ await check('re-running the same bundle changes nothing', async () => {
+ const before = { pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') };
+ const again = await run(bundle);
+ assert.deepStrictEqual([again.pastes.inserted, again.pastes.updated, again.pastes.unchanged], [0, 0, 9]);
+ assert.deepStrictEqual([again.comments.inserted, again.comments.updated, again.comments.unchanged], [0, 0, 3]);
+ assert.deepStrictEqual([again.likes.inserted, again.likes.unchanged, again.likes.held], [0, 1, 2]);
+ assert.deepStrictEqual({ pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') }, before);
+ assert.strictEqual(count('migration_runs'), 2);
+ });
+
+ await check('a newer bundle adds and updates; Community-side edits, counts and deletes are not undone; late mappings clear holds', async () => {
+ // Community-side activity since the first import.
+ db.prepare("UPDATE pastes SET views = 80 WHERE slug = 'amber-fox-11'").run();
+ const store = require('../server/pastes/store');
+ store.updatePaste(db, bySlug('blue-lake-22').id, { content: 'edited in Community' }, A.subject_id);
+ store.softDelete(db, bySlug('gray-dove-99').id);
+ // Someone registered live user 555 with the Network in the meantime.
+ const D = net.addUser({ network_user_id: 40, username: 'dana' });
+ net.directory.legacy.live[555] = D.subject_id;
+
+ const newer = seal({
+ pastes: pastes.map((p) => (p.id === 1 ? { ...p, title: 'One (renamed on Live)', views: 60, updated_at: '2026-09-10 00:00:00' }
+ : p.id === 2 ? { ...p, content: 'changed on Live' } : p))
+ .concat([paste({ id: 10, slug: 'new-wave-10', user_id: 109, created_at: '2026-09-20 00:00:00', updated_at: '2026-09-20 00:00:00' })]),
+ comments: comments.concat([{ id: 15, paste_id: 10, user_id: 109, parent_id: null, anon_name: null, message: 'fresh', is_deleted: 0, created_at: POST, updated_at: POST }]),
+ likes,
+ });
+ const r = await run(newer);
+ assert.strictEqual(r.pastes.inserted, 1);
+ assert.strictEqual(r.comments.inserted, 1);
+ const one = bySlug('amber-fox-11');
+ assert.strictEqual(one.title, 'One (renamed on Live)');
+ assert.strictEqual(one.views, 80, 'counters never go down');
+ assert.strictEqual(bySlug('blue-lake-22').content, 'edited in Community', 'local edits win');
+ assert.ok(bySlug('gray-dove-99').deleted_at, 'deleted stays deleted');
+ assert.strictEqual(bySlug('new-wave-10').owner_subject, B.subject_id);
+ assert.strictEqual(bySlug('dark-owl-44').owner_subject, D.subject_id, 'late mapping fills the owner');
+ assert.ok(!holds().some((h) => h.source_type === 'paste' && h.source_id === '4'), 'and clears its hold');
+ assert.ok(!holds().some((h) => h.source_type === 'like' && h.source_id === '1:555'));
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM paste_likes').get().c, 2);
+ assert.strictEqual(db.prepare("SELECT author_subject FROM paste_comments WHERE legacy_media_id = 13").get().author_subject, D.subject_id);
+ assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');
+ });
+
+ await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));
+ const dbPath = path.join(dir, 'c.db');
+ const good = path.join(dir, 'good.json');
+ const bad = path.join(dir, 'bad.json');
+ fs.writeFileSync(good, JSON.stringify(bundle));
+ fs.writeFileSync(bad, JSON.stringify({ ...bundle, sha256: 'f'.repeat(64) }));
+ const env = { ...process.env, COMMUNITY_DB_PATH: dbPath, OV_NETWORK_INTERNAL_URL: net.url, OV_NETWORK_URL: net.url, OV_OAUTH_CLIENT_ID: 'community', OV_OAUTH_CLIENT_SECRET: 'shh' };
+ const script = path.join(__dirname, '..', 'scripts', 'import-pastes.js');
+ const refused = spawnSync(process.execPath, [script, bad], { env, encoding: 'utf8' });
+ assert.strictEqual(refused.status, 2, refused.stderr);
+ assert.match(refused.stderr, /Refused: sha256 mismatch/);
+ // Run the child asynchronously: the mock Network lives in this process's event loop.
+ const { spawn } = require('child_process');
+ const out = await new Promise((resolve) => {
+ const child = spawn(process.execPath, [script, good, '--dry-run', '--id-fix-cutoff', '2026-08-20T03:00:26Z'], { env });
+ let stdout = '', stderr = '';
+ child.stdout.on('data', (c) => { stdout += c; });
+ child.stderr.on('data', (c) => { stderr += c; });
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
+ });
+ assert.strictEqual(out.code, 0, out.stderr);
+ assert.match(out.stdout, /DRY RUN — rolled back/);
+ // Live user 555 is mapped by now (previous check), so two pastes are held, not three.
+ assert.match(out.stdout, /pastes\s+9\s+9\s+0\s+0\s+2\s+0/, out.stdout);
+ const check2 = openDb(dbPath);
+ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM pastes').get().c, 0);
+ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM subject_projection').get().c, 0, 'not even the display cache');
+ check2.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ db.close();
+ await net.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,323 @@
@@ -0,0 +1,323 @@
+'use strict';
+/**
+ * Native /api/pastes (PASTES_AUTHORITY=community): who may call what.
+ * Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /
+ * X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via
+ * the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });
+ const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token
+ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
+ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
+ const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';
+
+ const call = (path, { method = 'GET', token, cookie, headers = {}, json, body, ip } = {}) => {
+ const h = { ...headers };
+ if (token) h.authorization = `Bearer ${token}`;
+ if (ip) h['x-forwarded-for'] = ip;
+ let b = body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; b = JSON.stringify(json); }
+ return t.get(path, { method, headers: h, body: b, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+
+ let alexSlug;
+
+ await check('browser create: owner from the JWT subject; body identity fields and X-OV-* headers ignored', async () => {
+ const r = await call('/api/pastes', {
+ method: 'POST', cookie: alexJwt, ip: '192.0.2.10',
+ headers: { 'x-ov-subject': sam.subject_id, 'x-ov-origin': 'ai' },
+ json: { title: 'Mine', content: 'print(1)', language: 'python', user_id: 9, owner_subject: sam.subject_id, author_id: 9, origin: 'ai', slug: 'my-chosen-slug' },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const out = r.json();
+ assert.ok(out.id && out.slug && out.url === `/p/${out.slug}` && out.paste);
+ assert.notStrictEqual(out.slug, 'my-chosen-slug', 'browsers never pick slugs');
+ assert.strictEqual(out.paste.owner_subject, alex.subject_id);
+ assert.strictEqual(out.paste.origin, 'user');
+ assert.strictEqual(out.paste.username, 'alex');
+ assert.strictEqual(out.paste.profile_color, '#0af', 'JWT claims feed the projection');
+ alexSlug = out.slug;
+ });
+
+ await check('an older JWT without subject_id is resolved through the Network once, then cached', async () => {
+ const before = net.resolveCalls.length;
+ const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().paste.owner_subject, sam.subject_id);
+ const lookups = net.resolveCalls.slice(before);
+ assert.deepStrictEqual(lookups[0], { system: 'network', type: 'user', ids: ['9'] });
+ await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'again' } });
+ assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 1, 'cached in legacy_id_map');
+ const grant = net.grants.find((g) => g.grant_type === 'client_credentials' && g.audience === 'openvibe.network');
+ assert.strictEqual(grant.scope, 'identity.subject.resolve');
+ });
+
+ await check('service token: create as the acting subject (X-OV-Subject)', async () => {
+ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'via live', user_id: 9 } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().paste.owner_subject, alex.subject_id);
+ });
+
+ await check('service token denials: wrong audience, bad signature, expired, missing capability, bad X-OV-Subject', async () => {
+ const wrongAud = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { aud: ['openvibe.live'] }), json: { content: 'x' } });
+ assert.strictEqual(wrongAud.status, 401);
+ assert.strictEqual(wrongAud.json().code, 'token.wrong_audience');
+ assert.match(wrongAud.headers.get('content-type'), /application\/problem\+json/);
+ const otherKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const forged = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { key: otherKey }), json: { content: 'x' } });
+ assert.strictEqual(forged.status, 401);
+ assert.strictEqual(forged.json().code, 'token.bad_signature');
+ const expired = await call('/api/pastes', { token: svc([CREATE], { expSec: -120 }) });
+ assert.strictEqual(expired.json().code, 'token.expired');
+ const noCap = await call('/api/pastes', { method: 'POST', token: svc([WRITE]), json: { content: 'x' } });
+ assert.strictEqual(noCap.status, 403);
+ assert.strictEqual(noCap.json().code, 'capability.denied');
+ const noPasteCaps = await call('/api/pastes', { token: svc(['chat.message.send']) });
+ assert.strictEqual(noPasteCaps.status, 403, 'a token for something else is not a pass to read as someone');
+ const badSubject = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': '7' }, json: { content: 'x' } });
+ assert.strictEqual(badSubject.status, 400);
+ assert.strictEqual(badSubject.json().code, 'subject.invalid');
+ const badRef = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-source-ref': '{"service":"live"}' }, json: { content: 'x' } });
+ assert.strictEqual(badRef.status, 400);
+ });
+
+ await check('service without X-OV-Subject writes anonymously; X-OV-Origin: ai → origin ai, no owner, stream ref kept', async () => {
+ const anon = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'nobody' } });
+ assert.strictEqual(anon.json().paste.owner_subject, null);
+ assert.strictEqual(anon.json().paste.origin, 'user');
+ const ref = { service: 'live', type: 'stream', id: '42' };
+ const ai = await call('/api/pastes', {
+ method: 'POST', token: svc([CREATE]),
+ headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id, 'x-ov-source-ref': JSON.stringify(ref) },
+ json: { title: 'A wild moment', content: 'the chat exploded', slug: 'wild-moment-4242', metadata: { ai_moment: true, stream_id: 42 }, ai_summary: 'chat exploded', ai_tags: ['hype'] },
+ });
+ assert.strictEqual(ai.status, 201, ai.text);
+ const p = ai.json().paste;
+ assert.strictEqual(p.slug, 'wild-moment-4242', 'services may bring their own slug');
+ assert.strictEqual(p.origin, 'ai');
+ assert.strictEqual(p.owner_subject, null, 'AI output is never attributed to a person');
+ assert.strictEqual(p.user_id, null);
+ assert.strictEqual(p.display_name, 'OpenVibe AI');
+ assert.deepStrictEqual(p.stream_ref, ref);
+ assert.strictEqual(p.stream_id, 42);
+ assert.deepStrictEqual(JSON.parse(p.metadata), { ai_moment: true, stream_id: 42 });
+ assert.strictEqual(p.ai_summary, 'chat exploded');
+ const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });
+ assert.strictEqual(dup.status, 409);
+ const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });
+ assert.strictEqual(bad.status, 400);
+ const onlyAi = (await call('/api/pastes?origin=ai')).json();
+ assert.ok(onlyAi.pastes.length >= 1 && onlyAi.pastes.every((x) => x.origin === 'ai'));
+ const browserMeta = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'meta?', metadata: { ai_moment: true } } });
+ assert.strictEqual(browserMeta.json().paste.metadata, null, 'browsers cannot set metadata');
+ });
+
+ await check('write capability: edits as the acting subject are owner-checked by Community', async () => {
+ const asAlex = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Edited via Live' } });
+ assert.strictEqual(asAlex.status, 200, asAlex.text);
+ assert.strictEqual(asAlex.json().paste.title, 'Edited via Live');
+ const asSam = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'hijack' } });
+ assert.strictEqual(asSam.status, 403);
+ assert.deepStrictEqual(asSam.json(), { error: 'Not authorized for this paste' });
+ const noSubject = await call(`/api/pastes/${alexSlug}`, { method: 'DELETE', token: svc([WRITE]) });
+ assert.strictEqual(noSubject.status, 401);
+ const createOnly = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'x' } });
+ assert.strictEqual(createOnly.status, 403);
+ assert.strictEqual(createOnly.json().code, 'capability.denied');
+ });
+
+ await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {
+ const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samOldJwt, json: { message: 'spam spam' } })).json().comment;
+ assert.strictEqual(c.username, 'sam');
+ const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
+ assert.strictEqual(notStaff.status, 403);
+ const fakeStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-staff': '1' } });
+ assert.strictEqual(fakeStaff.status, 403);
+ assert.strictEqual(fakeStaff.json().code, 'capability.denied');
+ const staff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE, MOD]), headers: { 'x-ov-staff': '1', 'x-ov-subject': kim.subject_id } });
+ assert.strictEqual(staff.status, 200, staff.text);
+ assert.deepStrictEqual(staff.json(), { message: 'Comment deleted' });
+ const bulkUser = await call('/api/pastes/bulk', { method: 'POST', cookie: alexJwt, json: { slugs: [alexSlug], action: 'private' } });
+ assert.strictEqual(bulkUser.status, 403);
+ const bulkAdmin = await call('/api/pastes/admin/stats', { cookie: adminJwt });
+ assert.strictEqual(bulkAdmin.status, 200);
+ assert.ok(bulkAdmin.json().stats.total > 0);
+ const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });
+ assert.strictEqual(bulkSvc.status, 403);
+ });
+
+ await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {
+ const hidden = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'hidden work', visibility: 'private' } })).json().slug;
+ const q = await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) });
+ assert.strictEqual(q.status, 200, q.text);
+ const queue = q.json().pastes.map((p) => p.slug);
+ assert.ok(queue.includes(hidden), 'the queue spans visibility');
+ assert.ok(!queue.includes('wild-moment-4242'), 'already annotated');
+ for (const who of [{ cookie: adminJwt }, {}, { token: svc([WRITE, CREATE]) }]) {
+ const denied = await call('/api/pastes?needs_ai=1', who);
+ assert.strictEqual(denied.status, 403, `needs_ai refused for ${JSON.stringify(Object.keys(who))}`);
+ const write = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', json: { ai_summary: 'x' }, ...who });
+ assert.strictEqual(write.status, 403);
+ }
+ const w = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', token: svc([MOD]), json: { ai_summary: 'a secret note', ai_tags: ['notes', 'private'] } });
+ assert.strictEqual(w.status, 200, w.text);
+ assert.strictEqual(w.json().ok, true);
+ assert.strictEqual(w.json().paste.ai_summary, 'a secret note');
+ assert.strictEqual(w.json().paste.ai_tags, '["notes","private"]');
+ assert.ok(w.json().paste.ai_analyzed_at);
+ const again = (await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) })).json().pastes.map((p) => p.slug);
+ assert.ok(!again.includes(hidden));
+ });
+
+ await check('author display: unknown subjects are resolved through resolve-batch; stale ones refresh', async () => {
+ const before = net.resolveCalls.length;
+ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': kim.subject_id }, json: { content: 'kim was here' } });
+ const p = r.json().paste;
+ assert.strictEqual(p.username, 'kim');
+ assert.strictEqual(p.display_name, 'Kim');
+ assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');
+ assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));
+ kim.display_name = 'Kim Renamed';
+ t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);
+ const stale = await call(`/api/pastes/${p.slug}?no_view=1`);
+ assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');
+ await new Promise((res) => setTimeout(res, 100));
+ const fresh = await call(`/api/pastes/${p.slug}?no_view=1`);
+ assert.strictEqual(fresh.json().paste.display_name, 'Kim Renamed');
+ net.directory.down = true;
+ const ghost = ids.newId('user');
+ const down = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': ghost }, json: { content: 'network down' } });
+ assert.strictEqual(down.status, 201, 'a Network outage costs names, not the write');
+ assert.strictEqual(down.json().paste.username, null);
+ net.directory.down = false;
+ });
+
+ await check('anonymous writes: 20 per 10 minutes per address; signed-in and other addresses unaffected', async () => {
+ const ip = '203.0.113.77';
+ for (let i = 0; i < 20; i++) {
+ const r = await call('/api/pastes', { method: 'POST', ip, json: { content: `anon ${i}` } });
+ assert.strictEqual(r.status, 201, `#${i}: ${r.text}`);
+ }
+ const over = await call('/api/pastes', { method: 'POST', ip, json: { content: 'one too many' } });
+ assert.strictEqual(over.status, 429);
+ assert.deepStrictEqual(over.json(), { error: 'Too many anonymous posts — sign in or try again later' });
+ const comment = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip, json: { message: 'also limited' } });
+ assert.strictEqual(comment.status, 429, 'comments share the budget');
+ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, cookie: alexJwt, json: { content: 'signed in' } })).status, 201);
+ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip: '203.0.113.78', json: { content: 'elsewhere' } })).status, 201);
+ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, token: svc([CREATE]), json: { content: 'service' } })).status, 201);
+ });
+
+ // A JPEG whose APP1 segment carries EXIF (with a fake GPS marker).
+ const jpeg = Buffer.concat([
+ Buffer.from([0xff, 0xd8]),
+ Buffer.from([0xff, 0xe0, 0x00, 0x10]), Buffer.from('JFIF\0\x01\x01\0\0\x01\0\x01\0\0', 'latin1'),
+ Buffer.from([0xff, 0xe1, 0x00, 0x17]), Buffer.from('Exif\0\0GPS-51.5N-0.12W', 'latin1'),
+ Buffer.from([0xff, 0xdb, 0x00, 0x04, 0x00, 0x00]),
+ Buffer.from([0xff, 0xda, 0x00, 0x04, 0x00, 0x00]), Buffer.from('pixels', 'latin1'), Buffer.from([0xff, 0xd9]),
+ ]);
+ let shotSlug;
+
+ await check('screenshot upload: metadata stripped, bytes stored in Media with a service token, URL + media_ref kept', async () => {
+ const fd = new FormData();
+ fd.append('title', 'My desk');
+ fd.append('description', 'look');
+ fd.append('page_url', 'https://example.com/x');
+ fd.append('user_id', '9');
+ fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'desk.jpg');
+ const r = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: fd });
+ assert.strictEqual(r.status, 201, r.text);
+ const out = r.json();
+ const up = t.media.uploads[t.media.uploads.length - 1];
+ assert.ok(!up.bytes.includes(Buffer.from('GPS')), 'EXIF never leaves Community');
+ assert.ok(up.bytes.includes(Buffer.from('pixels')) && up.bytes.includes(Buffer.from('JFIF')));
+ assert.strictEqual(up.mime, 'image/jpeg');
+ assert.ok(up.claims.cap.includes('media.object.upload'));
+ const p = out.paste;
+ assert.strictEqual(out.slug, p.slug);
+ assert.strictEqual(p.type, 'screenshot');
+ assert.strictEqual(p.owner_subject, alex.subject_id);
+ assert.strictEqual(p.screenshot_url, `https://openvibe.media/f/${up.key}`);
+ assert.strictEqual(p.title, 'My desk');
+ assert.strictEqual(p.content, 'look');
+ const meta = JSON.parse(p.metadata);
+ assert.strictEqual(meta.page_url, 'https://example.com/x');
+ assert.strictEqual(meta.original_name, 'desk.jpg');
+ const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
+ assert.strictEqual(row.media_ref, `legacy:community:file:${up.key}`);
+ shotSlug = p.slug;
+ const viaCreate = new FormData();
+ viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
+ const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });
+ assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');
+ assert.strictEqual(r2.json().paste.title, 'Screenshot');
+ const bad = new FormData();
+ bad.append('screenshot', new Blob(['hello'], { type: 'text/plain' }), 'a.txt');
+ assert.strictEqual((await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: bad })).status, 400);
+ t.media.failNext();
+ const again = new FormData();
+ again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');
+ const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });
+ assert.strictEqual(failed.status, 502);
+ assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
+ });
+
+ await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot → stored URL', async () => {
+ const raw = await call(`/p/${alexSlug}/raw`);
+ assert.strictEqual(raw.status, 200);
+ assert.strictEqual(raw.headers.get('content-type'), 'text/plain; charset=utf-8');
+ assert.strictEqual(raw.headers.get('x-content-type-options'), 'nosniff');
+ assert.strictEqual(raw.text, 'print(1)');
+ const priv = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'eyes only', visibility: 'private' } })).json().slug;
+ assert.strictEqual((await call(`/p/${priv}/raw`)).status, 404);
+ assert.strictEqual((await call(`/p/${priv}/raw`, { cookie: alexJwt })).text, 'eyes only');
+ const api = await call(`/api/pastes/${alexSlug}/raw`);
+ assert.strictEqual(api.status, 302);
+ assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
+ const shot = await call(`/p/${shotSlug}/screenshot`);
+ assert.strictEqual(shot.status, 302);
+ assert.match(shot.headers.get('location'), /^https:\/\/openvibe\.media\/f\//);
+ assert.strictEqual((await call(`/p/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);
+ assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);
+ const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;
+ assert.strictEqual((await call(`/p/${burn}/raw`)).text, 'once');
+ assert.strictEqual((await call(`/p/${burn}/raw`)).status, 410);
+ });
+
+ await check('likes need a person; comments allow anonymous names; set-avatar is the Network\'s; unknown routes 404', async () => {
+ const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });
+ assert.strictEqual(like.status, 401);
+ assert.deepStrictEqual(like.json(), { error: 'Authentication required' });
+ const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samOldJwt });
+ assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });
+ const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
+ assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });
+ const c = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip: '198.51.100.40', json: { message: 'nice', anon_name: 'Visitor' } });
+ assert.strictEqual(c.status, 201, c.text);
+ assert.strictEqual(c.json().comment.anon_name, 'Visitor');
+ const list = (await call(`/api/pastes/${alexSlug}/comments`)).json();
+ assert.ok(list.comments.some((x) => x.message === 'nice' && x.user_id === null));
+ assert.strictEqual((await call(`/api/pastes/${alexSlug}/comments/${c.json().comment.id}`, { method: 'DELETE' })).status, 401);
+ assert.strictEqual((await call(`/api/pastes/${alexSlug}/set-avatar`, { method: 'POST', cookie: alexJwt })).status, 501);
+ assert.strictEqual((await call(`/api/pastes/${alexSlug}/nope/deeper`)).status, 404);
+ const cfg = (await call('/api/pastes/config', { cookie: alexJwt })).json();
+ assert.strictEqual(cfg.maxSizeKb, 512);
+ assert.ok(cfg.todayCount > 0);
+ const malformed = await call('/api/pastes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' });
+ assert.strictEqual(malformed.status, 400);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+/**
+ * Server-rendered pages with PASTES_AUTHORITY=community: everything reads from Community's own
+ * store (never from OpenVibe.Live), with the same visibility rules as the API.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 } });
+ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
+ const svc = t.app.locals.pastes;
+ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });
+ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
+ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
+ const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
+
+ const js = (await svc.createText(asAlex, { title: 'Hello world in JavaScript', content: 'const greet = (name) => `Hi ${name}`;\n', language: 'javascript' })).slug;
+ const nginx = (await svc.createText(anon, { title: 'nginx snippet', content: 'server {\n listen 80;\n}\n', language: 'nginx' })).slug;
+ const py = (await svc.createText(asAlex, { title: 'python helper', content: 'def add(a, b):\n return a + b\n', language: 'python' })).slug;
+ const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;
+ const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;
+ const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;
+ t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);
+ t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);
+
+ await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ has(r.text, `href="/p/${js}"`);
+ const trendingAt = r.text.indexOf('id="trending"');
+ assert.ok(r.text.indexOf(nginx, trendingAt) < r.text.indexOf(js, trendingAt), 'most viewed first');
+ assert.ok(!r.text.includes(unl) && !r.text.includes(priv));
+ assert.strictEqual(t.live.calls.filter((c) => c.path.startsWith('/api/pastes')).length, 0);
+ });
+
+ await check('paste page: rendered from the store, view counted, author from the projection', async () => {
+ const r = await t.get(`/p/${js}`, { headers: { 'x-forwarded-for': '198.51.100.20' } });
+ assert.strictEqual(r.status, 200);
+ has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');
+ has(r.text, 'class="hljs-keyword">const</span>');
+ const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
+ assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');
+ has(r.text, 'https://openvibe.media/avatars/alex.png');
+ assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js).views, 1);
+ assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');
+ });
+
+ await check('unlisted: noindex and escaped; private: 404 for visitors, the owner gets the page and delete', async () => {
+ const u = await t.get(`/p/${unl}`);
+ has(u.text, '<meta name="robots" content="noindex,follow">');
+ has(u.text, '<script>alert(1)</script>');
+ assert.ok(!u.text.includes('<script>alert(1)</script>'));
+ assert.strictEqual((await t.get(`/p/${priv}`)).status, 404);
+ const owner = await t.get(`/p/${priv}`, { cookies: [`ov_token=${token}`] });
+ assert.strictEqual(owner.status, 200);
+ has(owner.text, `data-delete="${priv}"`);
+ // A service token is no identity on a page.
+ const svcTok = t.network.signService({ cap: ['community.paste.write', 'community.paste.moderate'] });
+ assert.strictEqual((await t.get(`/p/${priv}`, { headers: { authorization: `Bearer ${svcTok}`, 'x-ov-subject': alex.subject_id, 'x-ov-staff': '1' } })).status, 404);
+ });
+
+ await check('burn after read: the first visitor sees it, the next gets the 410 page', async () => {
+ assert.strictEqual((await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.30' } })).status, 200);
+ const gone = await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.31' } });
+ assert.strictEqual(gone.status, 410);
+ has(gone.text, 'This paste has burned');
+ });
+
+ await check('download reads the store without a view; screenshots use the stored Media URL', async () => {
+ const before = t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views;
+ const dl = await t.get(`/p/${py}/download`);
+ assert.strictEqual(dl.status, 200);
+ assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);
+ assert.ok(dl.text.startsWith('def add'));
+ assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views, before);
+ const shot = await t.get('/p/fair-moon-23');
+ has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');
+ const dls = await t.get('/p/fair-moon-23/download');
+ assert.strictEqual(dls.headers.get('location'), 'https://openvibe.media/f/abc-desk.png');
+ });
+
+ await check('browse: search, views sort and language filter over the store', async () => {
+ const q = await t.get('/pastes?q=nginx');
+ has(q.text, `href="/p/${nginx}"`);
+ assert.ok(!q.text.includes(`href="/p/${js}"`));
+ const views = await t.get('/pastes?sort=views');
+ const res = views.text.slice(views.text.indexOf('id="results"'));
+ assert.ok(res.indexOf(nginx) < res.indexOf(js));
+ const lang = await t.get('/pastes?lang=python');
+ has(lang.text, `href="/p/${py}"`);
+ assert.ok(!lang.text.includes(`href="/p/${js}"`));
+ });
+
+ await check('/my lists the signed-in person\'s pastes, private and unlisted included', async () => {
+ const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });
+ assert.strictEqual(mine.status, 200);
+ for (const slug of [js, py, unl, priv]) has(mine.text, `href="/p/${slug}"`);
+ assert.ok(!mine.text.includes(nginx));
+ });
+
+ await check('no-JS form creates in the store as the signed-in subject; anonymous posts share the 20/10min budget', async () => {
+ const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'private' }).toString();
+ const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
+ assert.strictEqual(post.status, 303);
+ const slug = post.headers.get('location').replace('/p/', '');
+ const row = t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);
+ assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });
+ const anonBody = new URLSearchParams({ content: 'hi' }).toString();
+ const ip = '203.0.113.200';
+ for (let i = 0; i < 20; i++) {
+ const r = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });
+ assert.strictEqual(r.status, 303, `#${i}`);
+ }
+ const over = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });
+ assert.strictEqual(over.status, 429);
+ has(over.text, 'Too many anonymous posts');
+ const api = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ content: 'x' }), headers: { 'content-type': 'application/json', 'x-forwarded-for': ip } });
+ assert.strictEqual(api.status, 429, 'the API shares the budget');
+ });
+
+ await check('feed and sitemap list store pastes', async () => {
+ const feed = await t.get('/feed.xml');
+ has(feed.text, `/p/${js}`);
+ assert.ok(!feed.text.includes(priv));
+ const map = await t.get('/sitemap.xml');
+ has(map.text, `/p/${js}`);
+ assert.ok(!map.text.includes(unl) && !map.text.includes(priv));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+'use strict';
+/**
+ * Paste store + service semantics on an in-memory database (no HTTP): create/get/list,
+ * visibility, burn-after-read, forks, likes, copies, comments and replies, deletes, versions,
+ * limits and view counting.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { openDb } = require('../server/db');
+const store = require('../server/pastes/store');
+const { createPasteService, PasteError } = require('../server/pastes/service');
+const { check, done } = require('./helpers/app');
+
+const db = openDb(':memory:');
+const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
+
+const alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+const bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+const mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };
+const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
+store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });
+
+async function rejects(p, status, message) {
+ try { await p; } catch (err) {
+ assert.ok(err instanceof PasteError, `expected PasteError, got ${err && err.stack}`);
+ assert.strictEqual(err.status, status, `status: ${JSON.stringify(err.body)}`);
+ if (message) assert.strictEqual(err.body.error, message);
+ return err;
+ }
+ throw new Error(`expected ${status}`);
+}
+
+(async () => {
+ let pub, unl, priv;
+
+ await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {
+ const out = await svc.createText(alice, { title: ' Hello ', content: 'const x = 1;\n ', language: 'auto', visibility: 'public' });
+ assert.match(out.slug, /^[a-z]+-[a-z]+-\d{2}$/);
+ assert.deepStrictEqual(Object.keys(out), ['id', 'slug', 'url', 'paste']);
+ assert.strictEqual(out.url, `/p/${out.slug}`);
+ const p = out.paste;
+ assert.strictEqual(p.title, 'Hello');
+ assert.strictEqual(p.content, 'const x = 1;');
+ assert.strictEqual(p.language, 'javascript');
+ assert.strictEqual(p.owner_subject, alice.subject);
+ assert.strictEqual(p.user_id, alice.subject, 'user_id carries the subject id');
+ assert.strictEqual(p.origin, 'user');
+ assert.strictEqual(p.username, 'alice');
+ assert.strictEqual(p.display_name, 'Alice');
+ assert.strictEqual(p.avatar_url, 'https://openvibe.media/a.png');
+ assert.strictEqual(p.profile_color, '#f0a');
+ assert.strictEqual(p.raw_url, `/p/${out.slug}/raw`);
+ assert.strictEqual(p.is_owner, true);
+ for (const k of ['views', 'copies', 'likes', 'unique_views']) assert.strictEqual(p[k], 0);
+ pub = out.slug;
+ unl = (await svc.createText(alice, { title: 'unl', content: 'u', visibility: 'unlisted' })).slug;
+ priv = (await svc.createText(alice, { title: 'priv', content: 'p', visibility: 'private' })).slug;
+ });
+
+ await check('create: validation errors match Media', async () => {
+ await rejects(svc.createText(alice, { content: ' ' }), 400, 'Content is required');
+ await rejects(svc.createText(alice, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Paste too large (max 512 KB)');
+ const untitled = await svc.createText(alice, { content: 'y' });
+ assert.strictEqual(untitled.paste.title, 'Untitled');
+ // An anonymous private paste could never be opened: it becomes unlisted.
+ const anonPriv = await svc.createText(anon, { content: 'z', visibility: 'private' });
+ assert.strictEqual(anonPriv.paste.visibility, 'unlisted');
+ assert.strictEqual(anonPriv.paste.owner_subject, null);
+ assert.strictEqual(anonPriv.paste.user_id, null);
+ });
+
+ await check('visibility: public listed; unlisted by slug only; private owner/staff only (404 otherwise)', async () => {
+ const list = await svc.list(anon, { limit: 50 });
+ const slugs = list.pastes.map((p) => p.slug);
+ assert.ok(slugs.includes(pub));
+ assert.ok(!slugs.includes(unl) && !slugs.includes(priv));
+ assert.strictEqual((await svc.get(bob, unl, { noView: true })).paste.slug, unl);
+ await rejects(svc.get(bob, priv), 404, 'Paste not found');
+ await rejects(svc.get(anon, priv), 404, 'Paste not found');
+ assert.strictEqual((await svc.get(alice, priv)).paste.visibility, 'private');
+ assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');
+ // Private pastes can't be forked, liked, copied or commented by others either.
+ await rejects(svc.fork(bob, priv), 404);
+ await rejects(Promise.resolve().then(() => svc.like(bob, priv)), 404);
+ await rejects(Promise.resolve().then(() => svc.copy(anon, priv)), 404);
+ await rejects(svc.comments(anon, priv), 404);
+ });
+
+ await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {
+ const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });
+ store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });
+ const list = await svc.list(anon, { search: 'long one' });
+ assert.strictEqual(list.total, 1);
+ assert.strictEqual(list.pastes[0].content.length, 300);
+ assert.deepStrictEqual(Object.keys(list).sort(), ['limit', 'offset', 'pastes', 'total']);
+ const mine = await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 100 });
+ assert.ok(mine.pastes.some((p) => p.slug === priv), 'owner sees own private with include_unlisted');
+ const theirs = await svc.list(bob, { username: 'alice', include_unlisted: '1', limit: 100 });
+ assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');
+ assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
+ const wildcard = await svc.list(anon, { search: '%' });
+ assert.strictEqual(wildcard.total, 0, 'LIKE wildcards are literal');
+ assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);
+ assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');
+ });
+
+ await check('by-user: 404 for unknown, own hidden pastes only for the owner', async () => {
+ await rejects(svc.byUser(anon, 'ghost'), 404, 'User not found');
+ const self = await svc.byUser(alice, 'ALICE');
+ assert.strictEqual(self.username, 'alice');
+ assert.ok(self.pastes.some((p) => p.slug === priv));
+ const other = await svc.byUser(bob, 'alice');
+ assert.ok(!other.pastes.some((p) => p.slug === priv || p.slug === unl));
+ assert.strictEqual(other.total, other.pastes.length);
+ });
+
+ await check('views: counted once per visitor (cooldown), never for the owner, never for bots, not with noView', async () => {
+ const slug = (await svc.createText(alice, { content: 'views' })).slug;
+ assert.strictEqual((await svc.get(alice, slug)).paste.views, 0, 'owner');
+ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1);
+ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1, 'cooldown');
+ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.1' })).paste.views, 2);
+ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.2', userAgent: 'Googlebot/2.1' })).paste.views, 2, 'bot');
+ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.3', noView: true })).paste.views, 2, 'no_view');
+ const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;
+ assert.strictEqual(p.views, 3);
+ assert.strictEqual(p.unique_views, 3);
+ const visits = db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id).map((r) => r.visitor);
+ assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');
+ });
+
+ await check('burn after read: the owner reads freely; the first other read shows it, the second burns it (410)', async () => {
+ const slug = (await svc.createText(alice, { content: 'secret', burn_after_read: true })).slug;
+ assert.strictEqual((await svc.get(alice, slug)).paste.content, 'secret');
+ const first = await svc.get(bob, slug);
+ assert.strictEqual(first.paste.content, 'secret');
+ assert.strictEqual(first.paste.burn_after_read, true);
+ await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');
+ await rejects(svc.get(alice, slug), 404);
+ const row = db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);
+ assert.strictEqual(row.content, null, 'content scrubbed');
+ assert.ok(row.deleted_at);
+ // Raw text: Media's rule — the first read serves it, the next one burns it.
+ const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;
+ assert.strictEqual(svc.raw(bob, raw).content, 'raw secret');
+ await rejects(Promise.resolve().then(() => svc.raw(bob, raw)), 410);
+ });
+
+ await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {
+ const f = await svc.fork(bob, pub);
+ assert.strictEqual(f.paste.title, 'Fork of Hello');
+ assert.strictEqual(f.paste.visibility, 'public');
+ assert.strictEqual(f.paste.owner_subject, bob.subject);
+ assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);
+ db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();
+ await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');
+ assert.deepStrictEqual(svc.like(bob, pub), { liked: true, likes: 1 });
+ assert.deepStrictEqual(svc.like(alice, pub), { liked: true, likes: 2 });
+ assert.deepStrictEqual(svc.like(bob, pub), { liked: false, likes: 1 });
+ await rejects(Promise.resolve().then(() => svc.like(anon, pub)), 401, 'Authentication required');
+ assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);
+ assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);
+ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 1 });
+ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 2 });
+ });
+
+ await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {
+ const a = (await svc.addComment(anon, pub, { message: ' hi ', anon_name: 'Guest <b>!' }, { ip: '1.2.3.4' })).comment;
+ assert.strictEqual(a.message, 'hi');
+ assert.strictEqual(a.anon_name, 'Guest b');
+ assert.strictEqual(a.user_id, null);
+ const b = (await svc.addComment(bob, pub, { message: 'first!' })).comment;
+ assert.strictEqual(b.user_id, bob.subject);
+ assert.strictEqual(b.username, 'bob');
+ const r = (await svc.addComment(alice, pub, { message: 'reply', parent_id: b.id })).comment;
+ assert.strictEqual(r.parent_id, b.id);
+ await rejects(svc.addComment(anon, pub, { message: 'deep', parent_id: r.id }, { ip: '1.2.3.5' }), 400, 'Cannot reply to a reply — reply to the original comment instead');
+ await rejects(svc.addComment(anon, pub, { message: '' }), 400, 'Comment cannot be empty');
+ await rejects(svc.addComment(anon, pub, { message: 'x'.repeat(2001) }), 400, 'Comment must be under 2000 characters');
+ await rejects(svc.addComment(anon, pub, { message: 'bad parent', parent_id: 99999 }), 400, 'Invalid parent comment');
+ const list = await svc.comments(anon, pub);
+ assert.strictEqual(list.total, 3);
+ assert.strictEqual(list.comments[0].id, b.id, 'newest top-level first');
+ assert.strictEqual(list.comments[0].reply_count, 1);
+ assert.strictEqual(list.comments[0].replies[0].username, 'alice');
+ assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');
+ // Delete: author, paste owner or staff; everyone else 403; anonymous 401.
+ await rejects(Promise.resolve().then(() => svc.deleteComment(anon, pub, b.id)), 401);
+ await rejects(Promise.resolve().then(() => svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');
+ assert.deepStrictEqual(svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });
+ assert.deepStrictEqual(svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');
+ await rejects(Promise.resolve().then(() => svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');
+ assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');
+ });
+
+ await check('comment limits for people: cooldown, then duplicate', async () => {
+ const strict = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 10 } });
+ const carol = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+ await strict.addComment(carol, pub, { message: 'one' });
+ await rejects(strict.addComment(carol, pub, { message: 'two' }), 429);
+ const loose = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 0 } });
+ await loose.addComment(carol, pub, { message: 'same' });
+ await rejects(loose.addComment(carol, pub, { message: 'same' }), 400, 'Duplicate comment');
+ });
+
+ await check('paste limits for people: cooldown with seconds to wait, daily cap', async () => {
+ const strict = createPasteService({ db, config: {}, limits: { cooldownSeconds: 30, maxPerUserPerDay: 200 } });
+ const dave = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+ await strict.createText(dave, { content: 'a' });
+ const err = await rejects(strict.createText(dave, { content: 'b' }), 429);
+ assert.ok(err.body.cooldown > 0 && err.body.cooldown <= 30);
+ assert.match(err.body.error, /^Please wait \d+s before creating another paste$/);
+ const capped = createPasteService({ db, config: {}, limits: { cooldownSeconds: 0, maxPerUserPerDay: 1 } });
+ await rejects(capped.createText(dave, { content: 'c' }), 429, 'Daily paste limit reached (1/day)');
+ // Anonymous callers aren't limited here (the per-address limiter in api.js is).
+ await strict.createText(anon, { content: 'x' });
+ await strict.createText(anon, { content: 'y' });
+ assert.deepStrictEqual(Object.keys(capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);
+ assert.strictEqual(capped.config(dave).todayCount, 1);
+ });
+
+ await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {
+ await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');
+ await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');
+ await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');
+ await rejects(Promise.resolve().then(() => svc.remove(bob, priv)), 404, 'Paste not found');
+ await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');
+ await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');
+ const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });
+ assert.strictEqual(u.paste.language, 'python');
+ assert.strictEqual(u.paste.pinned, false, 'owners cannot pin');
+ assert.strictEqual(u.paste.revision, 2);
+ const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });
+ assert.strictEqual(m.paste.pinned, true);
+ assert.strictEqual(m.paste.revision, 3);
+ const v = svc.versions(alice, pub);
+ assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);
+ assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');
+ assert.strictEqual(v.versions[1].edited_by, alice.subject);
+ assert.strictEqual(v.versions[2].edited_by, mod.subject);
+ await svc.update(alice, pub, { visibility: 'unlisted' });
+ assert.strictEqual(svc.versions(alice, pub).revision, 3, 'visibility changes are not text revisions');
+ await rejects(Promise.resolve().then(() => svc.versions(bob, unl)), 403);
+ });
+
+ await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {
+ await rejects(Promise.resolve().then(() => svc.remove(bob, unl)), 403);
+ assert.deepStrictEqual(svc.remove(alice, unl), { success: true });
+ await rejects(svc.get(alice, unl), 404);
+ assert.deepStrictEqual(svc.remove(mod, priv), { success: true }, 'staff');
+ const row = db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);
+ assert.ok(row && row.deleted_at && row.content === null);
+ assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));
+ });
+
+ await check('staff tools: stats, forks, bulk, AI write-back', async () => {
+ const stats = svc.stats().stats;
+ assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);
+ assert.ok(svc.forks({}).forks.length >= 1);
+ const b = svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });
+ assert.deepStrictEqual(b, { done: 1, skipped: 1 });
+ assert.throws(() => svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);
+ assert.throws(() => svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);
+ const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });
+ assert.strictEqual(ai.ok, true);
+ assert.strictEqual(ai.paste.ai_summary.length, 2000);
+ assert.strictEqual(ai.paste.ai_tags, '["a","b"]');
+ assert.ok(ai.paste.ai_analyzed_at);
+ const del = svc.deleteForks();
+ assert.ok(del.deleted >= 1 && del.success === true);
+ });
+
+ await check('generateSlug never reuses a slug, even a deleted one', async () => {
+ const seen = new Set(db.prepare('SELECT slug FROM pastes').all().map((r) => r.slug));
+ for (let i = 0; i < 200; i++) { const s = store.generateSlug(db); assert.ok(!seen.has(s)); }
+ });
+
+ db.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/** Uploaded images lose EXIF/XMP/text metadata without being re-encoded (server/media/strip-metadata.js). */
+const assert = require('assert');
+const zlib = require('zlib');
+const { stripImageMetadata } = require('../server/media/strip-metadata');
+const { check, done } = require('./helpers/app');
+
+function crc32(buf) {
+ let c, crc = 0xffffffff;
+ for (let n = 0; n < buf.length; n++) {
+ c = (crc ^ buf[n]) & 0xff;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ crc = (crc >>> 8) ^ c;
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+function pngChunk(type, data) {
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([len, body, crc]);
+}
+function riffChunk(type, data) {
+ const head = Buffer.alloc(8); head.write(type, 0, 'latin1'); head.writeUInt32LE(data.length, 4);
+ return Buffer.concat([head, data, data.length & 1 ? Buffer.alloc(1) : Buffer.alloc(0)]);
+}
+
+(async () => {
+ await check('PNG: tEXt / eXIf / iTXt chunks removed, image chunks kept byte-for-byte', async () => {
+ const ihdr = pngChunk('IHDR', Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0]));
+ const idat = pngChunk('IDAT', zlib.deflateSync(Buffer.from([0, 0])));
+ const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ihdr,
+ pngChunk('tEXt', Buffer.from('Author\0Secret Person', 'latin1')), pngChunk('eXIf', Buffer.from('MM\0*GPS', 'latin1')),
+ idat, pngChunk('iTXt', Buffer.from('XML:com.adobe.xmp\0\0\0\0\0<gps/>', 'latin1')), pngChunk('IEND', Buffer.alloc(0))]);
+ const out = stripImageMetadata(png, 'image/png');
+ assert.ok(!out.includes(Buffer.from('Secret')) && !out.includes(Buffer.from('GPS')) && !out.includes(Buffer.from('<gps/>')));
+ assert.ok(out.includes(ihdr) && out.includes(idat));
+ assert.ok(out.subarray(out.length - 12).equals(pngChunk('IEND', Buffer.alloc(0))));
+ });
+
+ await check('WebP: EXIF and XMP chunks removed, VP8X flags cleared, RIFF size fixed', async () => {
+ const vp8x = Buffer.alloc(10); vp8x[0] = 0x0c | 0x10; // EXIF + XMP + alpha flags
+ const body = Buffer.concat([riffChunk('VP8X', vp8x), riffChunk('VP8L', Buffer.from('imagebytes')), riffChunk('EXIF', Buffer.from('GPS-DATA')), riffChunk('XMP ', Buffer.from('<x/>'))]);
+ const head = Buffer.alloc(12); head.write('RIFF', 0, 'latin1'); head.writeUInt32LE(body.length + 4, 4); head.write('WEBP', 8, 'latin1');
+ const out = stripImageMetadata(Buffer.concat([head, body]), 'image/webp');
+ assert.ok(!out.includes(Buffer.from('GPS-DATA')) && !out.includes(Buffer.from('<x/>')));
+ assert.ok(out.includes(Buffer.from('imagebytes')));
+ assert.strictEqual(out.readUInt32LE(4), out.length - 8);
+ assert.strictEqual(out[20] & 0x0c, 0, 'EXIF/XMP flags cleared');
+ assert.strictEqual(out[20] & 0x10, 0x10, 'other flags kept');
+ });
+
+ await check('GIFs and anything unparseable pass through unchanged', async () => {
+ const gif = Buffer.from('GIF89a....');
+ assert.strictEqual(stripImageMetadata(gif, 'image/gif'), gif);
+ const junk = Buffer.from([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff]);
+ assert.strictEqual(stripImageMetadata(junk, 'image/jpeg'), junk);
+ });
+
+ done();
+})();