openvibers/openvibe.media Commits
Delete the read-only pastes app API and its handlers (#37)
Changes
@@@ -7,7 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonicalobject model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community--on 2026-09-22; Media's paste API is read-only and writes answer 410 ([Pastes](#pastes)).++on 2026-09-22; Media's read-only paste API is gone (T10 step 2) and it serves only the++frozen rows and old screenshots ([Pastes](#pastes)).Implements **Media API v1** from `../CONTRACTS.md`.@@@ -89,7 +90,7 @@ server/vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLIvod/health-scanner.js probe/decode/master-recovery primitivesvod/health-job.js background health scan + quarantine cleanup + master sweep-- pastes/routes.js /api/v1/:app/pastes (read-only; writes answer 410 pastes.moved)++ pastes/storage.js screenshot dir + slug minting kept after the pastes API was retiredfiles/routes.js /api/v1/:app/filesadmin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)thumbnails/ thumbnail service + /api/v1/:app/thumbnails@@@ -160,7 +161,7 @@ Credential types on `/api/v1/:app/...`:constant time against the app's stored hash. A key is only valid for itsown `:app` segment; presenting another app's key returns **403**.2. **Network user JWT** (browser endpoints only — chunks/complete, clips,-- pastes, files, thumbnails) — RS256, verified **offline** against the JWKS++ files, thumbnails) — RS256, verified **offline** against the JWKSpublic key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot(cached, refreshed every 6 h). `aud` must include `openvibe.media` whenpresent. If the request carries an `Origin` header it must be in the app's@@@ -193,7 +194,7 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, wnamed in a URL. Nothing is created unless the token's `project_id` is the path's project and thetoken holds the route's capability.- App tokens reach only these tenants and only the capability routes (files, objects v2 without-- retention holds). VODs, clips, pastes, thumbnails, assets, stats and admin refuse them.++ retention holds). VODs, clips, thumbnails, assets, stats and admin refuse them.- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get`401 token.sandbox_refused`.- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL@@@ -278,9 +279,10 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every### Pastes--**Read-only since the move:** OpenVibe.Community owns pastes (since--2026-09-22). The paste API here answers reads only: every other method--(POST, PUT, DELETE, …) answers 410 `{ "code": "pastes.moved" }` for every app.++**The API is gone:** OpenVibe.Community owns pastes (since 2026-09-22) and Media's++read-only paste app API was retired in T10 step 2 — `/api/v1/:app/pastes` is++unmounted, so every method there answers 404. No paste is created, read, listed or++deleted through Media any more.`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text`/raw` into 301s to Community. Screenshot bytes are still served from here, andnew screenshots are uploaded to the token-only `community` tenant.@@@ -288,15 +290,16 @@ Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, soMedia has no API that deletes them.--An unauthenticated write still answers 401 before the 410. `GET /pastes/config`--reports the old limits for information only.++++`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs++and writes screenshots into the same directory, and burn-after-read removes a++screenshot's bytes (skipping a held row).| method | path | notes ||---|---|---|--| GET | `/pastes?limit&offset&type&search&user_id` | public list |--| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |--| GET | `/pastes/:slug` | full paste (private: owner/app only) |--| GET | `/pastes/:slug/comments` | threaded comments |++| GET | `/p/:slug` | 301 to OpenVibe.Community when `PASTES_MOVED_TO` is set; otherwise the old page |++| GET | `/p/:slug/raw` | 301 to Community; image pastes 302 to their screenshot |++| GET | `/p/:slug/screenshot` | frozen screenshot bytes (unknown/private slugs 301 to Community) |AI summary/tags columns remain for imported rows.@@@ -411,7 +414,7 @@ release now in production as the next N-1 (`npm run n-1:record [ref]`) and commiExpress `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that thelocal nginx set when the request came through it, and the socket address for anyone else. A callerthat reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or--`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,++`CF-Connecting-IP` itself. View counting and the live-frame rate limit read `req.ip`,never the headers (`test/trust-proxy.test.js`).## Per-actor limits@@@ -477,7 +480,7 @@ Private items (and legacy rows with no visibility and `is_public = 0`) answerexactly like a missing id — the same 404 and body — unless the request bears theowning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holdsfor the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect--and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)++and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`)apply it to an app acting for someone other than the owner. A call acting for a user writesonly that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):someone else's private one is the missing answer, anything else 403. The media index lists a@@@ -19,7 +19,7 @@"media.lifecycle.transition"],"capabilitiesRegistered": true,-- "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's paste API is read-only and every write answers 410 pastes.moved, /p/:slug 301s to Community and Media serves screenshot bytes only (the read-only API is being retired, T10 step 2)",++ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted), /p/:slug 301s to Community and Media serves screenshot bytes only","deployed": true,"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.","features": [@@@ -4,7 +4,9 @@ Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into oneplatform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typedprojection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on--2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only. Every existing URL and v1 response is++2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only, and++its v1 pastes API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted; only the public++`/p/:slug` routes remain). Other existing URLs and v1 responses areunchanged. New code talks to objects through the [v2 API](#object-api-v2).Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`, `invariant.js`,@@@ -119,7 +121,7 @@ after the outcome event.| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction || file upload | `db.createFile` || thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |--| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API is read-only and writes answer 410 `pastes.moved` |++| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted) || avatar ingest | `avatars/ingest` || tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |@@@ -505,11 +507,11 @@ While any hold applies:- **No delete path works.**- v2 `DELETE` answers 409.-- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (paste deletes answer 410 `pastes.moved` for every paste since the move).++ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2, so a paste row is only ever deleted by SQL, which the trigger refuses).- Quarantine cleanup and the boot junk sweep skip the object.- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).- `deleteVodObjects` keeps the bytes.-- - Paste screenshot removal no longer exists (the paste API is read-only since pastes moved to OpenVibe.Community).++ - Paste screenshot removal is only the burn-after-read path in `server/pastes/storage.js`, which skips a held row.- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.@@@ -105,15 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Coecho "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"--echo "-- pastes: read-only (OpenVibe.Community owns pastes)"--R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \++echo "-- pastes: API retired (OpenVibe.Community owns pastes), public routes still serve"++CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \-d '{"title":"Smoke paste","content":"hello from the smoke test"}')--check "paste create → pastes.moved" "pastes.moved" "$(jsonget "$R" code)"++check "paste create → 404 (no route)" "404" "$CODE"CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")--check "screenshot paste create → 410" "410" "$CODE"++check "screenshot paste create → 404" "404" "$CODE"CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")--check "paste list still reads" "200" "$CODE"++check "paste list → 404" "404" "$CODE"echo "-- admin storage: overview + vod listing"R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")@@@ -125,7 +125,7 @@ router.get('/', async (req, res) => {`, [req.appId]) || {};const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};// Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and-- // the read-only paste API is being retired (T10 step 2); the count stays++ // the read-only paste API was retired (T10 step 2); the count stays// while the table exists (it feeds /api/v2/me and operator stats).const pasteStats = await db.get(`SELECT COUNT(*) AS count,@@@ -15,7 +15,8 @@// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same--// system as every other picture (deletable, reportable, served with range/caching by the paste routes).++// system as every other picture (reportable, served with range/caching by the public /p/:slug/screenshot++// route). The paste API was retired in T10 step 2, so Media has no route that deletes them.// ═══════════════════════════════════════════════════════════════const dns = require('dns').promises;const https = require('https');@@@ -34,7 +35,7 @@ const egress = require('openvibe-shared/egress');const isPublicAddress = (ip) => egress.isPublicAddress(ip);// Where avatars land and how their slug is minted: the paste storage module--// (server/pastes/storage.js), not the paste router. The defaults keep the exact++// (server/pastes/storage.js), a leaf with no router. The defaults keep the exact// persisted path and slug behavior; tests may inject their own.const pasteStorage = require('../pastes/storage');@@@ -110,7 +110,7 @@ const ready = (async () => {// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the// route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)-- // from server/pastes/storage.js itself — nothing from the paste router is passed in.++ // from server/pastes/storage.js itself — a leaf, not a router.app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));}@@@ -201,7 +201,6 @@ const ready = (async () => {app.use('/api/v1/:app/views', require('./views/routes'));app.use('/api/v1/:app/vods', require('./vod/routes'));app.use('/api/v1/:app/clips', require('./vod/clips-routes'));-- app.use('/api/v1/:app/pastes', require('./pastes/routes'));app.use('/api/v1/:app/files', require('./files/routes'));app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));app.use('/api/v1/:app/assets', require('./assets/routes'));ddeleted file mode 100644@@@ -1,259 +0,0 @@--/**-- * OpenVibe.Media — Pastes API, read-only (mounted at /api/v1/:app/pastes)-- *-- * OpenVibe.Community owns pastes since the 2026-09-22 cutover (PASTES_MOVED_TO).-- * This read-only surface is being retired (T10 step 2): nothing calls the app-- * API any more, so the router and its GET handlers go; the `pastes` table and-- * the screenshot leaves below stay until Pics owns the bytes. Media keeps the-- * rows and answers reads for anything still pointed here; public pages live at-- * /p/:slug (HTML) and /p/:slug/raw.-- *-- * GET / list (?limit&offset&type&search&user_id&include_unlisted&sort)-- * GET /config paste limits (+ the caller's count today)-- * GET /:slug paste meta/content-- * GET /:slug/comments list comments (+replies)-- *-- * Every other method answers 410 { code: 'pastes.moved' }.-- */--'use strict';----const express = require('express');--const fs = require('fs');--const db = require('../db/database');--const { tenantAuth, tenantCors } = require('../auth');----const router = express.Router({ mergeParams: true });--router.use(tenantCors);--// Pastes moved to OpenVibe.Community: no writes here, for any app. The caller is--// authenticated first, so an anonymous write still answers 401 like the reads.--const writeAuth = tenantAuth({ allowUser: true });--router.use((req, res, next) => {-- if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();-- writeAuth(req, res, () => res.status(410).json({ error: 'Pastes moved to OpenVibe.Community', code: 'pastes.moved' }));--});----// Screenshot directory, shared with the avatar ingest (server/pastes/storage.js).--const { SCREENSHOTS_DIR } = require('./storage');----// ── Helpers ─────────────────────────────────────────────────----async function getPasteConfig() {-- return {-- maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,-- screenshotMaxSizeMb: Number(await db.getSetting('paste_screenshot_max_size_mb')) || 8,-- cooldownSeconds: Number(await db.getSetting('paste_cooldown_seconds')) || 30,-- maxPerUserPerDay: Number(await db.getSetting('paste_max_per_user_per_day')) || 0,-- };--}----function screenshotUrl(paste) {-- return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;--}----function pastePublic(paste) {-- if (!paste) return null;-- return {-- unique_views: paste.unique_views || 0,---- id: paste.id,-- app_id: paste.app_id,-- slug: paste.slug,-- user_id: paste.user_id,-- type: paste.type,-- title: paste.title,-- content: paste.content,-- language: paste.language,-- visibility: paste.visibility,-- stream_id: paste.stream_id,-- screenshot_url: screenshotUrl(paste),-- metadata: paste.metadata,-- burn_after_read: !!paste.burn_after_read,-- forked_from: paste.forked_from,-- pinned: !!paste.pinned,-- views: paste.views || 0,-- copies: paste.copies || 0,-- likes: paste.likes || 0,-- is_nsfw: !!paste.is_nsfw,-- ai_summary: paste.ai_summary || null,-- ai_tags: paste.ai_tags || null,-- ai_analyzed_at: paste.ai_analyzed_at || null,-- url: `/p/${paste.slug}`,-- raw_url: `/p/${paste.slug}/raw`,-- created_at: paste.created_at,-- updated_at: paste.updated_at,-- };--}----// Effective actor for the daily count in GET /config: user-JWT callers are--// counted by their token; app-key callers name the user in the request.--function _actor(req) {-- if (req.authType === 'user') return { userId: req.userId, ip: req.ip };-- return { userId: (req.body && req.body.user_id) || null, ip: req.ip };--}----async function _getPasteScoped(req, res) {-- const slug = String(req.params.slug);-- const paste = await db.getPasteBySlug(slug, req.appId);-- if (!paste) {-- // "Paste not found" was being reported for pastes that demonstrably exist, and the-- // 404 was silent — so there was no way to tell whether the slug was wrong, the-- // tenant scope was wrong, or the row was genuinely missing. Log all three.-- let existsElsewhere = null;-- try {-- const any = await db.get('SELECT app_id FROM pastes WHERE slug = ?', [slug]);-- existsElsewhere = any ? any.app_id : null;-- } catch { /* */ }-- console.warn(`[Pastes] 404 slug="${slug}" appId="${req.appId}" auth=${req.authType || 'none'}` +-- (existsElsewhere ? ` — EXISTS under app_id="${existsElsewhere}" (tenant mismatch)` : ' — no row with that slug'));-- res.status(404).json({ error: 'Paste not found' });-- return null;-- }-- return paste;--}----// Fully remove a paste's screenshot from local disk AND any legacy B2 object.--async function removePasteScreenshot(paste) {-- if (!paste) return;-- // A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).-- const objects = require('../objects/model');-- if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;-- if (paste.screenshot_path) {-- try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }-- }-- try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }--}----// ── List pastes ─────────────────────────────────────────────--router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);-- const offset = Math.max(parseInt(req.query.offset) || 0, 0);-- const type = req.query.type; // 'paste', 'screenshot', or undefined for all-- const search = req.query.search ? `%${req.query.search}%` : null;-- const userId = req.query.user_id != null ? req.query.user_id : null;---- // Unlisted/private pastes are hidden from PUBLIC listings, but their owner must-- // still see them in their own list — otherwise creating an unlisted paste looks-- // exactly like it was never created at all. Only honoured for a caller that can be-- // trusted to be the owner: either an app key (the app gates who may ask) or a user-- // token whose subject matches the user_id being listed.-- const wantMine = req.query.include_unlisted === '1' || req.query.include_unlisted === 'true';-- const ownerView = wantMine && userId != null && (-- req.authType === 'app' || (req.authType === 'user' && String(req.userId) === String(userId))-- );-- const visClause = ownerView-- ? `AND visibility IN ('public', 'unlisted', 'private')`-- : `AND visibility = 'public'`;---- let sql = `SELECT * FROM pastes WHERE app_id = ? ${visClause}`;-- const params = [req.appId];---- if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }-- if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }-- // Work queue for the owning app's AI pass. Media holds the pastes but has no LLM;-- // the app polls this, analyses, and posts results back to /:slug/ai. App-key only —-- // this deliberately spans visibility, so it must never be reachable by a user token.-- if (req.authType === 'app' && (req.query.needs_ai === '1' || req.query.needs_ai === 'true')) {-- sql = sql.replace(visClause, `AND COALESCE(ai_summary, '') = ''`);-- }-- if (search) { sql += ` AND (title ILIKE ? OR content ILIKE ?)`; params.push(search, search); }---- const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';-- sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;-- params.push(limit, offset);---- const pastes = (await db.all(sql, params)).map(p => ({-- ...pastePublic(p),-- content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list-- }));---- let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? ${visClause}`;-- const countParams = [req.appId];-- if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }-- if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }-- if (search) { countSql += ` AND (title ILIKE ? OR content ILIKE ?)`; countParams.push(search, search); }-- const { total } = await db.get(countSql, countParams);---- res.json({ pastes, total, limit, offset });-- } catch (err) {-- console.error('[Pastes] List error:', err.message);-- res.status(500).json({ error: 'Failed to list pastes' });-- }--});----// ── Paste config / limits (inherited SPA reads this before posting) ──--// Registered before /:slug so the literal path wins.--router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const cfg = await getPasteConfig();-- const actor = _actor(req);-- res.json({-- maxSizeKb: cfg.maxSizeKb,-- screenshotMaxSizeMb: cfg.screenshotMaxSizeMb,-- cooldownSeconds: cfg.cooldownSeconds,-- maxPerUserPerDay: cfg.maxPerUserPerDay,-- todayCount: await db.countUserPastesToday(req.appId, actor.userId, actor.ip),-- });-- } catch (err) {-- console.error('[Pastes] Config error:', err.message);-- res.status(500).json({ error: 'Failed to load paste config' });-- }--});------// ── Get single paste by slug ────────────────────────────────--router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- // Private pastes: owner (or the app itself) only — decided before anything is counted.-- if (paste.visibility === 'private') {-- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);-- if (!allowed) return res.status(404).json({ error: 'Paste not found' });-- }-- // The owning app renders the paste page itself — count the view here (real client-- // IP via X-Forwarded-For, viewer via the "user" JWT / X-OV-User-Id) unless it says-- // this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).-- if (String(req.query.no_view || '') !== '1' && !paste.burn_after_read) {-- try { const r = await require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }-- }------ paste.liked = req.userId != null ? await db.hasUserLikedPaste(paste.id, req.userId) : false;-- res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });-- } catch (err) {-- console.error('[Pastes] Get error:', err.message);-- res.status(500).json({ error: 'Failed to get paste' });-- }--});----// ═════════════════════════════════════════════════════════════--// ── Paste Comments (supports anonymous via app callers) ─────--// ═════════════════════════════════════════════════════════════----router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;---- const limit = Math.min(parseInt(req.query.limit || '50'), 100);-- const offset = parseInt(req.query.offset || '0');---- const comments = await db.getPasteComments(paste.id, limit, offset);-- const total = await db.getPasteCommentCount(paste.id);---- for (const c of comments) {-- c.replies = await db.getPasteCommentReplies(c.id);-- c.reply_count = c.replies.length;-- }---- res.json({ comments, total });-- } catch (err) {-- console.error('[PasteComments] List error:', err.message);-- res.status(500).json({ error: 'Failed to load comments' });-- }--});----module.exports = router;--module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;--module.exports.removePasteScreenshot = removePasteScreenshot;@@@ -1,9 +1,10 @@'use strict';--// Paste storage plumbing, shared by the paste routes and the avatar ingest (which--// stores every avatar as an unlisted screenshot paste): where a paste's screenshot--// bytes land, and how a slug is minted (checked against the pastes table, so two--// writes never get the same one). A leaf module — requiring it pulls in neither the--// paste router nor anything that mounts it.++// Paste storage plumbing, shared by the public paste routes and the avatar ingest++// (which stores every avatar as an unlisted screenshot paste): where a paste's++// screenshot bytes land, how a slug is minted (checked against the pastes table, so++// two writes never get the same one), and how a burn-after-read screenshot is++// removed. A leaf module — requiring it pulls in neither a router nor anything that++// mounts one.const fs = require('fs');const path = require('path');const crypto = require('crypto');@@@ -63,4 +64,16 @@ async function generateSlug(attempts = 0) {return slug;}--module.exports = { SCREENSHOTS_DIR, generateSlug };++// Fully remove a paste's screenshot from local disk AND any legacy B2 object.++// A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).++async function removePasteScreenshot(paste) {++ if (!paste) return;++ const objects = require('../objects/model');++ if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;++ if (paste.screenshot_path) {++ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }++ }++ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }++}++++module.exports = { SCREENSHOTS_DIR, generateSlug, removePasteScreenshot };@@@ -548,7 +548,7 @@ router.get('/p/:slug', optionalIdentity, async (req, res) => {// Burn-after-read: allow one non-owner read, then delete.if (paste.burn_after_read && !isOwner && paste.views > 1) {-- await require('../pastes/routes').removePasteScreenshot(paste);++ await require('../pastes/storage').removePasteScreenshot(paste);await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);return res.status(410).send('This paste has been burned after reading.');}@@@ -49,12 +49,10 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');// ── Shared paste storage: the screenshots dir and the slug have one source ──-- // (server/pastes/storage.js — the avatar ingest no longer takes them from the paste router)-- assert.ok(!require.cache[require.resolve('../server/pastes/routes')], 'requiring the avatar ingest does not load the paste routes');++ // (server/pastes/storage.js — the paste router is gone, the avatar ingest reads the leaf directly)const storage = require('../server/pastes/storage');const config = require('../server/config');assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');-- assert.strictEqual(storage.SCREENSHOTS_DIR, require('../server/pastes/routes').SCREENSHOTS_DIR, 'the paste routes share that one directory');const slugA = await storage.generateSlug();const slugB = await storage.generateSlug();for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);@@@ -72,7 +72,6 @@ const sharp = require('sharp');app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));app.use('/api/v1/:app/files', require('../server/files/routes'));app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));const server = http.createServer(app);(async () => {@@@ -203,10 +202,10 @@ const sharp = require('sharp');assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');-- // Paste writes moved to OpenVibe.Community: POST /pastes answers 410 and writes nothing.++ // The read-only paste API is retired (T10 step 2): POST /pastes has no route and writes nothing.const pastesBefore = [await count('pastes'), await count('media_objects')];r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);++ assert.strictEqual(r.status, 404, 'no paste-write route remains');assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');// A screenshot paste row (imported before the move) still commits with its object.const slug = 'shot1';@@@ -1,6 +1,7 @@'use strict';--// Pastes moved to OpenVibe.Community: paste writes always answer 410 pastes.moved and reads keep working.--// With PASTES_MOVED_TO set, the paste page and its text 301 to Community; screenshot bytes are still served here.++// Pastes moved to OpenVibe.Community: Media's read-only paste API is retired (T10 step 2), so++// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug page++// and its text still 301 to Community when PASTES_MOVED_TO is set, and screenshot bytes are served here.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -18,7 +19,6 @@ const express = require('express');const app = express();app.use(express.json());-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));app.use(require('../server/public/routes'));const server = http.createServer(app);@@@ -28,25 +28,21 @@ const express = require('express');const get = (p) => fetch(base + p, { redirect: 'manual' });const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });-- // Writes answer 410 for every app, with or without PASTES_MOVED_TO, and create nothing.++ // The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to++ // answer 410 and creates nothing, and a read has no route either.const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;const rows = await pasteCount();let r = await post();-- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');-- assert.strictEqual(await pasteCount(), rows, 'a refused write creates no paste');-- // The caller is authenticated before the 410: an anonymous write answers 401, like the reads.-- r = await fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });-- assert.strictEqual(r.status, 401);++ assert.strictEqual(r.status, 404, 'no paste-write route remains');++ r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });++ assert.strictEqual(r.status, 404, 'no paste-read route remains');++ assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');// Before the redirect switch: the pages are served here.assert.strictEqual((await get('/p/text-1')).status, 200);assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');process.env.PASTES_MOVED_TO = 'https://openvibe.community/';-- r = await post();-- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');-- r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });-- assert.strictEqual(r.status, 200, 'reads still work');r = await get('/p/text-1');assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1');r = await get('/p/text-1/raw');@@@ -63,7 +63,6 @@ const http = require('http');app.use(express.json());app.use('/api/v1/:app/vods', require('../server/vod/routes'));app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));app.use('/o', require('../server/objects/routes').publicRouter);app.use('/', require('../server/public/routes'));const server = http.createServer(app);@@@ -182,9 +181,11 @@ const http = require('http');const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;const before = await views();-- await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');++ // The read-only paste API is retired (T10 step 2): the v1 path is unmounted for everyone,++ // so it cannot confirm a private paste exists either.++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(77))).status, 404);assert.strictEqual(await views(), before, 'a refused read counts no view');-- assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);console.log('✅ v1 detail: acting non-owners get the missing answer');server.close();@@@ -67,7 +67,6 @@ const http = require('http');app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));app.use('/api/v1/:app/vods', require('../server/vod/routes'));app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));app.use('/api/v2/:app/objects', require('../server/objects/routes'));(async () => {@@@ -173,17 +172,17 @@ const http = require('http');assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');-- // ── A held screenshot paste: paste writes moved to OpenVibe.Community, so deletes answer 410 ──++ // ── A held screenshot paste: the v1 paste API is retired (T10 step 2), so the hold is++ // enforced by the delete trigger alone — the row, its bytes and its object stay. ──const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });-- r = await call('DELETE', '/api/v1/live/pastes/shot1');-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);-- r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved'], 'bulk delete is gone too');++ await assert.rejects(async () => await db.run("DELETE FROM pastes WHERE slug = 'shot1'"), /retention hold/, 'row SQL is refused by the trigger');++ const probe = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/live/pastes/shot1`, { method: 'DELETE', headers: { authorization: 'Bearer live-key' }, redirect: 'manual' });++ assert.strictEqual(probe.status, 404, 'the paste API has no delete route any more');assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));const shotObj = await model.getObject(shot.object_id);assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');-- console.log('✅ a held screenshot paste: DELETE and bulk delete answer 410 pastes.moved; row, bytes and object stay');++ console.log('✅ a held screenshot paste: the delete trigger refuses it; row, bytes and object stay');// ── Listing and release ──r = await call('GET', H);
@@ -7,7 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
@@ -7,7 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
-on 2026-09-22; Media's paste API is read-only and writes answer 410 ([Pastes](#pastes)).
+on 2026-09-22; Media's read-only paste API is gone (T10 step 2) and it serves only the
+frozen rows and old screenshots ([Pastes](#pastes)).
Implements **Media API v1** from `../CONTRACTS.md`.
Implements **Media API v1** from `../CONTRACTS.md`.
@@ -89,7 +90,7 @@ server/
@@ -89,7 +90,7 @@ server/
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-job.js background health scan + quarantine cleanup + master sweep
vod/health-job.js background health scan + quarantine cleanup + master sweep
- pastes/routes.js /api/v1/:app/pastes (read-only; writes answer 410 pastes.moved)
+ pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
files/routes.js /api/v1/:app/files
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
@@ -160,7 +161,7 @@ Credential types on `/api/v1/:app/...`:
@@ -160,7 +161,7 @@ Credential types on `/api/v1/:app/...`:
constant time against the app's stored hash. A key is only valid for its
constant time against the app's stored hash. A key is only valid for its
own `:app` segment; presenting another app's key returns **403**.
own `:app` segment; presenting another app's key returns **403**.
2. **Network user JWT** (browser endpoints only — chunks/complete, clips,
2. **Network user JWT** (browser endpoints only — chunks/complete, clips,
- pastes, files, thumbnails) — RS256, verified **offline** against the JWKS
+ files, thumbnails) — RS256, verified **offline** against the JWKS
public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot
public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot
(cached, refreshed every 6 h). `aud` must include `openvibe.media` when
(cached, refreshed every 6 h). `aud` must include `openvibe.media` when
present. If the request carries an `Origin` header it must be in the app's
present. If the request carries an `Origin` header it must be in the app's
@@ -193,7 +194,7 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w
@@ -193,7 +194,7 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w
named in a URL. Nothing is created unless the token's `project_id` is the path's project and the
named in a URL. Nothing is created unless the token's `project_id` is the path's project and the
token holds the route's capability.
token holds the route's capability.
- App tokens reach only these tenants and only the capability routes (files, objects v2 without
- App tokens reach only these tenants and only the capability routes (files, objects v2 without
- retention holds). VODs, clips, pastes, thumbnails, assets, stats and admin refuse them.
+ retention holds). VODs, clips, thumbnails, assets, stats and admin refuse them.
- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get
- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get
`401 token.sandbox_refused`.
`401 token.sandbox_refused`.
- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL
- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL
@@ -278,9 +279,10 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
@@ -278,9 +279,10 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
### Pastes
### Pastes
-**Read-only since the move:** OpenVibe.Community owns pastes (since
-2026-09-22). The paste API here answers reads only: every other method
-(POST, PUT, DELETE, …) answers 410 `{ "code": "pastes.moved" }` for every app.
+**The API is gone:** OpenVibe.Community owns pastes (since 2026-09-22) and Media's
+read-only paste app API was retired in T10 step 2 — `/api/v1/:app/pastes` is
+unmounted, so every method there answers 404. No paste is created, read, listed or
+deleted through Media any more.
`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text
`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text
`/raw` into 301s to Community. Screenshot bytes are still served from here, and
`/raw` into 301s to Community. Screenshot bytes are still served from here, and
new screenshots are uploaded to the token-only `community` tenant.
new screenshots are uploaded to the token-only `community` tenant.
@@ -288,15 +290,16 @@ Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
@@ -288,15 +290,16 @@ Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and
`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and
`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so
`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so
Media has no API that deletes them.
Media has no API that deletes them.
-An unauthenticated write still answers 401 before the 410. `GET /pastes/config`
-reports the old limits for information only.
+
+`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs
+and writes screenshots into the same directory, and burn-after-read removes a
+screenshot's bytes (skipping a held row).
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
-| GET | `/pastes?limit&offset&type&search&user_id` | public list |
-| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |
-| GET | `/pastes/:slug` | full paste (private: owner/app only) |
-| GET | `/pastes/:slug/comments` | threaded comments |
+| GET | `/p/:slug` | 301 to OpenVibe.Community when `PASTES_MOVED_TO` is set; otherwise the old page |
+| GET | `/p/:slug/raw` | 301 to Community; image pastes 302 to their screenshot |
+| GET | `/p/:slug/screenshot` | frozen screenshot bytes (unknown/private slugs 301 to Community) |
AI summary/tags columns remain for imported rows.
AI summary/tags columns remain for imported rows.
@@ -411,7 +414,7 @@ release now in production as the next N-1 (`npm run n-1:record [ref]`) and commi
@@ -411,7 +414,7 @@ release now in production as the next N-1 (`npm run n-1:record [ref]`) and commi
Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
local nginx set when the request came through it, and the socket address for anyone else. A caller
local nginx set when the request came through it, and the socket address for anyone else. A caller
that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or
that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or
-`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,
+`CF-Connecting-IP` itself. View counting and the live-frame rate limit read `req.ip`,
never the headers (`test/trust-proxy.test.js`).
never the headers (`test/trust-proxy.test.js`).
## Per-actor limits
## Per-actor limits
@@ -477,7 +480,7 @@ Private items (and legacy rows with no visibility and `is_public = 0`) answer
@@ -477,7 +480,7 @@ Private items (and legacy rows with no visibility and `is_public = 0`) answer
exactly like a missing id — the same 404 and body — unless the request bears the
exactly like a missing id — the same 404 and body — unless the request bears the
owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds
owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds
for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect
for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect
-and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)
+and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`)
apply it to an app acting for someone other than the owner. A call acting for a user writes
apply it to an app acting for someone other than the owner. A call acting for a user writes
only that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):
only that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):
someone else's private one is the missing answer, anything else 403. The media index lists a
someone else's private one is the missing answer, anything else 403. The media index lists a
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"media.lifecycle.transition"
"media.lifecycle.transition"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
- "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's paste API is read-only and every write answers 410 pastes.moved, /p/:slug 301s to Community and Media serves screenshot bytes only (the read-only API is being retired, T10 step 2)",
+ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted), /p/:slug 301s to Community and Media serves screenshot bytes only",
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.",
"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.",
"features": [
"features": [
@@ -4,7 +4,9 @@ Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one
@@ -4,7 +4,9 @@ Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one
platform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited
platform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited
`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typed
`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typed
projection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on
projection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on
-2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only. Every existing URL and v1 response is
+2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only, and
+its v1 pastes API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted; only the public
+`/p/:slug` routes remain). Other existing URLs and v1 responses are
unchanged. New code talks to objects through the [v2 API](#object-api-v2).
unchanged. New code talks to objects through the [v2 API](#object-api-v2).
Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`, `invariant.js`,
Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`, `invariant.js`,
@@ -119,7 +121,7 @@ after the outcome event.
@@ -119,7 +121,7 @@ after the outcome event.
| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction |
| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction |
| file upload | `db.createFile` |
| file upload | `db.createFile` |
| thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |
| thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |
-| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API is read-only and writes answer 410 `pastes.moved` |
+| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted) |
| avatar ingest | `avatars/ingest` |
| avatar ingest | `avatars/ingest` |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |
@@ -505,11 +507,11 @@ While any hold applies:
@@ -505,11 +507,11 @@ While any hold applies:
- **No delete path works.**
- **No delete path works.**
- v2 `DELETE` answers 409.
- v2 `DELETE` answers 409.
- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (paste deletes answer 410 `pastes.moved` for every paste since the move).
+ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2, so a paste row is only ever deleted by SQL, which the trigger refuses).
- Quarantine cleanup and the boot junk sweep skip the object.
- Quarantine cleanup and the boot junk sweep skip the object.
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- `deleteVodObjects` keeps the bytes.
- `deleteVodObjects` keeps the bytes.
- - Paste screenshot removal no longer exists (the paste API is read-only since pastes moved to OpenVibe.Community).
+ - Paste screenshot removal is only the burn-after-read path in `server/pastes/storage.js`, which skips a held row.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
@@ -105,15 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
@@ -105,15 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
-echo "-- pastes: read-only (OpenVibe.Community owns pastes)"
-R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
+echo "-- pastes: API retired (OpenVibe.Community owns pastes), public routes still serve"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"title":"Smoke paste","content":"hello from the smoke test"}')
-d '{"title":"Smoke paste","content":"hello from the smoke test"}')
-check "paste create → pastes.moved" "pastes.moved" "$(jsonget "$R" code)"
+check "paste create → 404 (no route)" "404" "$CODE"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
-check "screenshot paste create → 410" "410" "$CODE"
+check "screenshot paste create → 404" "404" "$CODE"
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")
-check "paste list still reads" "200" "$CODE"
+check "paste list → 404" "404" "$CODE"
echo "-- admin storage: overview + vod listing"
echo "-- admin storage: overview + vod listing"
R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
@@ -125,7 +125,7 @@ router.get('/', async (req, res) => {
@@ -125,7 +125,7 @@ router.get('/', async (req, res) => {
`, [req.appId]) || {};
`, [req.appId]) || {};
const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
// Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and
// Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and
- // the read-only paste API is being retired (T10 step 2); the count stays
+ // the read-only paste API was retired (T10 step 2); the count stays
// while the table exists (it feeds /api/v2/me and operator stats).
// while the table exists (it feeds /api/v2/me and operator stats).
const pasteStats = await db.get(`
const pasteStats = await db.get(`
SELECT COUNT(*) AS count,
SELECT COUNT(*) AS count,
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same
// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same
-// system as every other picture (deletable, reportable, served with range/caching by the paste routes).
+// system as every other picture (reportable, served with range/caching by the public /p/:slug/screenshot
+// route). The paste API was retired in T10 step 2, so Media has no route that deletes them.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const dns = require('dns').promises;
const dns = require('dns').promises;
const https = require('https');
const https = require('https');
@@ -34,7 +35,7 @@ const egress = require('openvibe-shared/egress');
@@ -34,7 +35,7 @@ const egress = require('openvibe-shared/egress');
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
// Where avatars land and how their slug is minted: the paste storage module
// Where avatars land and how their slug is minted: the paste storage module
-// (server/pastes/storage.js), not the paste router. The defaults keep the exact
+// (server/pastes/storage.js), a leaf with no router. The defaults keep the exact
// persisted path and slug behavior; tests may inject their own.
// persisted path and slug behavior; tests may inject their own.
const pasteStorage = require('../pastes/storage');
const pasteStorage = require('../pastes/storage');
@@ -110,7 +110,7 @@ const ready = (async () => {
@@ -110,7 +110,7 @@ const ready = (async () => {
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)
// route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)
- // from server/pastes/storage.js itself — nothing from the paste router is passed in.
+ // from server/pastes/storage.js itself — a leaf, not a router.
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
}
}
@@ -201,7 +201,6 @@ const ready = (async () => {
@@ -201,7 +201,6 @@ const ready = (async () => {
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
- app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
app.use('/api/v1/:app/assets', require('./assets/routes'));
app.use('/api/v1/:app/assets', require('./assets/routes'));
deleted file mode 100644
deleted file mode 100644
@@ -1,259 +0,0 @@
@@ -1,259 +0,0 @@
-/**
- * OpenVibe.Media — Pastes API, read-only (mounted at /api/v1/:app/pastes)
- *
- * OpenVibe.Community owns pastes since the 2026-09-22 cutover (PASTES_MOVED_TO).
- * This read-only surface is being retired (T10 step 2): nothing calls the app
- * API any more, so the router and its GET handlers go; the `pastes` table and
- * the screenshot leaves below stay until Pics owns the bytes. Media keeps the
- * rows and answers reads for anything still pointed here; public pages live at
- * /p/:slug (HTML) and /p/:slug/raw.
- *
- * GET / list (?limit&offset&type&search&user_id&include_unlisted&sort)
- * GET /config paste limits (+ the caller's count today)
- * GET /:slug paste meta/content
- * GET /:slug/comments list comments (+replies)
- *
- * Every other method answers 410 { code: 'pastes.moved' }.
- */
-'use strict';
-
-const express = require('express');
-const fs = require('fs');
-const db = require('../db/database');
-const { tenantAuth, tenantCors } = require('../auth');
-
-const router = express.Router({ mergeParams: true });
-router.use(tenantCors);
-// Pastes moved to OpenVibe.Community: no writes here, for any app. The caller is
-// authenticated first, so an anonymous write still answers 401 like the reads.
-const writeAuth = tenantAuth({ allowUser: true });
-router.use((req, res, next) => {
- if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
- writeAuth(req, res, () => res.status(410).json({ error: 'Pastes moved to OpenVibe.Community', code: 'pastes.moved' }));
-});
-
-// Screenshot directory, shared with the avatar ingest (server/pastes/storage.js).
-const { SCREENSHOTS_DIR } = require('./storage');
-
-// ── Helpers ─────────────────────────────────────────────────
-
-async function getPasteConfig() {
- return {
- maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,
- screenshotMaxSizeMb: Number(await db.getSetting('paste_screenshot_max_size_mb')) || 8,
- cooldownSeconds: Number(await db.getSetting('paste_cooldown_seconds')) || 30,
- maxPerUserPerDay: Number(await db.getSetting('paste_max_per_user_per_day')) || 0,
- };
-}
-
-function screenshotUrl(paste) {
- return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;
-}
-
-function pastePublic(paste) {
- if (!paste) return null;
- return {
- unique_views: paste.unique_views || 0,
-
- id: paste.id,
- app_id: paste.app_id,
- slug: paste.slug,
- user_id: paste.user_id,
- type: paste.type,
- title: paste.title,
- content: paste.content,
- language: paste.language,
- visibility: paste.visibility,
- stream_id: paste.stream_id,
- screenshot_url: screenshotUrl(paste),
- metadata: paste.metadata,
- burn_after_read: !!paste.burn_after_read,
- forked_from: paste.forked_from,
- pinned: !!paste.pinned,
- views: paste.views || 0,
- copies: paste.copies || 0,
- likes: paste.likes || 0,
- is_nsfw: !!paste.is_nsfw,
- ai_summary: paste.ai_summary || null,
- ai_tags: paste.ai_tags || null,
- ai_analyzed_at: paste.ai_analyzed_at || null,
- url: `/p/${paste.slug}`,
- raw_url: `/p/${paste.slug}/raw`,
- created_at: paste.created_at,
- updated_at: paste.updated_at,
- };
-}
-
-// Effective actor for the daily count in GET /config: user-JWT callers are
-// counted by their token; app-key callers name the user in the request.
-function _actor(req) {
- if (req.authType === 'user') return { userId: req.userId, ip: req.ip };
- return { userId: (req.body && req.body.user_id) || null, ip: req.ip };
-}
-
-async function _getPasteScoped(req, res) {
- const slug = String(req.params.slug);
- const paste = await db.getPasteBySlug(slug, req.appId);
- if (!paste) {
- // "Paste not found" was being reported for pastes that demonstrably exist, and the
- // 404 was silent — so there was no way to tell whether the slug was wrong, the
- // tenant scope was wrong, or the row was genuinely missing. Log all three.
- let existsElsewhere = null;
- try {
- const any = await db.get('SELECT app_id FROM pastes WHERE slug = ?', [slug]);
- existsElsewhere = any ? any.app_id : null;
- } catch { /* */ }
- console.warn(`[Pastes] 404 slug="${slug}" appId="${req.appId}" auth=${req.authType || 'none'}` +
- (existsElsewhere ? ` — EXISTS under app_id="${existsElsewhere}" (tenant mismatch)` : ' — no row with that slug'));
- res.status(404).json({ error: 'Paste not found' });
- return null;
- }
- return paste;
-}
-
-// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
-async function removePasteScreenshot(paste) {
- if (!paste) return;
- // A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).
- const objects = require('../objects/model');
- if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;
- if (paste.screenshot_path) {
- try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
- }
- try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
-}
-
-// ── List pastes ─────────────────────────────────────────────
-router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
- const type = req.query.type; // 'paste', 'screenshot', or undefined for all
- const search = req.query.search ? `%${req.query.search}%` : null;
- const userId = req.query.user_id != null ? req.query.user_id : null;
-
- // Unlisted/private pastes are hidden from PUBLIC listings, but their owner must
- // still see them in their own list — otherwise creating an unlisted paste looks
- // exactly like it was never created at all. Only honoured for a caller that can be
- // trusted to be the owner: either an app key (the app gates who may ask) or a user
- // token whose subject matches the user_id being listed.
- const wantMine = req.query.include_unlisted === '1' || req.query.include_unlisted === 'true';
- const ownerView = wantMine && userId != null && (
- req.authType === 'app' || (req.authType === 'user' && String(req.userId) === String(userId))
- );
- const visClause = ownerView
- ? `AND visibility IN ('public', 'unlisted', 'private')`
- : `AND visibility = 'public'`;
-
- let sql = `SELECT * FROM pastes WHERE app_id = ? ${visClause}`;
- const params = [req.appId];
-
- if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }
- if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }
- // Work queue for the owning app's AI pass. Media holds the pastes but has no LLM;
- // the app polls this, analyses, and posts results back to /:slug/ai. App-key only —
- // this deliberately spans visibility, so it must never be reachable by a user token.
- if (req.authType === 'app' && (req.query.needs_ai === '1' || req.query.needs_ai === 'true')) {
- sql = sql.replace(visClause, `AND COALESCE(ai_summary, '') = ''`);
- }
- if (search) { sql += ` AND (title ILIKE ? OR content ILIKE ?)`; params.push(search, search); }
-
- const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';
- sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;
- params.push(limit, offset);
-
- const pastes = (await db.all(sql, params)).map(p => ({
- ...pastePublic(p),
- content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list
- }));
-
- let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? ${visClause}`;
- const countParams = [req.appId];
- if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }
- if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }
- if (search) { countSql += ` AND (title ILIKE ? OR content ILIKE ?)`; countParams.push(search, search); }
- const { total } = await db.get(countSql, countParams);
-
- res.json({ pastes, total, limit, offset });
- } catch (err) {
- console.error('[Pastes] List error:', err.message);
- res.status(500).json({ error: 'Failed to list pastes' });
- }
-});
-
-// ── Paste config / limits (inherited SPA reads this before posting) ──
-// Registered before /:slug so the literal path wins.
-router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const cfg = await getPasteConfig();
- const actor = _actor(req);
- res.json({
- maxSizeKb: cfg.maxSizeKb,
- screenshotMaxSizeMb: cfg.screenshotMaxSizeMb,
- cooldownSeconds: cfg.cooldownSeconds,
- maxPerUserPerDay: cfg.maxPerUserPerDay,
- todayCount: await db.countUserPastesToday(req.appId, actor.userId, actor.ip),
- });
- } catch (err) {
- console.error('[Pastes] Config error:', err.message);
- res.status(500).json({ error: 'Failed to load paste config' });
- }
-});
-
-
-// ── Get single paste by slug ────────────────────────────────
-router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- // Private pastes: owner (or the app itself) only — decided before anything is counted.
- if (paste.visibility === 'private') {
- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
- if (!allowed) return res.status(404).json({ error: 'Paste not found' });
- }
- // The owning app renders the paste page itself — count the view here (real client
- // IP via X-Forwarded-For, viewer via the "user" JWT / X-OV-User-Id) unless it says
- // this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
- if (String(req.query.no_view || '') !== '1' && !paste.burn_after_read) {
- try { const r = await require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
- }
-
-
- paste.liked = req.userId != null ? await db.hasUserLikedPaste(paste.id, req.userId) : false;
- res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
- } catch (err) {
- console.error('[Pastes] Get error:', err.message);
- res.status(500).json({ error: 'Failed to get paste' });
- }
-});
-
-// ═════════════════════════════════════════════════════════════
-// ── Paste Comments (supports anonymous via app callers) ─────
-// ═════════════════════════════════════════════════════════════
-
-router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
-
- const limit = Math.min(parseInt(req.query.limit || '50'), 100);
- const offset = parseInt(req.query.offset || '0');
-
- const comments = await db.getPasteComments(paste.id, limit, offset);
- const total = await db.getPasteCommentCount(paste.id);
-
- for (const c of comments) {
- c.replies = await db.getPasteCommentReplies(c.id);
- c.reply_count = c.replies.length;
- }
-
- res.json({ comments, total });
- } catch (err) {
- console.error('[PasteComments] List error:', err.message);
- res.status(500).json({ error: 'Failed to load comments' });
- }
-});
-
-module.exports = router;
-module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
-module.exports.removePasteScreenshot = removePasteScreenshot;
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
'use strict';
'use strict';
-// Paste storage plumbing, shared by the paste routes and the avatar ingest (which
-// stores every avatar as an unlisted screenshot paste): where a paste's screenshot
-// bytes land, and how a slug is minted (checked against the pastes table, so two
-// writes never get the same one). A leaf module — requiring it pulls in neither the
-// paste router nor anything that mounts it.
+// Paste storage plumbing, shared by the public paste routes and the avatar ingest
+// (which stores every avatar as an unlisted screenshot paste): where a paste's
+// screenshot bytes land, how a slug is minted (checked against the pastes table, so
+// two writes never get the same one), and how a burn-after-read screenshot is
+// removed. A leaf module — requiring it pulls in neither a router nor anything that
+// mounts one.
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -63,4 +64,16 @@ async function generateSlug(attempts = 0) {
@@ -63,4 +64,16 @@ async function generateSlug(attempts = 0) {
return slug;
return slug;
}
}
-module.exports = { SCREENSHOTS_DIR, generateSlug };
+// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
+// A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).
+async function removePasteScreenshot(paste) {
+ if (!paste) return;
+ const objects = require('../objects/model');
+ if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;
+ if (paste.screenshot_path) {
+ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
+ }
+ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
+}
+
+module.exports = { SCREENSHOTS_DIR, generateSlug, removePasteScreenshot };
@@ -548,7 +548,7 @@ router.get('/p/:slug', optionalIdentity, async (req, res) => {
@@ -548,7 +548,7 @@ router.get('/p/:slug', optionalIdentity, async (req, res) => {
// Burn-after-read: allow one non-owner read, then delete.
// Burn-after-read: allow one non-owner read, then delete.
if (paste.burn_after_read && !isOwner && paste.views > 1) {
if (paste.burn_after_read && !isOwner && paste.views > 1) {
- await require('../pastes/routes').removePasteScreenshot(paste);
+ await require('../pastes/storage').removePasteScreenshot(paste);
await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
return res.status(410).send('This paste has been burned after reading.');
return res.status(410).send('This paste has been burned after reading.');
}
}
@@ -49,12 +49,10 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -49,12 +49,10 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
// ── Shared paste storage: the screenshots dir and the slug have one source ──
// ── Shared paste storage: the screenshots dir and the slug have one source ──
- // (server/pastes/storage.js — the avatar ingest no longer takes them from the paste router)
- assert.ok(!require.cache[require.resolve('../server/pastes/routes')], 'requiring the avatar ingest does not load the paste routes');
+ // (server/pastes/storage.js — the paste router is gone, the avatar ingest reads the leaf directly)
const storage = require('../server/pastes/storage');
const storage = require('../server/pastes/storage');
const config = require('../server/config');
const config = require('../server/config');
assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');
assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');
- assert.strictEqual(storage.SCREENSHOTS_DIR, require('../server/pastes/routes').SCREENSHOTS_DIR, 'the paste routes share that one directory');
const slugA = await storage.generateSlug();
const slugA = await storage.generateSlug();
const slugB = await storage.generateSlug();
const slugB = await storage.generateSlug();
for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);
for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);
@@ -72,7 +72,6 @@ const sharp = require('sharp');
@@ -72,7 +72,6 @@ const sharp = require('sharp');
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
app.use('/api/v1/:app/files', require('../server/files/routes'));
app.use('/api/v1/:app/files', require('../server/files/routes'));
app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));
app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));
- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
(async () => {
(async () => {
@@ -203,10 +202,10 @@ const sharp = require('sharp');
@@ -203,10 +202,10 @@ const sharp = require('sharp');
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
- // Paste writes moved to OpenVibe.Community: POST /pastes answers 410 and writes nothing.
+ // The read-only paste API is retired (T10 step 2): POST /pastes has no route and writes nothing.
const pastesBefore = [await count('pastes'), await count('media_objects')];
const pastesBefore = [await count('pastes'), await count('media_objects')];
r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
+ assert.strictEqual(r.status, 404, 'no paste-write route remains');
assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');
assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');
// A screenshot paste row (imported before the move) still commits with its object.
// A screenshot paste row (imported before the move) still commits with its object.
const slug = 'shot1';
const slug = 'shot1';
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
-// Pastes moved to OpenVibe.Community: paste writes always answer 410 pastes.moved and reads keep working.
-// With PASTES_MOVED_TO set, the paste page and its text 301 to Community; screenshot bytes are still served here.
+// Pastes moved to OpenVibe.Community: Media's read-only paste API is retired (T10 step 2), so
+// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug page
+// and its text still 301 to Community when PASTES_MOVED_TO is set, and screenshot bytes are served here.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -18,7 +19,6 @@ const express = require('express');
@@ -18,7 +19,6 @@ const express = require('express');
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use(require('../server/public/routes'));
app.use(require('../server/public/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
@@ -28,25 +28,21 @@ const express = require('express');
@@ -28,25 +28,21 @@ const express = require('express');
const get = (p) => fetch(base + p, { redirect: 'manual' });
const get = (p) => fetch(base + p, { redirect: 'manual' });
const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
- // Writes answer 410 for every app, with or without PASTES_MOVED_TO, and create nothing.
+ // The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to
+ // answer 410 and creates nothing, and a read has no route either.
const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;
const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;
const rows = await pasteCount();
const rows = await pasteCount();
let r = await post();
let r = await post();
- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
- assert.strictEqual(await pasteCount(), rows, 'a refused write creates no paste');
- // The caller is authenticated before the 410: an anonymous write answers 401, like the reads.
- r = await fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
- assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.status, 404, 'no paste-write route remains');
+ r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
+ assert.strictEqual(r.status, 404, 'no paste-read route remains');
+ assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');
// Before the redirect switch: the pages are served here.
// Before the redirect switch: the pages are served here.
assert.strictEqual((await get('/p/text-1')).status, 200);
assert.strictEqual((await get('/p/text-1')).status, 200);
assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');
assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');
process.env.PASTES_MOVED_TO = 'https://openvibe.community/';
process.env.PASTES_MOVED_TO = 'https://openvibe.community/';
- r = await post();
- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
- r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
- assert.strictEqual(r.status, 200, 'reads still work');
r = await get('/p/text-1');
r = await get('/p/text-1');
assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1');
assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1');
r = await get('/p/text-1/raw');
r = await get('/p/text-1/raw');
@@ -63,7 +63,6 @@ const http = require('http');
@@ -63,7 +63,6 @@ const http = require('http');
app.use(express.json());
app.use(express.json());
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use('/o', require('../server/objects/routes').publicRouter);
app.use('/o', require('../server/objects/routes').publicRouter);
app.use('/', require('../server/public/routes'));
app.use('/', require('../server/public/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
@@ -182,9 +181,11 @@ const http = require('http');
@@ -182,9 +181,11 @@ const http = require('http');
const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;
const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;
const before = await views();
const before = await views();
- await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');
+ // The read-only paste API is retired (T10 step 2): the v1 path is unmounted for everyone,
+ // so it cannot confirm a private paste exists either.
+ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');
+ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(77))).status, 404);
assert.strictEqual(await views(), before, 'a refused read counts no view');
assert.strictEqual(await views(), before, 'a refused read counts no view');
- assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);
console.log('✅ v1 detail: acting non-owners get the missing answer');
console.log('✅ v1 detail: acting non-owners get the missing answer');
server.close();
server.close();
@@ -67,7 +67,6 @@ const http = require('http');
@@ -67,7 +67,6 @@ const http = require('http');
app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use('/api/v2/:app/objects', require('../server/objects/routes'));
app.use('/api/v2/:app/objects', require('../server/objects/routes'));
(async () => {
(async () => {
@@ -173,17 +172,17 @@ const http = require('http');
@@ -173,17 +172,17 @@ const http = require('http');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
- // ── A held screenshot paste: paste writes moved to OpenVibe.Community, so deletes answer 410 ──
+ // ── A held screenshot paste: the v1 paste API is retired (T10 step 2), so the hold is
+ // enforced by the delete trigger alone — the row, its bytes and its object stay. ──
const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
- r = await call('DELETE', '/api/v1/live/pastes/shot1');
- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
- r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });
- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved'], 'bulk delete is gone too');
+ await assert.rejects(async () => await db.run("DELETE FROM pastes WHERE slug = 'shot1'"), /retention hold/, 'row SQL is refused by the trigger');
+ const probe = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/live/pastes/shot1`, { method: 'DELETE', headers: { authorization: 'Bearer live-key' }, redirect: 'manual' });
+ assert.strictEqual(probe.status, 404, 'the paste API has no delete route any more');
assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
const shotObj = await model.getObject(shot.object_id);
const shotObj = await model.getObject(shot.object_id);
assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');
assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');
- console.log('✅ a held screenshot paste: DELETE and bulk delete answer 410 pastes.moved; row, bytes and object stay');
+ console.log('✅ a held screenshot paste: the delete trigger refuses it; row, bytes and object stay');
// ── Listing and release ──
// ── Listing and release ──
r = await call('GET', H);
r = await call('GET', H);