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)

OP OpenVibers committed 7 Oct 2026 · c3dc88831e · 17 files, +95 −284

* Media: /p/:slug and /p/:slug/raw are unconditional 301s to Community; the local paste row-serving is deleted (T4/T10).

## Changes (file:line)

**Routes / config**
- `server/config.js:46` — new `pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`, trailing slash stripped); the redirect no longer needs the env var. *(I stored the Community origin, matching the deployed `PASTES_MOVED_TO` value and `.env.example`; the routes append `/p/<slug>` — say the word if you literally wanted the config value to be `https://openvibe.community/p`.)*
- `server/public/routes.js:527-534` — `/p/:slug` and `/p/:slug/raw` are now unconditional 301s (`communityPasteUrl`, slug `encodeURIComponent`-ed, `/raw` preserved). The row-read / private-check / view-count / burn-after-read branches and the `optionalIdentity` middleware are gone.
- `server/public/routes.js:536-554` — screenshot-bytes route untouched (still reads rows, still 301s unknown/private slugs to Community).
- `server/public/routes.js:10-11,19-21` — header comment: pastes redirect to Community.

**Deleted (only served the row branches)**
- `server/public/pages.js:191` — `renderPastePage()` removed (only user was the old `/p/:slug`).
- `server/pastes/storage.js:66` — `removePasteScreenshot()` removed; `SCREENSHOTS_DIR` + `generateSlug()` kept (avatar ingest uses both).
- `server/vod/vod-storage.js:440-464` — `deleteLegacyPasteScreenshot()` removed (its only caller was `removePasteScreenshot`) and dropped from the exports.
- `server/public/page-frame.js:228` — comment no longer names the paste viewer (the small-card override is still used by `server/me/pages.js`).

**Store:** nothing removed — `getPasteBySlug` is still used by the screenshot route, and the other paste helpers (`likePaste`, `incrementPasteCopies`, …) were already orphaned by T10 step 2, not by this change. I left them; say if you want them swept too.

**Tests**
- `test/pastes-moved.test.js:44-62` — rewritten: unconditional 301 with `PASTES_MOVED_TO` unset (default target), `/raw` suffix preserved, missing slug, URL-encoded slug, screenshot bytes still 200.
- `test/private-not-found.test.js:151-162` — paste block rewritten to the uniform 301 (private/public/missing/screenshot), replacing the row-serving 404 assertions.
- `test/public-pages.test.js:71,103` — `renderPastePage` unit block dropped; router check is now the 301 (no row inserted).
- `test/security-private.test.js:200-207` — private paste page asserted as a 301 for every identity, not the `same()` 404 comparison.

**Docs**
- `README.md:291-294,306-308,497-498`, `STATUS.json:23`, `.env.example:32`, `docs/object-model.md:514`, `scripts/smoke-test.sh:108,114-116` (smoke test now checks the 301 + Location).

## Remaining `pastes`-table readers/writers (drop can follow)

Readers:
- `server/public/routes.js:539` — `/p/:slug/screenshot` (kept deliberately).
- `server/pastes/storage.js:61` — `generateSlug` uniqueness check.

* N-1: /p/:slug leaves the crawl and the recorded client (Media only 301s pastes to Community now).

Changes

ddiff --git a/.env.example b/.env.example
iindex f362542..b046137 100644
---- a/.env.example
++++ b/.env.example
@@@ -29,8 +29,9 @@ MEDIA_DATA_DIR=./data
VOD_PATH=./data/vods
CLIPS_PATH=./data/clips
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
THUMBNAILS_PATH=./data/thumbnails
FILES_PATH=./data/files
ddiff --git a/README.md b/README.md
iindex f55e5e0..47ba7b5 100644
---- a/README.md
++++ b/README.md
@@@ -7,8 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
object 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 every
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, 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`, so
Media 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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex c420af0..6772ad8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 826187f..66e0680 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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.
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 07939ef..f3ae989 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -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 '^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")
ddiff --git a/server/config.js b/server/config.js
iindex 90a3505..a302f91 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/pastes/storage.js b/server/pastes/storage.js
iindex e09f0b9..4fed5d7 100644
---- a/server/pastes/storage.js
++++ b/server/pastes/storage.js
@@@ -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 };
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex cb42933..03418cc 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -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)}">`);
}
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 7293b80..cf2862c 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -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 };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex d9487a1..6e13d11 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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;
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
iindex 5f8171c..cd946e4 100644
---- a/server/vod/vod-storage.js
++++ b/server/vod/vod-storage.js
@@@ -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,
ddiff --git a/test/fixtures/n-1/client.json b/test/fixtures/n-1/client.json
iindex 49b13c2..0795168 100644
---- a/test/fixtures/n-1/client.json
++++ b/test/fixtures/n-1/client.json
@@@ -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",
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 3eed123..a7954fd 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -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: [
ddiff --git a/test/pastes-moved.test.js b/test/pastes-moved.test.js
iindex f91ed02..ff6bd9d 100644
---- a/test/pastes-moved.test.js
++++ b/test/pastes-moved.test.js
@@@ -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 it
const 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 });
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
iindex be3a551..3670d62 100644
---- a/test/private-not-found.test.js
++++ b/test/private-not-found.test.js
@@@ -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');
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex d026682..d8ae60d 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -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(&quot;hi&quot;)'));
-- 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');
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex bce068d..fef77b8 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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 ?)')