openvibers/openvibe.media Commits
Media: /p/:slug and /p/:slug/raw are unconditional 301s to Community; the local paste row-serving is deleted (T4/T10). (#46)
Changes
@@@ -29,8 +29,9 @@ MEDIA_DATA_DIR=./dataVOD_PATH=./data/vodsCLIPS_PATH=./data/clipsPASTES_PATH=./data/pastes--# Where pastes live now (2026-09-22): /p/:slug and /p/:slug/raw 301 there when set. Stored screenshot--# bytes under PASTES_PATH/screenshots are still served; every write to the paste API answers 410.++# Where pastes live now (2026-09-22): /p/:slug and /p/:slug/raw are 301s there, always — the default++# below applies when unset. Stored screenshot bytes under PASTES_PATH/screenshots are still served,++# and every write to the paste API answers 404.#PASTES_MOVED_TO=https://openvibe.communityTHUMBNAILS_PATH=./data/thumbnailsFILES_PATH=./data/files@@@ -7,8 +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 read-only paste API is gone (T10 step 2) and it serves only the--frozen rows and old screenshots ([Pastes](#pastes)).++on 2026-09-22; Media's read-only paste API is gone (T10 step 2), `/p/:slug` only++redirects there and it serves the frozen rows' old screenshots ([Pastes](#pastes)).Implements **Media API v1** from `../CONTRACTS.md`.@@@ -288,23 +288,24 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed everyread-only paste app API was retired in T10 step 2 — `/api/v1/:app/pastes` isunmounted, so every method there answers 404. No paste is created, read, listed ordeleted 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, and--new screenshots are uploaded to the token-only `community` tenant.++`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the target++is `config.pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`,++so the redirect never depends on an env var) and no row is read — a private or++missing slug redirects exactly like a public one. Screenshot bytes are still served++from here, and new screenshots are uploaded to the token-only `community` tenant.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.`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).++and writes screenshots into the same directory.| method | path | notes ||---|---|---|--| 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) |++| GET | `/p/:slug` | always 301 to OpenVibe.Community — pastes live there; no row is read |++| GET | `/p/:slug/raw` | always 301 to Community's `/p/:slug/raw`; the suffix is preserved |++| GET | `/p/:slug/screenshot` | frozen screenshot bytes; unknown/private slugs 301 to Community |AI summary/tags columns remain for imported rows.@@@ -493,8 +494,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.|---|---|| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) || `GET /c/:id` | clip playback, same logic, `noindex` |--| `GET /p/:slug` | server-rendered paste HTML page; with `PASTES_MOVED_TO` set (production since 2026-09-22) a 301 to OpenVibe.Community, the canonical home for pastes |--| `GET /p/:slug/raw` | `text/plain` |++| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |++| `GET /p/:slug/raw` | **301** to Community's raw text || `GET /p/:slug/screenshot` | paste screenshot image || `GET /t/:id` | thumbnail (id = filename), `noindex` || `GET /f/:key` | file with stored Content-Type + Range, `noindex` |@@@ -20,7 +20,7 @@"media.lifecycle.transition"],"capabilitiesRegistered": true,-- "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",++ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO, default https://openvibe.community); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted) and /p/:slug and /p/:slug/raw are unconditional 301s there (no row read), so Media serves only the frozen rows' screenshot bytes","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": [@@@ -511,7 +511,7 @@ While any hold applies:- 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 is only the burn-after-read path in `server/pastes/storage.js`, which skips a held row.++ - A paste screenshot has no removal path left at all: the paste API and, with it, the burn-after-read delete went in T10.- 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,7 +105,7 @@ 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: API retired (OpenVibe.Community owns pastes), public routes still serve"++echo "-- pastes: API retired (OpenVibe.Community owns pastes), /p/:slug redirects there"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 → 404 (no route)" "404" "$CODE"@@@ -114,6 +114,9 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes"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 → 404" "404" "$CODE"++PHDRS=$(curl -s -D - -o /dev/null "$BASE/p/any-slug-at-all")++echo "$PHDRS" | head -1 | grep -q ' 301 ' && ok "GET /p/:slug → 301 to Community" || bad "GET /p/:slug ($(echo "$PHDRS" | head -1))"++echo "$PHDRS" | grep -qi "^location: https://openvibe.community/p/any-slug-at-all" && ok "/p/:slug Location names Community" || bad "/p/:slug Location"echo "-- admin storage: overview + vod listing"R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")@@@ -40,6 +40,10 @@ const config = {},pastes: {path: process.env.PASTES_PATH || './data/pastes',++ // OpenVibe.Community is the paste authority (2026-09-22): Media's /p/:slug and++ // /p/:slug/raw only redirect there. The origin of Community's paste URLs; the++ // default keeps the redirect working with no env var set.++ movedTo: String(process.env.PASTES_MOVED_TO || 'https://openvibe.community').replace(/\/+$/, ''),},assets: {path: process.env.ASSETS_PATH || './data/assets',@@@ -1,10 +1,9 @@'use strict';--// Paste storage plumbing, shared by the public paste routes and the avatar ingest++// Paste storage plumbing, shared by the public screenshot route 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.++// 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 a router nor anything that mounts one.const fs = require('fs');const path = require('path');const crypto = require('crypto');@@@ -64,16 +63,4 @@ async function generateSlug(attempts = 0) {return slug;}--// 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 };++module.exports = { SCREENSHOTS_DIR, generateSlug };@@@ -225,8 +225,8 @@ function page({ seo, css = '', body, history, footer }) {footer: footerOptions(footer),});// The shell picks the card from whether a large image is present; a page can ask for the small-- // card explicitly (the paste viewer, a screenshot-less paste). seo.headTags has no override, so-- // swap the one tag it emitted after the fact.++ // card explicitly (the account pages). seo.headTags has no override, so swap the one tag it++ // emitted after the fact.if (seo.twitterCard && seo.twitterCard !== 'summary_large_image') {return html.replace('<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:card" content="${esc(seo.twitterCard)}">`);}@@@ -7,9 +7,8 @@* instead: player, title, source link and full SEO. wantsHtmlPage() draws* that line; ?raw=1 always yields the bytes.*-- * /p/:slug is a thin viewer. Pastes are canonical on OpenVibe.Community,-- * which renders them from this API, so the page here points there-- * (canonical + noindex) and stays useful for direct links.++ * /p/:slug has no page here: pastes live on OpenVibe.Community and the++ * route is a 301 (see public/routes.js).*/'use strict';@@@ -189,77 +188,4 @@ function renderWatchPage(kind, record, readiness = null) {});}--/** /p/:slug — thin viewer; the page's home is OpenVibe.Community. */--function renderPastePage(paste) {-- const slug = String(paste.slug);-- const title = snip(paste.title || 'Untitled', 120);-- const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;-- const self = `${config.publicUrl}/p/${encodeURIComponent(slug)}`;-- const communityUrl = `${appUrl('community')}/p/${encodeURIComponent(slug)}`;-- const screenshotUrl = `${self}/screenshot`;-- const language = paste.language || 'text';-- const description = snip(paste.ai_summary || (isScreenshot ? (paste.content || `Screenshot "${title}" shared on the OpenVibe network.`) : (paste.content || `${language} paste shared on the OpenVibe network.`)), 200);-- const created = isoDate(paste.created_at);-- const updated = isoDate(paste.updated_at) || created;-- const tags = (() => { try { const t = JSON.parse(paste.ai_tags || '[]'); return Array.isArray(t) ? t.map(String).slice(0, 12) : []; } catch { return String(paste.ai_tags || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); } })();---- const ld = isScreenshot-- ? {-- '@context': 'https://schema.org', '@type': 'ImageObject',-- name: title, description, url: communityUrl, contentUrl: screenshotUrl, mainEntityOfPage: communityUrl,-- ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),-- ...(tags.length ? { keywords: tags.join(', ') } : {}),-- publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },-- }-- : {-- '@context': 'https://schema.org', '@type': 'Article',-- headline: title, description, url: communityUrl, mainEntityOfPage: communityUrl,-- ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),-- ...(tags.length ? { keywords: tags.join(', ') } : {}),-- genre: language === 'text' ? 'Text paste' : `${language} code paste`,-- isAccessibleForFree: true,-- hasPart: { '@type': 'CreativeWork', name: title, encodingFormat: 'text/plain', url: `${self}/raw` },-- publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },-- };---- const content = isScreenshot-- ? `<figure class="shot"><img src="${esc(screenshotUrl)}" alt="${esc(title)}" loading="lazy"></figure>-- ${paste.content ? `<p class="desc">${esc(paste.content)}</p>` : ''}`-- : `<pre class="code" data-language="${esc(language)}"><code>${esc(paste.content || '')}</code></pre>`;---- const metaBits = [-- esc(language),-- `${Number(paste.views || 0).toLocaleString('en-US')} views`,-- paste.unique_views != null ? `${Number(paste.unique_views).toLocaleString('en-US')} unique` : null,-- fmtDate(paste.created_at) || null,-- isScreenshot ? null : `<a href="${esc(`/p/${encodeURIComponent(slug)}/raw`)}">raw</a>`,-- ].filter(Boolean);---- const body = `-- <p class="note">This paste lives on <a href="${esc(communityUrl)}">OpenVibe.Community</a> — comments, likes and forks are there. This is the plain viewer.</p>-- <h1>${esc(title)}</h1>-- <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>-- ${content}-- <div class="actions"><a class="btn" href="${esc(communityUrl)}">Open on OpenVibe.Community</a></div>`;---- const css = `-- .code { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; margin: 0 0 1rem; }-- .shot { margin: 0 0 1rem; }-- .shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;---- return pageFrame.page({-- seo: {-- title: `${title} — ${SITE_NAME}`, description,-- // Canonical on Community: search engines index the paste there, not here.-- canonical: communityUrl, robots: 'noindex, follow',-- image: isScreenshot ? screenshotUrl : undefined, ogType: 'article',-- twitterCard: isScreenshot ? 'summary_large_image' : 'summary',-- jsonLd: [ld],-- },-- css, body,-- history: { type: 'paste', title },-- footer: { variant: 'compact' },-- });--}----module.exports = { wantsHtmlPage, renderWatchPage, renderPastePage, sourcePage, watchIndexable, isAiClip };++module.exports = { wantsHtmlPage, renderWatchPage, sourcePage, watchIndexable, isAiClip };@@@ -7,8 +7,8 @@* NAVIGATING here gets a watch page instead (see* pages.js — ?raw=1 always yields the bytes).* GET /c/:id clip playback (same tiering/range logic + watch page)-- * GET /p/:slug paste viewer — canonical on OpenVibe.Community-- * GET /p/:slug/raw paste raw text++ * GET /p/:slug 301 to OpenVibe.Community — pastes live there now++ * GET /p/:slug/raw 301 to OpenVibe.Community's raw text* GET /p/:slug/screenshot paste screenshot image* GET /t/:id thumbnails (id = filename)* GET /f/:key files with correct Content-Type + Range@@@ -17,8 +17,8 @@* the watch pages Media is canonical for (public, not Live's, not AI clips)** /v /c /t /f bytes carry X-Robots-Tag: noindex — the owning app has the-- * canonical page (the watch page says so with rel=canonical). The paste-- * viewer is noindex too: Community owns that URL now.++ * canonical page (the watch page says so with rel=canonical). Paste pages++ * redirect away: Community owns that URL now.*/'use strict';@@@ -27,6 +27,7 @@ const ovServe = require('openvibe-shared/serve');const cache = require('openvibe-shared/cache-policy');const path = require('path');const fs = require('fs');++const config = require('../config');const db = require('../db/database');const tools = require('../vod/media-tools');const { optionalIdentity } = require('../auth');@@@ -521,74 +522,16 @@ router.get('/f/:key', async (req, res) => {// ── Pastes ───────────────────────────────────────────────────--// The page itself is rendered in pages.js (thin viewer; canonical on Community).--// Pastes moved to OpenVibe.Community (roadmap Wave 5, PASTES_MOVED_TO): the page and its text are--// Community's now, so send people there. Screenshot bytes keep being served from here.--const movedTo = () => String(process.env.PASTES_MOVED_TO || '').replace(/\/$/, '');++// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22). Media holds the++// frozen rows but serves nothing from them for these two routes: every slug — found, missing++// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw). Screenshot++// bytes keep being served from here.++const movedTo = () => config.pastes.movedTo;++const communityPasteUrl = (slug, suffix = '') => `${movedTo()}/p/${encodeURIComponent(String(slug))}${suffix}`;--router.get('/p/:slug', optionalIdentity, async (req, res) => {-- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(req.params.slug)}`);-- try {-- const paste = await db.getPasteBySlug(String(req.params.slug));-- if (!paste) return res.status(404).send('Paste not found');---- // Unlisted pastes stay reachable by direct link (that's the point).-- // Private pastes: owning app/user only.-- if (paste.visibility === 'private' && !canAccessPrivate(paste, req)) {-- return res.status(404).send('Paste not found');-- }---- // Count the view (visit-based, cooldown, owner excluded). Burn-after-read pastes-- // keep the literal every-read counter — one read is the whole point of them.-- const isOwner = req.userId != null && paste.user_id === req.userId;-- if (!isOwner) {-- if (paste.burn_after_read) { await db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]); paste.views += 1; }-- else { const r = await views.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; } }-- }---- // Burn-after-read: allow one non-owner read, then delete.-- if (paste.burn_after_read && !isOwner && paste.views > 1) {-- 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.');-- }---- res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- res.type('html').send(pages.renderPastePage(paste));-- } catch (err) {-- console.error('[Public] /p error:', err.message);-- res.status(500).send('Error');-- }--});++router.get('/p/:slug', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug)));--router.get('/p/:slug/raw', async (req, res) => {-- try {-- const found = await db.getPasteBySlug(String(req.params.slug));-- // A private paste answers exactly like a missing slug (no redirect that proves it exists).-- const paste = found && found.visibility !== 'private' ? found : null;-- // Image pastes have no raw text — bounce to the screenshot (stale-- // consumers stored /raw URLs for hero-moment images).-- if (paste && paste.type === 'screenshot') {-- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));-- return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);-- }-- // Moved: every slug goes to Community, found or not, like /p/:slug.-- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/raw`);-- if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');---- // Burn after read-- if (paste.burn_after_read && paste.views > 0) {-- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);-- return res.status(410).send('This paste has been burned after reading.');-- }---- if (paste.burn_after_read) await db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);-- else await views.recordView('paste', paste.id, { req, ownerUserId: paste.user_id });-- res.type('text/plain').send(paste.content);-- } catch {-- res.status(500).send('Error');-- }--});++router.get('/p/:slug/raw', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug, '/raw')));router.get('/p/:slug/screenshot', async (req, res) => {if (drill.refuseBytes(res)) return;@@@ -437,32 +437,6 @@ async function deleteObject(provider, key) {return true;}--/**-- * Best-effort removal of a paste's legacy B2 screenshot object(s). Legacy paste-- * screenshots live in B2 under `community.attachments/objects/<date>/…paste--- * screenshot-<pasteId>.<ext>` (date-partitioned, key not stored in the DB), so we-- * list the prefix and delete anything matching the paste id. Silent no-op if B2 is off.-- */--async function deleteLegacyPasteScreenshot(pasteId) {-- const client = clientFor('b2');-- if (!client || !pasteId) return;-- const marker = `paste-screenshot-${pasteId}.`;-- try {-- let token;-- do {-- const r = await client.send(new S3.ListObjectsV2Command({-- Bucket: PROVIDER_ENV.b2.bucket, Prefix: 'community.attachments/', ContinuationToken: token, MaxKeys: 1000,-- }));-- for (const o of (r.Contents || [])) {-- if (o.Key.split('/').pop().includes(marker)) await deleteObject('b2', o.Key);-- }-- token = r.IsTruncated ? r.NextContinuationToken : null;-- } while (token);-- } catch (err) {-- console.warn(`[VodStorage] Legacy paste screenshot cleanup failed for paste ${pasteId}:`, err.message);-- }--}--/** Every object under `prefix` in one provider's bucket: [{ key, size, last_modified }] (read-only). */async function listObjects(provider, prefix) {const client = clientFor(provider);@@@ -1593,7 +1567,6 @@ module.exports = {recordTierDecision,deleteVodObjects,deleteObject,-- deleteLegacyPasteScreenshot,presignGet,runSweep,reconcileGhosts,@@@ -588,17 +588,6 @@"page /"]},-- {-- "method": "GET",-- "path": "/p/n1paste",-- "auth": "anon",-- "accept": "html",-- "status": 200,-- "kind": "html",-- "from": [-- "page /"-- ]-- },{"method": "GET","path": "/updates",@@@ -703,17 +692,6 @@"page /c/1"]},-- {-- "method": "GET",-- "path": "/p/n1paste/raw",-- "auth": "anon",-- "accept": "html",-- "status": 200,-- "kind": "text",-- "from": [-- "page /p/n1paste"-- ]-- },{"method": "GET","path": "/shared/shipped.js?v=18e4e7ae4ec3",@@@ -90,7 +90,8 @@ module.exports = {],forms: true,keep: () => true,-- crawl: ['/', '/browse', '/v/1', '/c/1', '/p/n1paste', '/me', '/updates'],++ // /p/:slug left the crawl on 2026-10-07: Media only 301s pastes to OpenVibe.Community now (T4/T10).++ crawl: ['/', '/browse', '/v/1', '/c/1', '/me', '/updates'],origins: ['https://openvibe.media'],/** Values for template expressions, first match wins (the rows SEED writes). */samples: [@@@ -1,7 +1,9 @@'use strict';// 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.++// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug and++// /p/:slug/raw routes are unconditional 301s to Community — no env var needed (the default is++// config.pastes.movedTo) and no row is read: a known, missing or private slug all go there.++// Screenshot bytes are still served here.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -10,6 +12,7 @@ const http = require('http');const express = require('express');(async () => {++ delete process.env.PASTES_MOVED_TO; // the redirect must not depend on itconst tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));const db = require('../server/db/database');await db.upsertApp({ app_id: 'live', api_key: 'live-key' });@@@ -38,20 +41,26 @@ const express = require('express');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/';++ // The page and its text are Community's: a permanent 301, with no env var set.r = await get('/p/text-1');-- assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/text-1']);r = await get('/p/text-1/raw');-- assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1/raw');-- r = await get('/p/shot-1/raw');-- assert.strictEqual(r.status, 302); assert.strictEqual(r.headers.get('location'), '/p/shot-1/screenshot', 'image pastes keep their local bytes');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/text-1/raw'], 'the raw suffix is preserved');++++ // No row read: a slug that has no row at all — a paste made in Community since the move — and a++ // slug with characters an app could put in a link both redirect the same way.++ r = await get('/p/no-such-slug');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/no-such-slug']);++ r = await get('/p/no-such-slug/raw');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/no-such-slug/raw']);++ r = await get('/p/a%2Fb%20c');++ assert.strictEqual(r.status, 301);++ assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/a%2Fb%20c', 'the slug is URL-encoded, never interpolated raw');++++ // The frozen rows still serve their screenshot bytes (Live/Community still link them).r = await get('/p/shot-1/screenshot');assert.strictEqual(r.status, 200, 'screenshot bytes are still served here');-- assert.ok(await db.getPasteBySlug('text-1'), 'no read deletes anything after the switch');++ assert.ok(await db.getPasteBySlug('text-1'), 'no request deletes anything');server.close();fs.rmSync(tmp, { recursive: true, force: true });@@@ -1,7 +1,7 @@'use strict';// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all--// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug/raw,++// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug,// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts// for one of its users (X-OV-User-Id).const assert = require('assert');@@@ -148,23 +148,18 @@ const http = require('http');assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');console.log('✅ /o: private objects look missing before any lifecycle answer');-- // ── Paste raw: no redirect or answer that singles out a private paste ──-- await same(['/p/priv-text/raw'], ['/p/no-such/raw'], 'private text paste looks missing');-- await same(['/p/priv-shot/raw'], ['/p/no-such/raw'], 'private screenshot paste: no redirect to its screenshot');-- assert.strictEqual((await get('/p/pub-text/raw')).text, 'hello', 'public paste raw still serves');-- process.env.PASTES_MOVED_TO = 'https://community.test';-- r = await get('/p/no-such/raw');-- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/raw'], 'moved: every slug goes to Community');-- r = await get('/p/priv-shot/raw');-- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/raw'], 'moved: a private screenshot is not bounced to /screenshot');++ // ── Paste routes: every slug is a 301 to Community, so private, public and missing answer in++ // exactly the same shape and Media reads no row at all ──++ const redirectsTo = async (p) => { const x = await get(p); assert.strictEqual(x.status, 301, `${p} redirects`); return x.location; };++ assert.strictEqual(await redirectsTo('/p/priv-text'), 'https://openvibe.community/p/priv-text', 'a private paste page redirects like any other slug');++ assert.strictEqual(await redirectsTo('/p/no-such'), 'https://openvibe.community/p/no-such', 'a missing slug goes to Community too');++ assert.strictEqual(await redirectsTo('/p/pub-text'), 'https://openvibe.community/p/pub-text', 'and so does a public one');++ assert.strictEqual(await redirectsTo('/p/priv-text/raw'), 'https://openvibe.community/p/priv-text/raw', 'raw keeps the suffix; nothing singles out a private paste');// /p/:slug/screenshot: a slug Media doesn't hold (pastes made in Community since the move, whose// screenshots hero moments linked here) goes to Community; a private one answers the same way.-- r = await get('/p/no-such/screenshot');-- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/screenshot'], 'moved: an unknown screenshot slug goes to Community');-- r = await get('/p/priv-shot/screenshot');-- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/screenshot'], 'moved: a private screenshot looks like an unknown one');-- delete process.env.PASTES_MOVED_TO;-- console.log('✅ /p/:slug/raw: private looks missing');++ assert.strictEqual(await redirectsTo('/p/no-such/screenshot'), 'https://openvibe.community/p/no-such/screenshot', 'an unknown screenshot slug goes to Community');++ assert.strictEqual(await redirectsTo('/p/priv-shot/screenshot'), 'https://openvibe.community/p/priv-shot/screenshot', 'a private screenshot looks like an unknown one');++ console.log('✅ /p/:slug: private looks missing (every slug redirects)');// ── v1 detail routes when the app acts for a user ──await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');@@@ -1,6 +1,6 @@'use strict';// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),--// the paste viewer's Community canonical, and the OpenVibe Frame + SEO on all of them.++// the paste redirect to Community, and the OpenVibe Frame + SEO on the pages.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -68,28 +68,13 @@ const http = require('http');const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');-- // ── Paste viewer: canonical on Community, noindex, Article JSON-LD ──-- const paste = { slug: 'abc-123', title: 'hello.js', type: 'paste', content: 'console.log("hi")', language: 'javascript', views: 3, unique_views: 2, created_at: '2026-09-01 10:00:00', updated_at: '2026-09-02 10:00:00', ai_tags: '["js","demo"]' };-- const ph = pages.renderPastePage(paste);-- assert.ok(ph.includes('<link rel="canonical" href="https://openvibe.community/p/abc-123">'));-- assert.ok(ph.includes('<meta name="robots" content="noindex, follow">'));-- assert.ok(ph.includes('href="https://openvibe.community/p/abc-123"'), 'header links to the Community page');-- assert.ok(ph.includes('console.log("hi")'));-- assert.ok(ph.includes('"history":{"type":"paste","title":"hello.js"}'));-- const pld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(ph)[1]);-- assert.strictEqual(pld['@type'], 'Article');-- assert.strictEqual(pld.mainEntityOfPage, 'https://openvibe.community/p/abc-123');-- assert.strictEqual(pld.keywords, 'js, demo');-- const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });-- assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));-- assert.ok(shot.includes('"@type":"ImageObject"'));-- assert.ok(!/\bfree\b/i.test(html + ph + shot + pageFrame.baseCss()), 'no cost claims in served HTML');++ // Paste pages are Community's: there is no viewer here (public/routes.js only redirects).++ assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──const express = require('express');const raw = db.getDb();await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 1, 'public', 10) RETURNING id`).run(42, 'live', 'Router VOD', '/nonexistent/vod-42.mp4');-- await raw.prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility) VALUES (?, 'live', 'P', 'paste', 'x', 'text', 'public') RETURNING id`).run('slug1');const app = express();app.use('/', require('../server/public/routes'));const server = app.listen(0);@@@ -115,8 +100,7 @@ const http = require('http');r = await get('/v/42', { accept: 'video/webm,video/ogg,video/*;q=0.9,*/*;q=0.5', 'sec-fetch-dest': 'video' });assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');r = await get('/p/slug1', nav);-- assert.strictEqual(r.status, 200);-- assert.ok(r.body.includes('https://openvibe.community/p/slug1'));++ assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');r = await get('/og-image.png', {});assert.strictEqual(r.status, 200);assert.strictEqual(r.headers['content-type'], 'image/png');@@@ -197,8 +197,14 @@ const crypto = require('crypto');await same([`/c/${privClip}`, { ...h, ...key('games-key') }], ['/c/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /c');await same([`/c/${privClip}`, { ...h, ...asUser(77) }], ['/c/999999', { ...h, ...asUser(77) }], 'another user on /c');}-- await same(['/p/priv-page', nav], ['/p/no-such-paste', nav], 'a private paste page looks missing');-- await same(['/p/priv-page', { ...nav, ...asUser(77) }], ['/p/no-such-paste', { ...nav, ...asUser(77) }], 'a private paste page, for another user');++ // The paste page is a 301 to Community for every slug: a private one is indistinguishable from++ // an unknown one, and no identity changes that — Media reads no row on this route.++ for (const h of [nav, { ...nav, ...asUser(77) }, { ...nav, ...key('games-key') }]) {++ let r = await get('/p/priv-page', h);++ assert.deepStrictEqual([r.status, r.location], [301, 'https://openvibe.community/p/priv-page'], 'a private paste page redirects to Community');++ r = await get('/p/no-such-paste', h);++ assert.deepStrictEqual([r.status, r.location], [301, 'https://openvibe.community/p/no-such-paste'], 'an unknown slug redirects the same way');++ }await same([`/api/v1/games/vods/${pubVod}`, key('games-key')], ['/api/v1/games/vods/999999', key('games-key')], 'another app cannot read this app\'s VOD by id');await same([`/api/v1/games/clips/${privClip}`, key('games-key')], ['/api/v1/games/clips/999999', key('games-key')], 'nor its clip');const projected = await raw.prepare('SELECT id, visibility FROM media_objects WHERE legacy_ref IS NOT NULL AND id IN (SELECT object_id FROM vods WHERE id IN (?, ?) UNION SELECT id FROM media_objects WHERE legacy_ref ILIKE ? OR legacy_ref ILIKE ?)')
@@ -29,8 +29,9 @@ MEDIA_DATA_DIR=./data
@@ -29,8 +29,9 @@ MEDIA_DATA_DIR=./data
VOD_PATH=./data/vods
VOD_PATH=./data/vods
CLIPS_PATH=./data/clips
CLIPS_PATH=./data/clips
PASTES_PATH=./data/pastes
PASTES_PATH=./data/pastes
-# Where pastes live now (2026-09-22): /p/:slug and /p/:slug/raw 301 there when set. Stored screenshot
-# bytes under PASTES_PATH/screenshots are still served; every write to the paste API answers 410.
+# Where pastes live now (2026-09-22): /p/:slug and /p/:slug/raw are 301s there, always — the default
+# below applies when unset. Stored screenshot bytes under PASTES_PATH/screenshots are still served,
+# and every write to the paste API answers 404.
#PASTES_MOVED_TO=https://openvibe.community
#PASTES_MOVED_TO=https://openvibe.community
THUMBNAILS_PATH=./data/thumbnails
THUMBNAILS_PATH=./data/thumbnails
FILES_PATH=./data/files
FILES_PATH=./data/files
@@ -7,8 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
@@ -7,8 +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 read-only paste API is gone (T10 step 2) and it serves only the
-frozen rows and old screenshots ([Pastes](#pastes)).
+on 2026-09-22; Media's read-only paste API is gone (T10 step 2), `/p/:slug` only
+redirects there and it serves the frozen rows' old screenshots ([Pastes](#pastes)).
Implements **Media API v1** from `../CONTRACTS.md`.
Implements **Media API v1** from `../CONTRACTS.md`.
@@ -288,23 +288,24 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
@@ -288,23 +288,24 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
read-only paste app API was retired in T10 step 2 — `/api/v1/:app/pastes` is
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
unmounted, so every method there answers 404. No paste is created, read, listed or
deleted through Media any more.
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, and
-new screenshots are uploaded to the token-only `community` tenant.
+`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the target
+is `config.pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`,
+so the redirect never depends on an env var) and no row is read — a private or
+missing slug redirects exactly like a public one. Screenshot bytes are still served
+from here, and new screenshots are uploaded to the token-only `community` tenant.
Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
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.
`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs
`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).
+and writes screenshots into the same directory.
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
-| 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) |
+| GET | `/p/:slug` | always 301 to OpenVibe.Community — pastes live there; no row is read |
+| GET | `/p/:slug/raw` | always 301 to Community's `/p/:slug/raw`; the suffix is preserved |
+| 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.
@@ -493,8 +494,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
@@ -493,8 +494,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
|---|---|
|---|---|
| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) |
| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) |
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /c/:id` | clip playback, same logic, `noindex` |
-| `GET /p/:slug` | server-rendered paste HTML page; with `PASTES_MOVED_TO` set (production since 2026-09-22) a 301 to OpenVibe.Community, the canonical home for pastes |
-| `GET /p/:slug/raw` | `text/plain` |
+| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
+| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/screenshot` | paste screenshot image |
| `GET /p/:slug/screenshot` | paste screenshot image |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
@@ -20,7 +20,7 @@
@@ -20,7 +20,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 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",
+ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO, default https://openvibe.community); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted) and /p/:slug and /p/:slug/raw are unconditional 301s there (no row read), so Media serves only the frozen rows' screenshot bytes",
"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": [
@@ -511,7 +511,7 @@ While any hold applies:
@@ -511,7 +511,7 @@ While any hold applies:
- 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 is only the burn-after-read path in `server/pastes/storage.js`, which skips a held row.
+ - A paste screenshot has no removal path left at all: the paste API and, with it, the burn-after-read delete went in T10.
- 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,7 +105,7 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
@@ -105,7 +105,7 @@ 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: API retired (OpenVibe.Community owns pastes), public routes still serve"
+echo "-- pastes: API retired (OpenVibe.Community owns pastes), /p/:slug redirects there"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
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 → 404 (no route)" "404" "$CODE"
check "paste create → 404 (no route)" "404" "$CODE"
@@ -114,6 +114,9 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes"
@@ -114,6 +114,9 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes"
check "screenshot paste create → 404" "404" "$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 → 404" "404" "$CODE"
check "paste list → 404" "404" "$CODE"
+PHDRS=$(curl -s -D - -o /dev/null "$BASE/p/any-slug-at-all")
+echo "$PHDRS" | head -1 | grep -q ' 301 ' && ok "GET /p/:slug → 301 to Community" || bad "GET /p/:slug ($(echo "$PHDRS" | head -1))"
+echo "$PHDRS" | grep -qi "^location: https://openvibe.community/p/any-slug-at-all" && ok "/p/:slug Location names Community" || bad "/p/:slug Location"
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")
@@ -40,6 +40,10 @@ const config = {
@@ -40,6 +40,10 @@ const config = {
},
},
pastes: {
pastes: {
path: process.env.PASTES_PATH || './data/pastes',
path: process.env.PASTES_PATH || './data/pastes',
+ // OpenVibe.Community is the paste authority (2026-09-22): Media's /p/:slug and
+ // /p/:slug/raw only redirect there. The origin of Community's paste URLs; the
+ // default keeps the redirect working with no env var set.
+ movedTo: String(process.env.PASTES_MOVED_TO || 'https://openvibe.community').replace(/\/+$/, ''),
},
},
assets: {
assets: {
path: process.env.ASSETS_PATH || './data/assets',
path: process.env.ASSETS_PATH || './data/assets',
@@ -1,10 +1,9 @@
@@ -1,10 +1,9 @@
'use strict';
'use strict';
-// Paste storage plumbing, shared by the public paste routes and the avatar ingest
+// Paste storage plumbing, shared by the public screenshot route and the avatar ingest
// (which stores every avatar as an unlisted screenshot paste): where a paste's
// (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.
+// 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 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');
@@ -64,16 +63,4 @@ async function generateSlug(attempts = 0) {
@@ -64,16 +63,4 @@ async function generateSlug(attempts = 0) {
return slug;
return slug;
}
}
-// 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 };
+module.exports = { SCREENSHOTS_DIR, generateSlug };
@@ -225,8 +225,8 @@ function page({ seo, css = '', body, history, footer }) {
@@ -225,8 +225,8 @@ function page({ seo, css = '', body, history, footer }) {
footer: footerOptions(footer),
footer: footerOptions(footer),
});
});
// The shell picks the card from whether a large image is present; a page can ask for the small
// The shell picks the card from whether a large image is present; a page can ask for the small
- // card explicitly (the paste viewer, a screenshot-less paste). seo.headTags has no override, so
- // swap the one tag it emitted after the fact.
+ // card explicitly (the account pages). seo.headTags has no override, so swap the one tag it
+ // emitted after the fact.
if (seo.twitterCard && seo.twitterCard !== 'summary_large_image') {
if (seo.twitterCard && seo.twitterCard !== 'summary_large_image') {
return html.replace('<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:card" content="${esc(seo.twitterCard)}">`);
return html.replace('<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:card" content="${esc(seo.twitterCard)}">`);
}
}
@@ -7,9 +7,8 @@
@@ -7,9 +7,8 @@
* instead: player, title, source link and full SEO. wantsHtmlPage() draws
* instead: player, title, source link and full SEO. wantsHtmlPage() draws
* that line; ?raw=1 always yields the bytes.
* that line; ?raw=1 always yields the bytes.
*
*
- * /p/:slug is a thin viewer. Pastes are canonical on OpenVibe.Community,
- * which renders them from this API, so the page here points there
- * (canonical + noindex) and stays useful for direct links.
+ * /p/:slug has no page here: pastes live on OpenVibe.Community and the
+ * route is a 301 (see public/routes.js).
*/
*/
'use strict';
'use strict';
@@ -189,77 +188,4 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -189,77 +188,4 @@ function renderWatchPage(kind, record, readiness = null) {
});
});
}
}
-/** /p/:slug — thin viewer; the page's home is OpenVibe.Community. */
-function renderPastePage(paste) {
- const slug = String(paste.slug);
- const title = snip(paste.title || 'Untitled', 120);
- const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;
- const self = `${config.publicUrl}/p/${encodeURIComponent(slug)}`;
- const communityUrl = `${appUrl('community')}/p/${encodeURIComponent(slug)}`;
- const screenshotUrl = `${self}/screenshot`;
- const language = paste.language || 'text';
- const description = snip(paste.ai_summary || (isScreenshot ? (paste.content || `Screenshot "${title}" shared on the OpenVibe network.`) : (paste.content || `${language} paste shared on the OpenVibe network.`)), 200);
- const created = isoDate(paste.created_at);
- const updated = isoDate(paste.updated_at) || created;
- const tags = (() => { try { const t = JSON.parse(paste.ai_tags || '[]'); return Array.isArray(t) ? t.map(String).slice(0, 12) : []; } catch { return String(paste.ai_tags || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); } })();
-
- const ld = isScreenshot
- ? {
- '@context': 'https://schema.org', '@type': 'ImageObject',
- name: title, description, url: communityUrl, contentUrl: screenshotUrl, mainEntityOfPage: communityUrl,
- ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),
- ...(tags.length ? { keywords: tags.join(', ') } : {}),
- publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
- }
- : {
- '@context': 'https://schema.org', '@type': 'Article',
- headline: title, description, url: communityUrl, mainEntityOfPage: communityUrl,
- ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),
- ...(tags.length ? { keywords: tags.join(', ') } : {}),
- genre: language === 'text' ? 'Text paste' : `${language} code paste`,
- isAccessibleForFree: true,
- hasPart: { '@type': 'CreativeWork', name: title, encodingFormat: 'text/plain', url: `${self}/raw` },
- publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
- };
-
- const content = isScreenshot
- ? `<figure class="shot"><img src="${esc(screenshotUrl)}" alt="${esc(title)}" loading="lazy"></figure>
- ${paste.content ? `<p class="desc">${esc(paste.content)}</p>` : ''}`
- : `<pre class="code" data-language="${esc(language)}"><code>${esc(paste.content || '')}</code></pre>`;
-
- const metaBits = [
- esc(language),
- `${Number(paste.views || 0).toLocaleString('en-US')} views`,
- paste.unique_views != null ? `${Number(paste.unique_views).toLocaleString('en-US')} unique` : null,
- fmtDate(paste.created_at) || null,
- isScreenshot ? null : `<a href="${esc(`/p/${encodeURIComponent(slug)}/raw`)}">raw</a>`,
- ].filter(Boolean);
-
- const body = `
- <p class="note">This paste lives on <a href="${esc(communityUrl)}">OpenVibe.Community</a> — comments, likes and forks are there. This is the plain viewer.</p>
- <h1>${esc(title)}</h1>
- <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
- ${content}
- <div class="actions"><a class="btn" href="${esc(communityUrl)}">Open on OpenVibe.Community</a></div>`;
-
- const css = `
- .code { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; margin: 0 0 1rem; }
- .shot { margin: 0 0 1rem; }
- .shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;
-
- return pageFrame.page({
- seo: {
- title: `${title} — ${SITE_NAME}`, description,
- // Canonical on Community: search engines index the paste there, not here.
- canonical: communityUrl, robots: 'noindex, follow',
- image: isScreenshot ? screenshotUrl : undefined, ogType: 'article',
- twitterCard: isScreenshot ? 'summary_large_image' : 'summary',
- jsonLd: [ld],
- },
- css, body,
- history: { type: 'paste', title },
- footer: { variant: 'compact' },
- });
-}
-
-module.exports = { wantsHtmlPage, renderWatchPage, renderPastePage, sourcePage, watchIndexable, isAiClip };
+module.exports = { wantsHtmlPage, renderWatchPage, sourcePage, watchIndexable, isAiClip };
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
* NAVIGATING here gets a watch page instead (see
* NAVIGATING here gets a watch page instead (see
* pages.js — ?raw=1 always yields the bytes).
* pages.js — ?raw=1 always yields the bytes).
* GET /c/:id clip playback (same tiering/range logic + watch page)
* GET /c/:id clip playback (same tiering/range logic + watch page)
- * GET /p/:slug paste viewer — canonical on OpenVibe.Community
- * GET /p/:slug/raw paste raw text
+ * GET /p/:slug 301 to OpenVibe.Community — pastes live there now
+ * GET /p/:slug/raw 301 to OpenVibe.Community's raw text
* GET /p/:slug/screenshot paste screenshot image
* GET /p/:slug/screenshot paste screenshot image
* GET /t/:id thumbnails (id = filename)
* GET /t/:id thumbnails (id = filename)
* GET /f/:key files with correct Content-Type + Range
* GET /f/:key files with correct Content-Type + Range
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
* the watch pages Media is canonical for (public, not Live's, not AI clips)
* the watch pages Media is canonical for (public, not Live's, not AI clips)
*
*
* /v /c /t /f bytes carry X-Robots-Tag: noindex — the owning app has the
* /v /c /t /f bytes carry X-Robots-Tag: noindex — the owning app has the
- * canonical page (the watch page says so with rel=canonical). The paste
- * viewer is noindex too: Community owns that URL now.
+ * canonical page (the watch page says so with rel=canonical). Paste pages
+ * redirect away: Community owns that URL now.
*/
*/
'use strict';
'use strict';
@@ -27,6 +27,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -27,6 +27,7 @@ const ovServe = require('openvibe-shared/serve');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const path = require('path');
const path = require('path');
const fs = require('fs');
const fs = require('fs');
+const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const tools = require('../vod/media-tools');
const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const { optionalIdentity } = require('../auth');
@@ -521,74 +522,16 @@ router.get('/f/:key', async (req, res) => {
@@ -521,74 +522,16 @@ router.get('/f/:key', async (req, res) => {
// ── Pastes ───────────────────────────────────────────────────
// ── Pastes ───────────────────────────────────────────────────
-// The page itself is rendered in pages.js (thin viewer; canonical on Community).
-// Pastes moved to OpenVibe.Community (roadmap Wave 5, PASTES_MOVED_TO): the page and its text are
-// Community's now, so send people there. Screenshot bytes keep being served from here.
-const movedTo = () => String(process.env.PASTES_MOVED_TO || '').replace(/\/$/, '');
+// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22). Media holds the
+// frozen rows but serves nothing from them for these two routes: every slug — found, missing
+// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw). Screenshot
+// bytes keep being served from here.
+const movedTo = () => config.pastes.movedTo;
+const communityPasteUrl = (slug, suffix = '') => `${movedTo()}/p/${encodeURIComponent(String(slug))}${suffix}`;
-router.get('/p/:slug', optionalIdentity, async (req, res) => {
- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(req.params.slug)}`);
- try {
- const paste = await db.getPasteBySlug(String(req.params.slug));
- if (!paste) return res.status(404).send('Paste not found');
-
- // Unlisted pastes stay reachable by direct link (that's the point).
- // Private pastes: owning app/user only.
- if (paste.visibility === 'private' && !canAccessPrivate(paste, req)) {
- return res.status(404).send('Paste not found');
- }
-
- // Count the view (visit-based, cooldown, owner excluded). Burn-after-read pastes
- // keep the literal every-read counter — one read is the whole point of them.
- const isOwner = req.userId != null && paste.user_id === req.userId;
- if (!isOwner) {
- if (paste.burn_after_read) { await db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]); paste.views += 1; }
- else { const r = await views.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; } }
- }
-
- // Burn-after-read: allow one non-owner read, then delete.
- if (paste.burn_after_read && !isOwner && paste.views > 1) {
- 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.');
- }
-
- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- res.type('html').send(pages.renderPastePage(paste));
- } catch (err) {
- console.error('[Public] /p error:', err.message);
- res.status(500).send('Error');
- }
-});
+router.get('/p/:slug', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug)));
-router.get('/p/:slug/raw', async (req, res) => {
- try {
- const found = await db.getPasteBySlug(String(req.params.slug));
- // A private paste answers exactly like a missing slug (no redirect that proves it exists).
- const paste = found && found.visibility !== 'private' ? found : null;
- // Image pastes have no raw text — bounce to the screenshot (stale
- // consumers stored /raw URLs for hero-moment images).
- if (paste && paste.type === 'screenshot') {
- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
- return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);
- }
- // Moved: every slug goes to Community, found or not, like /p/:slug.
- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/raw`);
- if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
-
- // Burn after read
- if (paste.burn_after_read && paste.views > 0) {
- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
- return res.status(410).send('This paste has been burned after reading.');
- }
-
- if (paste.burn_after_read) await db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
- else await views.recordView('paste', paste.id, { req, ownerUserId: paste.user_id });
- res.type('text/plain').send(paste.content);
- } catch {
- res.status(500).send('Error');
- }
-});
+router.get('/p/:slug/raw', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug, '/raw')));
router.get('/p/:slug/screenshot', async (req, res) => {
router.get('/p/:slug/screenshot', async (req, res) => {
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
@@ -437,32 +437,6 @@ async function deleteObject(provider, key) {
@@ -437,32 +437,6 @@ async function deleteObject(provider, key) {
return true;
return true;
}
}
-/**
- * Best-effort removal of a paste's legacy B2 screenshot object(s). Legacy paste
- * screenshots live in B2 under `community.attachments/objects/<date>/…paste-
- * screenshot-<pasteId>.<ext>` (date-partitioned, key not stored in the DB), so we
- * list the prefix and delete anything matching the paste id. Silent no-op if B2 is off.
- */
-async function deleteLegacyPasteScreenshot(pasteId) {
- const client = clientFor('b2');
- if (!client || !pasteId) return;
- const marker = `paste-screenshot-${pasteId}.`;
- try {
- let token;
- do {
- const r = await client.send(new S3.ListObjectsV2Command({
- Bucket: PROVIDER_ENV.b2.bucket, Prefix: 'community.attachments/', ContinuationToken: token, MaxKeys: 1000,
- }));
- for (const o of (r.Contents || [])) {
- if (o.Key.split('/').pop().includes(marker)) await deleteObject('b2', o.Key);
- }
- token = r.IsTruncated ? r.NextContinuationToken : null;
- } while (token);
- } catch (err) {
- console.warn(`[VodStorage] Legacy paste screenshot cleanup failed for paste ${pasteId}:`, err.message);
- }
-}
-
/** Every object under `prefix` in one provider's bucket: [{ key, size, last_modified }] (read-only). */
/** Every object under `prefix` in one provider's bucket: [{ key, size, last_modified }] (read-only). */
async function listObjects(provider, prefix) {
async function listObjects(provider, prefix) {
const client = clientFor(provider);
const client = clientFor(provider);
@@ -1593,7 +1567,6 @@ module.exports = {
@@ -1593,7 +1567,6 @@ module.exports = {
recordTierDecision,
recordTierDecision,
deleteVodObjects,
deleteVodObjects,
deleteObject,
deleteObject,
- deleteLegacyPasteScreenshot,
presignGet,
presignGet,
runSweep,
runSweep,
reconcileGhosts,
reconcileGhosts,
@@ -588,17 +588,6 @@
@@ -588,17 +588,6 @@
"page /"
"page /"
]
]
},
},
- {
- "method": "GET",
- "path": "/p/n1paste",
- "auth": "anon",
- "accept": "html",
- "status": 200,
- "kind": "html",
- "from": [
- "page /"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/updates",
"path": "/updates",
@@ -703,17 +692,6 @@
@@ -703,17 +692,6 @@
"page /c/1"
"page /c/1"
]
]
},
},
- {
- "method": "GET",
- "path": "/p/n1paste/raw",
- "auth": "anon",
- "accept": "html",
- "status": 200,
- "kind": "text",
- "from": [
- "page /p/n1paste"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/shared/shipped.js?v=18e4e7ae4ec3",
"path": "/shared/shipped.js?v=18e4e7ae4ec3",
@@ -90,7 +90,8 @@ module.exports = {
@@ -90,7 +90,8 @@ module.exports = {
],
],
forms: true,
forms: true,
keep: () => true,
keep: () => true,
- crawl: ['/', '/browse', '/v/1', '/c/1', '/p/n1paste', '/me', '/updates'],
+ // /p/:slug left the crawl on 2026-10-07: Media only 301s pastes to OpenVibe.Community now (T4/T10).
+ crawl: ['/', '/browse', '/v/1', '/c/1', '/me', '/updates'],
origins: ['https://openvibe.media'],
origins: ['https://openvibe.media'],
/** Values for template expressions, first match wins (the rows SEED writes). */
/** Values for template expressions, first match wins (the rows SEED writes). */
samples: [
samples: [
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
'use strict';
'use strict';
// Pastes moved to OpenVibe.Community: Media's read-only paste API is retired (T10 step 2), so
// 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.
+// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug and
+// /p/:slug/raw routes are unconditional 301s to Community — no env var needed (the default is
+// config.pastes.movedTo) and no row is read: a known, missing or private slug all go there.
+// Screenshot bytes are still 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');
@@ -10,6 +12,7 @@ const http = require('http');
@@ -10,6 +12,7 @@ const http = require('http');
const express = require('express');
const express = require('express');
(async () => {
(async () => {
+ delete process.env.PASTES_MOVED_TO; // the redirect must not depend on it
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));
const db = require('../server/db/database');
const db = require('../server/db/database');
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
@@ -38,20 +41,26 @@ const express = require('express');
@@ -38,20 +41,26 @@ const express = require('express');
assert.strictEqual(r.status, 404, 'no paste-read route remains');
assert.strictEqual(r.status, 404, 'no paste-read route remains');
assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');
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/';
+ // The page and its text are Community's: a permanent 301, with no env var set.
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.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/text-1']);
r = await get('/p/text-1/raw');
r = await get('/p/text-1/raw');
- assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1/raw');
- r = await get('/p/shot-1/raw');
- assert.strictEqual(r.status, 302); assert.strictEqual(r.headers.get('location'), '/p/shot-1/screenshot', 'image pastes keep their local bytes');
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/text-1/raw'], 'the raw suffix is preserved');
+
+ // No row read: a slug that has no row at all — a paste made in Community since the move — and a
+ // slug with characters an app could put in a link both redirect the same way.
+ r = await get('/p/no-such-slug');
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/no-such-slug']);
+ r = await get('/p/no-such-slug/raw');
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, 'https://openvibe.community/p/no-such-slug/raw']);
+ r = await get('/p/a%2Fb%20c');
+ assert.strictEqual(r.status, 301);
+ assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/a%2Fb%20c', 'the slug is URL-encoded, never interpolated raw');
+
+ // The frozen rows still serve their screenshot bytes (Live/Community still link them).
r = await get('/p/shot-1/screenshot');
r = await get('/p/shot-1/screenshot');
assert.strictEqual(r.status, 200, 'screenshot bytes are still served here');
assert.strictEqual(r.status, 200, 'screenshot bytes are still served here');
- assert.ok(await db.getPasteBySlug('text-1'), 'no read deletes anything after the switch');
+ assert.ok(await db.getPasteBySlug('text-1'), 'no request deletes anything');
server.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.
// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.
// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all
// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all
-// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug/raw,
+// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug,
// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts
// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts
// for one of its users (X-OV-User-Id).
// for one of its users (X-OV-User-Id).
const assert = require('assert');
const assert = require('assert');
@@ -148,23 +148,18 @@ const http = require('http');
@@ -148,23 +148,18 @@ const http = require('http');
assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');
assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');
console.log('✅ /o: private objects look missing before any lifecycle answer');
console.log('✅ /o: private objects look missing before any lifecycle answer');
- // ── Paste raw: no redirect or answer that singles out a private paste ──
- await same(['/p/priv-text/raw'], ['/p/no-such/raw'], 'private text paste looks missing');
- await same(['/p/priv-shot/raw'], ['/p/no-such/raw'], 'private screenshot paste: no redirect to its screenshot');
- assert.strictEqual((await get('/p/pub-text/raw')).text, 'hello', 'public paste raw still serves');
- process.env.PASTES_MOVED_TO = 'https://community.test';
- r = await get('/p/no-such/raw');
- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/raw'], 'moved: every slug goes to Community');
- r = await get('/p/priv-shot/raw');
- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/raw'], 'moved: a private screenshot is not bounced to /screenshot');
+ // ── Paste routes: every slug is a 301 to Community, so private, public and missing answer in
+ // exactly the same shape and Media reads no row at all ──
+ const redirectsTo = async (p) => { const x = await get(p); assert.strictEqual(x.status, 301, `${p} redirects`); return x.location; };
+ assert.strictEqual(await redirectsTo('/p/priv-text'), 'https://openvibe.community/p/priv-text', 'a private paste page redirects like any other slug');
+ assert.strictEqual(await redirectsTo('/p/no-such'), 'https://openvibe.community/p/no-such', 'a missing slug goes to Community too');
+ assert.strictEqual(await redirectsTo('/p/pub-text'), 'https://openvibe.community/p/pub-text', 'and so does a public one');
+ assert.strictEqual(await redirectsTo('/p/priv-text/raw'), 'https://openvibe.community/p/priv-text/raw', 'raw keeps the suffix; nothing singles out a private paste');
// /p/:slug/screenshot: a slug Media doesn't hold (pastes made in Community since the move, whose
// /p/:slug/screenshot: a slug Media doesn't hold (pastes made in Community since the move, whose
// screenshots hero moments linked here) goes to Community; a private one answers the same way.
// screenshots hero moments linked here) goes to Community; a private one answers the same way.
- r = await get('/p/no-such/screenshot');
- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/screenshot'], 'moved: an unknown screenshot slug goes to Community');
- r = await get('/p/priv-shot/screenshot');
- assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/screenshot'], 'moved: a private screenshot looks like an unknown one');
- delete process.env.PASTES_MOVED_TO;
- console.log('✅ /p/:slug/raw: private looks missing');
+ assert.strictEqual(await redirectsTo('/p/no-such/screenshot'), 'https://openvibe.community/p/no-such/screenshot', 'an unknown screenshot slug goes to Community');
+ assert.strictEqual(await redirectsTo('/p/priv-shot/screenshot'), 'https://openvibe.community/p/priv-shot/screenshot', 'a private screenshot looks like an unknown one');
+ console.log('✅ /p/:slug: private looks missing (every slug redirects)');
// ── v1 detail routes when the app acts for a user ──
// ── v1 detail routes when the app acts for a user ──
await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');
await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),
// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),
-// the paste viewer's Community canonical, and the OpenVibe Frame + SEO on all of them.
+// the paste redirect to Community, and the OpenVibe Frame + SEO on the pages.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -68,28 +68,13 @@ const http = require('http');
@@ -68,28 +68,13 @@ const http = require('http');
const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
- // ── Paste viewer: canonical on Community, noindex, Article JSON-LD ──
- const paste = { slug: 'abc-123', title: 'hello.js', type: 'paste', content: 'console.log("hi")', language: 'javascript', views: 3, unique_views: 2, created_at: '2026-09-01 10:00:00', updated_at: '2026-09-02 10:00:00', ai_tags: '["js","demo"]' };
- const ph = pages.renderPastePage(paste);
- assert.ok(ph.includes('<link rel="canonical" href="https://openvibe.community/p/abc-123">'));
- assert.ok(ph.includes('<meta name="robots" content="noindex, follow">'));
- assert.ok(ph.includes('href="https://openvibe.community/p/abc-123"'), 'header links to the Community page');
- assert.ok(ph.includes('console.log("hi")'));
- assert.ok(ph.includes('"history":{"type":"paste","title":"hello.js"}'));
- const pld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(ph)[1]);
- assert.strictEqual(pld['@type'], 'Article');
- assert.strictEqual(pld.mainEntityOfPage, 'https://openvibe.community/p/abc-123');
- assert.strictEqual(pld.keywords, 'js, demo');
- const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });
- assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));
- assert.ok(shot.includes('"@type":"ImageObject"'));
- assert.ok(!/\bfree\b/i.test(html + ph + shot + pageFrame.baseCss()), 'no cost claims in served HTML');
+ // Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
+ assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──
// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──
const express = require('express');
const express = require('express');
const raw = db.getDb();
const raw = db.getDb();
await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 1, 'public', 10) RETURNING id`).run(42, 'live', 'Router VOD', '/nonexistent/vod-42.mp4');
await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, duration_seconds) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 1, 'public', 10) RETURNING id`).run(42, 'live', 'Router VOD', '/nonexistent/vod-42.mp4');
- await raw.prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility) VALUES (?, 'live', 'P', 'paste', 'x', 'text', 'public') RETURNING id`).run('slug1');
const app = express();
const app = express();
app.use('/', require('../server/public/routes'));
app.use('/', require('../server/public/routes'));
const server = app.listen(0);
const server = app.listen(0);
@@ -115,8 +100,7 @@ const http = require('http');
@@ -115,8 +100,7 @@ const http = require('http');
r = await get('/v/42', { accept: 'video/webm,video/ogg,video/*;q=0.9,*/*;q=0.5', 'sec-fetch-dest': 'video' });
r = await get('/v/42', { accept: 'video/webm,video/ogg,video/*;q=0.9,*/*;q=0.5', 'sec-fetch-dest': 'video' });
assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');
assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');
r = await get('/p/slug1', nav);
r = await get('/p/slug1', nav);
- assert.strictEqual(r.status, 200);
- assert.ok(r.body.includes('https://openvibe.community/p/slug1'));
+ assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');
r = await get('/og-image.png', {});
r = await get('/og-image.png', {});
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers['content-type'], 'image/png');
assert.strictEqual(r.headers['content-type'], 'image/png');
@@ -197,8 +197,14 @@ const crypto = require('crypto');
@@ -197,8 +197,14 @@ const crypto = require('crypto');
await same([`/c/${privClip}`, { ...h, ...key('games-key') }], ['/c/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /c');
await same([`/c/${privClip}`, { ...h, ...key('games-key') }], ['/c/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /c');
await same([`/c/${privClip}`, { ...h, ...asUser(77) }], ['/c/999999', { ...h, ...asUser(77) }], 'another user on /c');
await same([`/c/${privClip}`, { ...h, ...asUser(77) }], ['/c/999999', { ...h, ...asUser(77) }], 'another user on /c');
}
}
- await same(['/p/priv-page', nav], ['/p/no-such-paste', nav], 'a private paste page looks missing');
- await same(['/p/priv-page', { ...nav, ...asUser(77) }], ['/p/no-such-paste', { ...nav, ...asUser(77) }], 'a private paste page, for another user');
+ // The paste page is a 301 to Community for every slug: a private one is indistinguishable from
+ // an unknown one, and no identity changes that — Media reads no row on this route.
+ for (const h of [nav, { ...nav, ...asUser(77) }, { ...nav, ...key('games-key') }]) {
+ let r = await get('/p/priv-page', h);
+ assert.deepStrictEqual([r.status, r.location], [301, 'https://openvibe.community/p/priv-page'], 'a private paste page redirects to Community');
+ r = await get('/p/no-such-paste', h);
+ assert.deepStrictEqual([r.status, r.location], [301, 'https://openvibe.community/p/no-such-paste'], 'an unknown slug redirects the same way');
+ }
await same([`/api/v1/games/vods/${pubVod}`, key('games-key')], ['/api/v1/games/vods/999999', key('games-key')], 'another app cannot read this app\'s VOD by id');
await same([`/api/v1/games/vods/${pubVod}`, key('games-key')], ['/api/v1/games/vods/999999', key('games-key')], 'another app cannot read this app\'s VOD by id');
await same([`/api/v1/games/clips/${privClip}`, key('games-key')], ['/api/v1/games/clips/999999', key('games-key')], 'nor its clip');
await same([`/api/v1/games/clips/${privClip}`, key('games-key')], ['/api/v1/games/clips/999999', key('games-key')], 'nor its clip');
const projected = await raw.prepare('SELECT id, visibility FROM media_objects WHERE legacy_ref IS NOT NULL AND id IN (SELECT object_id FROM vods WHERE id IN (?, ?) UNION SELECT id FROM media_objects WHERE legacy_ref ILIKE ? OR legacy_ref ILIKE ?)')
const projected = await raw.prepare('SELECT id, visibility FROM media_objects WHERE legacy_ref IS NOT NULL AND id IN (SELECT object_id FROM vods WHERE id IN (?, ?) UNION SELECT id FROM media_objects WHERE legacy_ref ILIKE ? OR legacy_ref ILIKE ?)')