openvibers/openvibe.live Commits
Attempts: 1. author Claude opus done; 2. review deepseek-v4.1-flash done
Changes
@@@ -157,10 +157,9 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt# admin settings as `ops_alert_webhook_url`.OPS_ALERT_WEBHOOK_URL=--# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)--PASTES_ON_COMMUNITY=0++# Pastes are served by openvibe.community (Live 301s /p/:slug there and links straight to it)OV_COMMUNITY_URL=https://openvibe.community--# Community's loopback API: pastes (PASTES_AUTHORITY=community) and VOD/clip comments, which are++# Community's loopback API: pastes and VOD/clip comments, which are# Community threads (server/comments-client.js; Live's service token needs community.comment.write# and community.comment.moderate on openvibe.community)OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200@@@ -100,7 +100,7 @@ Everything reads the effective value: listing queries return `COALESCE(ai_categoLive's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Mediasplit: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,--same ids) and OpenVibe.Community (pastes, by `legacy_media_id` or slug) hold the old rows too. Since++same ids) and OpenVibe.Community (pastes, by slug) hold the old rows too. Sinceregister item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or readof these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Itsallow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this@@@ -119,7 +119,7 @@ What used to read them, and what answers now (most of it in `server/media-proxy/| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own || AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never || Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |--| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` when it owns them | counts only |++| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` | counts only || Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only || Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff || Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |@@@ -221,7 +221,7 @@ SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods vSELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips kWHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p-- WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.legacy_media_id = p.id OR x.slug = p.slug);++ WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.slug = p.slug);SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes lWHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc@@@ -476,7 +476,7 @@ function _channelPasteCardHTML(p, canManage) {const thumb = (isShot && p.screenshot_url)? `<div class="ch-paste-thumb"><img src="${esc(p.screenshot_url)}" alt="" loading="lazy"></div>`: `<div class="ch-paste-thumb ch-paste-thumb-icon"><i class="fa-solid ${isShot ? 'fa-image' : 'fa-code'}"></i></div>`;-- return `<a class="ch-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">++ return `<a class="ch-paste-card" href="${esc(pasteHref(p.slug))}">${thumb}<div class="ch-paste-info"><div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>@@@ -1533,7 +1533,7 @@ async function loadHomePastes(opts) {? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;return `-- <a class="home-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">++ <a class="home-paste-card" href="${esc(pasteHref(p.slug))}">${media}<div class="home-paste-body"><div class="home-paste-info">@@@ -1025,6 +1025,12 @@ function whenRouteReady(pageId, render) {});}++/** A paste's page on openvibe.community (the shell's ov-pastes-base meta names it). */++function pasteHref(slug) {++ const meta = document.querySelector('meta[name="ov-pastes-base"]');++ return `${meta ? meta.content : ''}/p/${encodeURIComponent(slug)}`;++}++/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive* signed in; guests go straight to the page. */function _pasteHandOver(target) {@@@ -72,7 +72,7 @@ function renderPasteCard(p) {const nsfwBlur = p.is_nsfw ? ' paste-card-nsfw-blur' : '';const _cardInner = `-- <a class="paste-card${nsfwBlur}" href="/p/${p.slug}" onclick="return handleLinkClick(event, '/p/${p.slug}')">++ <a class="paste-card${nsfwBlur}" href="${escapeHtml(pasteHref(p.slug))}">${thumb}<div class="paste-card-info"><div class="paste-card-title">${pinBadge}${nsfwBadge}${escapeHtml(p.title)}${burnBadge}</div>@@@ -425,9 +425,8 @@ async function tick(opts = {}) {// never knew Community pastes (every moment since the move had a broken image).let shot = paste.screenshot_url || null;if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }-- img = media.publicUrl(shot) || (pastesClient.onCommunity()-- ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`-- : `${media.pasteUrl(pasteSlug)}/screenshot`);++ img = media.publicUrl(shot)++ || `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`;}} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }@@@ -233,8 +233,8 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r}});--// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community — or--// OpenVibe.Media while PASTES_AUTHORITY is unset). Pastes have no metadata filter upstream, so++// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community).++// Pastes have no metadata filter upstream, so// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.router.get('/avatar/history', requireAuth, async (req, res) => {try {@@@ -536,7 +536,7 @@ effectsRouter.post('/hardware', (req, res) => {res.json({ ok: true });});--// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).++// /paste from chat, through Live's pastes client (OpenVibe.Community).effectsRouter.post('/paste', async (req, res) => {const b = req.body || {};try {@@@ -269,9 +269,8 @@ const SOURCES = {},};--/** People's or AI pastes from OpenVibe.Community. Pastes are only listed once Community owns them. */++/** People's or AI pastes from OpenVibe.Community. */async function communityPastes(origin, offset, n, { sort, since }) {-- if (!pastesClient.onCommunity()) { const e = new Error('pastes are listed from OpenVibe.Community only'); e.unavailable = true; throw e; }const r = await pastesClient.request('GET', '', {query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },timeoutMs: SOURCE_TIMEOUT_MS,@@@ -541,7 +541,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.--// Pastes on openvibe.community (PASTES_ON_COMMUNITY=1): /p/<slug> redirects there, and it is mounted++// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted// before SEO so crawlers and people get the same redirect and Community is the one canonical page.require('./web/paste-handover').register(app);try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }@@@ -643,15 +643,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));});--// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)--// against THIS origin — bounce them to the Media public host, where the--// canonical paste page lives.++// Old relative paste URLs on THIS origin: screenshot bytes still live on the Media public host;++// raw text is served by OpenVibe.Community, the paste's home.app.get('/p/:slug/screenshot', (req, res) => {res.set('Cache-Control', 'public, max-age=300');res.redirect(302, `${mediaClient.MEDIA_PUBLIC_URL}/p/${encodeURIComponent(req.params.slug)}/screenshot`);});app.get('/p/:slug/raw', (req, res) => {-- res.redirect(302, mediaClient.pasteRawUrl(req.params.slug));++ res.redirect(302, `${require('./web/paste-handover').communityUrl()}/p/${encodeURIComponent(req.params.slug)}/raw`);});// Offline-screen assets (channel offline background: webp images + transcoded webm)@@@ -271,43 +271,6 @@ function recutClip(clipId, opts = {}) {return request('POST', `/clips/${clipId}/recut`, opts);}--// ── Pastes ───────────────────────────────────────────────────────────────────----/**-- * POST /pastes { title?, content?, language?, user_id?, visibility?, screenshot (multipart)? }-- * → { id, slug, url }. Pass `screenshot` as Buffer or {buffer, filename, contentType}.-- */--function createPaste({ screenshot, ...fields } = {}, opts = {}) {-- if (screenshot) {-- const fd = _formData(fields, screenshot, 'screenshot');-- return request('POST', '/pastes', { body: fd, timeoutMs: 60000, ...opts });-- }-- return request('POST', '/pastes', { body: fields, ...opts });--}----async function getPaste(slug, opts = {}) {-- const out = await request('GET', `/pastes/${encodeURIComponent(slug)}`, opts);-- return (out && out.paste) || out; // Media wraps single pastes as { paste }--}----function listPastes(query = {}, opts = {}) {-- return request('GET', '/pastes', { query, ...opts });--}----/** Pastes awaiting AI analysis (app-key only on Media). */--function listPastesNeedingAi(limit = 5, opts = {}) {-- return request('GET', '/pastes', { query: { needs_ai: 1, limit }, ...opts });--}----/** Write AI results back to a paste. */--function setPasteAi(slug, { ai_summary, ai_tags } = {}, opts = {}) {-- return request('POST', `/pastes/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags }, ...opts });--}----function deletePaste(slug, opts = {}) {-- return request('DELETE', `/pastes/${encodeURIComponent(slug)}`, opts);--}--// ── Files ────────────────────────────────────────────────────────────────────/** POST /files (multipart) → { key, url, size, mime } */@@@ -451,8 +414,6 @@ module.exports = {getVod, listVods, updateVod, deleteVod,// clipscreateClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,-- // pastes-- createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,// files + thumbnailsuploadFile, getFileMeta, deleteFile,uploadThumbnail, generateThumbnail,@@@ -265,19 +265,14 @@ async function topContentRanges(user) {};}--// ── Pastes (OpenVibe.Community; OpenVibe.Media while PASTES_AUTHORITY is unset) ──────────────++// ── Pastes (OpenVibe.Community) ─────────────────────────────────────────────────────────────/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */async function _listUserPastes(user, { limit = 30, type, hidden = false }) {-- let out;-- if (pastesClient.onCommunity()) {-- // Community lists one person's pastes by username. Hidden ones only for the owner (act as-- // them) or staff (X-OV-Staff): Live says which, after its own check of the caller.-- const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});-- out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });-- } else {-- out = await media.listPastes({ user_id: user.id, include_unlisted: hidden ? 1 : undefined, type, limit }, { timeoutMs: TIMEOUT_MS });-- }++ // Community lists one person's pastes by username. Hidden ones only for the owner (act as them)++ // or staff (X-OV-Staff): Live says which, after its own check of the caller.++ const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});++ const out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });let pastes = rowsOf(out, 'pastes');if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');return { pastes, total: totalOf(out, pastes) };@@@ -341,11 +336,10 @@ async function siteMediaStats() {let _pasteStats = { at: 0, data: null };/**-- * Paste totals from the service that owns pastes: Community's counts under PASTES_AUTHORITY=community-- * ({ pastes, pasteText, pasteImages }), else null (Media's /stats already carries them).++ * Paste totals from OpenVibe.Community ({ pastes, pasteText, pasteImages }), or null while it is++ * unreachable. Cached five minutes; retried 30 s after a failure.*/async function sitePasteStats() {-- if (!pastesClient.onCommunity()) return null;if (Date.now() - _pasteStats.at < SITE_STATS_TTL_MS) return _pasteStats.data;try {const out = await pastesClient.request('GET', '/admin/stats', { act: { staff: true }, timeoutMs: TIMEOUT_MS });@@@ -1,44 +1,21 @@/**-- * OpenVibe.Live — /api/pastes proxy (OpenVibe.Media backed)++ * OpenVibe.Live — /api/pastes proxy (OpenVibe.Community backed)*-- * The paste system (incl. like/comment/fork endpoints, preserved from the-- * inherited code) lives in OpenVibe.Media under /api/v1/live/pastes/... .-- * This router forwards the SPA's existing /api/pastes/* calls 1:1. Identity is resolved-- * HERE — every forwarding route runs optionalAuth/requireAuth first, and media-client-- * sends the resulting Live-local user id to Media. Forwarding the caller's Network JWT-- * and letting Media read the identity out of it (what this used to do) filed writes-- * under the Network's id for the account, which is a different number from the local-- * one and belongs to an unrelated local user.++ * OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every++ * call there with Live's service token; the person is named by their canonical subject (never by a++ * Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.*-- * Live-local exceptions:++ * Live-local exception:* - POST /:slug/set-avatar → updates users.avatar_url in live.db-- * - screenshot upload → re-wrapped as multipart POST /pastes*/'use strict';const express = require('express');--const multer = require('multer');const db = require('../db/database');const { can } = require('../auth/permissions');const media = require('../media-client');const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');--// Admin/moderation endpoints on Media are app-key-only: authorize the caller--// as a Live admin here, then forward WITHOUT the user token so the app key applies.--function forwardAsApp(subPath) {-- return (req, res) => {-- const p = typeof subPath === 'function' ? subPath(req) : subPath;-- media.proxy(req, res, `/pastes${p}`)-- .catch((err) => {-- console.warn('[Pastes proxy]', err.message);-- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });-- });-- };--}----const router = express.Router();--const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 15 * 1024 * 1024 } });--// Anonymous pastes and comments are allowed, but Media does not rate-limit app-key calls, so the--// limit for anonymous writes lives here.++// Anonymous pastes and comments are allowed; the limit for anonymous writes lives here.// (A restore drill refuses every write, so it has no limiter and no limiter timer.)const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next() : require('express-rate-limit')({windowMs: 10 * 60 * 1000,@@@ -49,216 +26,12 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(message: { error: 'Too many anonymous posts — sign in or try again later' },});--/**-- * Every request reaches Media with Live's app key. Media treats an app-key call that names no acting-- * user as the app itself — full authority: it trusts `user_id` in the body and query, lists private-- * pastes, and moderates comments freely. A signed-in caller is pinned by X-OV-User-Id and gets-- * normal ownership checks; an anonymous one would get app authority. So identity fields a browser-- * sends are dropped here, always, and owner-view switches are dropped for anonymous callers.-- */--const IDENTITY_FIELDS = ['user_id', 'userId', 'author_id', 'owner_id'];--const OWNER_VIEW_FIELDS = ['include_unlisted', 'include_private', 'mine'];--function scrubIdentity(req) {-- const anon = media.actingUserFrom(req) == null;-- if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {-- for (const k of IDENTITY_FIELDS) delete req.body[k];-- }-- if (anon && req.query) for (const k of OWNER_VIEW_FIELDS) delete req.query[k];-- return anon;--}----function forward(subPath) {-- return (req, res) => {-- scrubIdentity(req);-- const p = typeof subPath === 'function' ? subPath(req) : subPath;-- media.proxy(req, res, `/pastes${p}`, { actingUser: media.actingUserFrom(req) })-- .catch((err) => {-- console.warn('[Pastes proxy]', err.message);-- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });-- });-- };--}----const slugPath = (suffix = '') => (req) => `/${encodeURIComponent(req.params.slug)}${suffix}`;----// ── Screenshot upload (multipart re-wrap) ────────────────────--router.post('/screenshot', optionalAuth, anonWriteLimiter, shotUpload.single('screenshot'), async (req, res) => {-- try {-- if (!req.file) return res.status(400).json({ error: 'No screenshot uploaded' });-- const out = await media.createPaste({-- title: req.body.title || 'Screenshot',-- content: req.body.description || '',-- language: 'text',-- visibility: req.body.visibility || 'public',-- user_id: req.user?.id || undefined,-- stream_id: req.body.stream_id || undefined,-- metadata: req.body.metadata || undefined,-- screenshot: {-- buffer: req.file.buffer,-- filename: req.file.originalname || 'screenshot.png',-- contentType: req.file.mimetype || 'image/png',-- },-- }, { actingUser: media.actingUserFrom(req) });-- const paste = out?.paste || out; // Media returns { id, slug, url, paste }-- res.status(201).json({ paste, url: out?.url || (paste?.slug ? `/p/${paste.slug}` : null) });-- } catch (err) {-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });-- console.warn('[Pastes proxy] screenshot:', err.message);-- res.status(502).json({ error: 'Media service unavailable' });-- }--});----// ── Set an image paste as my avatar (Live-local user record) ─--router.post('/:slug/set-avatar', requireAuth, async (req, res) => {-- try {-- let paste;-- try { paste = await media.getPaste(req.params.slug); } catch { paste = null; }-- if (!paste) return res.status(404).json({ error: 'Paste not found' });-- if (paste.type !== 'screenshot' && !paste.screenshot_url && !paste.screenshot_path) {-- return res.status(400).json({ error: 'That paste is not an image' });-- }-- const avatarUrl = media.publicUrl(paste.screenshot_url)-- || (paste.screenshot_path ? media.screenshotUrl(require('path').basename(paste.screenshot_path)) : media.pasteRawUrl(paste.slug));-- db.updateUserAvatar(req.user.id, avatarUrl, paste.id || null);-- // The avatar belongs to the network account: every other OpenVibe site shows it too.-- try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* picked up at the next sign-in */ }-- res.json({ success: true, avatar_url: avatarUrl });-- } catch (err) {-- console.warn('[Pastes proxy] set-avatar:', err.message);-- res.status(500).json({ error: 'Failed to set avatar' });-- }--});----// ── Everything else: transparent passthrough ─────────────────--// Media only stores our numeric user ids — resolve author names locally so--// cards don't all read "Anonymous", and translate ?username= into user_id.--function _nameUsers(rows) {-- for (const p of rows || []) {-- if (p && p.user_id != null && !p.username) {-- const u = db.getUserById(p.user_id);-- if (u) { p.username = u.username; p.display_name = u.display_name; p.profile_color = u.profile_color; p.avatar_url = u.avatar_url; }-- }-- }-- return rows;--}--function forwardEnriched(subPath, pick) {-- return async (req, res) => {-- try {-- const anon = scrubIdentity(req);-- const query = { ...req.query };-- if (query.username && query.username !== 'all') {-- const u = db.getUserByUsername(query.username);-- delete query.username;-- if (!u) return res.json({ pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });-- query.user_id = u.id;-- }-- const p = typeof subPath === 'function' ? subPath(req) : subPath;-- const out = await media.request('GET', `/pastes${p}`, { query, actingUser: media.actingUserFrom(req) });-- // Media shows private pastes to the app itself, which is what an anonymous call looks like.-- if (anon && out && out.paste && out.paste.visibility === 'private') return res.status(404).json({ error: 'Paste not found' });-- if (anon && out && Array.isArray(out.pastes)) out.pastes = out.pastes.filter((x) => !x || x.visibility !== 'private');-- _nameUsers(pick(out));-- res.json(out);-- } catch (err) {-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });-- console.warn('[Pastes proxy]', err.message);-- res.status(502).json({ error: 'Media service unavailable' });-- }-- };--}--router.get('/', optionalAuth, forwardEnriched('', (o) => o?.pastes));--router.post('/', optionalAuth, anonWriteLimiter, forward(''));--router.get('/config', forward('/config'));--router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));--router.delete('/admin/forks', requireAdmin, forwardAsApp('/admin/forks'));--router.post('/bulk', requireAdmin, forwardAsApp('/bulk'));--// Media doesn't know usernames — resolve locally, then list by user id.--// Rows written before the id-space fix (when the SPA's Network JWT was forwarded to--// Media, which then filed the write under the NETWORK id) still carry that id, so keep--// asking for both and merge. New rows are always written with the local id.--router.get('/by-user/:username', optionalAuth, async (req, res) => {-- try {-- const user = db.getUserByUsername(req.params.username);-- if (!user) return res.status(404).json({ error: 'User not found' });-- let networkId = null;-- try {-- const row = db.getDb().prepare("SELECT service_user_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(user.id);-- if (row && row.service_user_id != null) networkId = row.service_user_id;-- } catch { /* */ }-- const limit = Math.min(Math.max(parseInt(req.query.limit || '30', 10), 1), 100);-- const sort = req.query.sort === 'oldest' ? 'oldest' : 'newest';-- // Viewing your OWN paste list includes your unlisted/private ones — otherwise an-- // unlisted paste is invisible everywhere and looks like it was never created.-- // Anyone else viewing this user's page still sees public pastes only.-- //-- // Local ids only. req.user.id has always been the local one, so also accepting a-- // match against the VIEWED user's network id just meant that whoever happened to-- // hold that number as their local id — an unrelated person — was treated as the-- // owner here, and Media trusts this gate rather than re-checking it.-- const viewingSelf = !!(req.user && String(req.user.id) === String(user.id));-- const mine = viewingSelf ? { include_unlisted: 1 } : {};-- const lists = await Promise.all([-- media.listPastes({ user_id: user.id, limit, sort, ...mine }).catch(() => null),-- networkId != null && String(networkId) !== String(user.id)-- ? media.listPastes({ user_id: networkId, limit, sort, ...mine }).catch(() => null)-- : null,-- ]);-- const seen = new Set();-- const pastes = lists.flatMap(l => l?.pastes || [])-- .filter(p => !seen.has(p.slug) && seen.add(p.slug))-- .sort((a, b) => (sort === 'oldest' ? 1 : -1) * (new Date(a.created_at) - new Date(b.created_at)))-- .slice(0, limit);-- res.json({ pastes: _nameUsers(pastes), total: pastes.length, username: user.username });-- } catch (err) {-- console.warn('[Pastes proxy] by-user:', err.message);-- res.status(502).json({ error: 'Media service unavailable' });-- }--});--router.get('/:slug', optionalAuth, forwardEnriched(slugPath(), (o) => (o?.paste ? [o.paste] : [o])));--router.put('/:slug', requireAuth, forward(slugPath()));--router.delete('/:slug', requireAuth, forward(slugPath()));--// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)--// have no Media API v1 endpoints yet — they 404 until Media grows them.--// Censor replaces the screenshot: multer consumed the multipart body, so re-wrap--// it as a fresh multipart upstream request (app-key auth — Media censor is admin-only).--router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), async (req, res) => {-- try {-- if (!req.file) return res.status(400).json({ error: 'No replacement screenshot uploaded' });-- const fd = media._formData({}, {-- buffer: req.file.buffer,-- filename: req.file.originalname || 'censored.png',-- contentType: req.file.mimetype || 'image/png',-- }, 'screenshot');-- const out = await media.request('POST', `/pastes/${encodeURIComponent(req.params.slug)}/censor`, { body: fd, timeoutMs: 60000 });-- res.json(out);-- } catch (err) {-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });-- console.warn('[Pastes proxy] censor:', err.message);-- res.status(502).json({ error: 'Media service unavailable' });-- }--});--router.post('/:slug/fork', optionalAuth, anonWriteLimiter, forward(slugPath('/fork')));--// Raw content is public on Media (/p/:slug/raw) — bounce the API-shaped URL there.--router.get('/:slug/raw', (req, res) => res.redirect(302, media.pasteRawUrl(req.params.slug)));--router.post('/:slug/like', requireAuth, forward(slugPath('/like')));--router.post('/:slug/copy', optionalAuth, forward(slugPath('/copy')));--router.get('/:slug/comments', optionalAuth, forwardEnriched(slugPath('/comments'), (o) => o?.comments));--// optionalAuth, not requireAuth: anonymous comments are allowed, and Media decides--// whether they are enabled. What matters is that a signed-in commenter is named.--router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath('/comments')));--// Deleting needs an identity: without one Media sees the app key alone and lets it delete anything.--router.delete('/:slug/comments/:commentId', requireAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));----// ── Community authority (PASTES_AUTHORITY=community, roadmap Wave 5) ──────────────--// OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every--// call there with Live's service token; the person is named by their canonical subject (never by a--// Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.--// Only set-avatar stays here, because it changes Live's own user row.const pastesClient = require('../pastes-client');const principal = require('../net/network-principal');const { Readable } = require('stream');--function toCommunity(subPath) {++/** `whenDown(req)`: the answer while Community is unreachable or failing (5xx), instead of a 502. */++function toCommunity(subPath, whenDown = null) {return async (req, res) => {try {const p = typeof subPath === 'function' ? subPath(req) : subPath;@@@ -280,6 +53,7 @@ function toCommunity(subPath) {else if (ct) { headers['Content-Type'] = ct; opts.body = Readable.toWeb(req); opts.duplex = 'half'; }}const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);++ if (whenDown && upstream.status >= 500) return res.json(whenDown(req));if (upstream.status === 401) principal.invalidate('openvibe.community');res.status(upstream.status);for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {@@@ -289,7 +63,10 @@ function toCommunity(subPath) {res.send(Buffer.from(await upstream.arrayBuffer()));} catch (err) {console.warn('[Pastes → Community]', err.message);-- if (!res.headersSent) res.status(502).json({ error: 'Paste service unavailable' });++ if (!res.headersSent) {++ if (whenDown) return res.json(whenDown(req));++ res.status(502).json({ error: 'Paste service unavailable' });++ }}};}@@@ -316,8 +93,13 @@ communityRouter.delete('/admin/forks', requireAdmin, toCommunity('/admin/forks')communityRouter.post('/bulk', requireAdmin, toCommunity('/bulk'));communityRouter.post('/:slug/censor', requireAdmin, toCommunity((req) => `/${encodeURIComponent(req.params.slug)}/censor`));communityRouter.post(['/', '/screenshot', '/:slug/fork', '/:slug/comments'], optionalAuth, anonWriteLimiter, toCommunity((req) => req.path === '/' ? '' : req.path));++// Editing, deleting, liking and removing a comment need a signed-in person: refused here, before Community.++communityRouter.put('/:slug', requireAuth, toCommunity((req) => req.path));++communityRouter.delete(['/:slug', '/:slug/comments/:commentId'], requireAuth, toCommunity((req) => req.path));++communityRouter.post('/:slug/like', requireAuth, toCommunity((req) => req.path));++// A channel's or dashboard's paste tab shows an empty list while Community is down, not an error.++communityRouter.get('/by-user/:username', optionalAuth, toCommunity((req) => req.path, (req) => ({ pastes: [], total: 0, username: req.params.username })));communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '' : req.path));--module.exports = (req, res, next) => (pastesClient.onCommunity() ? communityRouter : router)(req, res, next);--module.exports.mediaRouter = router;++module.exports = communityRouter;module.exports.communityRouter = communityRouter;@@@ -2,20 +2,15 @@/*** Where Live's pastes live (roadmap Wave 5).*-- * PASTES_AUTHORITY=community: OpenVibe.Community owns pastes. Live calls its /api/pastes with a-- * service token (community.paste.create/write/moderate) and names the person it acts for in-- * X-OV-Subject (their canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never-- * filed under a person (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.-- * Anything else (default): the old path through OpenVibe.Media's app API (media-client).-- *-- * Same function shapes as media-client's paste helpers, so call sites only swap the require.++ * OpenVibe.Community owns pastes. Live calls its /api/pastes with a service token++ * (community.paste.create/write/moderate) and names the person it acts for in X-OV-Subject (their++ * canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never filed under a person++ * (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.*/--const media = require('./media-client');const principal = require('./net/network-principal');const COMMUNITY_URL = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');const AUDIENCE = 'openvibe.community';--const onCommunity = () => process.env.PASTES_AUTHORITY === 'community';class CommunityApiError extends Error {constructor(status, body) {@@@ -83,9 +78,8 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20return out;}--/** media-client-compatible createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */++/** createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {-- if (!onCommunity()) return media.createPaste({ screenshot, user_id, ...fields }, opts);const act = opts.origin === 'ai'? { origin: 'ai', sourceRef: fields.stream_id ? { service: 'live', type: 'stream', id: String(fields.stream_id) } : undefined }: { liveUserId: user_id };@@@ -100,24 +94,20 @@ async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {}async function getPaste(slug) {-- if (!onCommunity()) return media.getPaste(slug);const out = await request('GET', `/${encodeURIComponent(slug)}`, { query: { no_view: 1 } });return (out && out.paste) || out;}function listPastes(query = {}) {-- if (!onCommunity()) return media.listPastes(query);return request('GET', '', { query });}function listPastesNeedingAi(limit = 5) {-- if (!onCommunity()) return media.listPastesNeedingAi(limit);return request('GET', '', { query: { needs_ai: 1, limit } });}function setPasteAi(slug, { ai_summary, ai_tags } = {}) {-- if (!onCommunity()) return media.setPasteAi(slug, { ai_summary, ai_tags });return request('POST', `/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags } });}--module.exports = { onCommunity, request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };++module.exports = { request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };@@@ -57,7 +57,7 @@ const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () =const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };return {items: (out.items || []).map((it) => ({-- url: it.kind === 'paste' && PASTES_BASE ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,++ url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),desc: it.excerpt,})),@@@ -153,11 +153,12 @@ function _overlayAiState(row, kind) {} catch { /* */ }}--// Pastes live on openvibe.community when PASTES_ON_COMMUNITY=1: /p/<slug> is a redirect there--// (server/web/paste-handover.js, mounted before this middleware), the SPA router sends it there--// too, and every paste link Live renders points straight at Community, the paste's one canonical--// page. Live's sitemap never lists /p/ (roadmap 32.2).--const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';++// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,++// mounted before this middleware), and every paste link Live renders, here and in the SPA (which++// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the++// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).++const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');++const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;const SITE_NAME = 'OpenVibe.Live';@@@ -887,7 +888,7 @@ function _headBlock(meta) {const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;const img = abs(meta.image || DEFAULT_OG_IMAGE);const parts = [-- PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',++ PASTES_META,`<title>${esc(meta.title)}</title>`,`<meta name="description" content="${esc(meta.description)}">`,`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,@@@ -973,7 +974,7 @@ function shellHtml(urlPath, status) {let html = _base();if (!html) return null;const p = String(urlPath || '/');-- html = assets.renderRoute(html, p);++ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);if (p === '/' && status !== 404) return html;const headEnd = html.search(/<\/head>/i);if (headEnd === -1) return html;@@@ -1029,7 +1030,7 @@ async function middleware(req, res, next) {if (!SEO_ROUTE_RE.test(p)) return next();// A paste Community owns is never rendered here (the handover answers it first; this keeps it// so if the mount order ever changes).-- if (PASTES_BASE && p.startsWith('/p/')) return next();++ if (p.startsWith('/p/')) return next();// Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and// monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent// */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches@@@ -1,36 +1,23 @@'use strict';/**-- * Pastes live on openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep working-- * as redirects, so links in chat, search results and clipboards survive.++ * Pastes live on openvibe.community: the old Live paste URLs keep working as a permanent redirect,++ * so links in chat, search results and clipboards survive. Every paste link Live renders already++ * points at Community, so this only answers old links.** Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,* each naming itself canonical, and a different answer for people and machines. Now every client-- * gets the same answer and openvibe.community is the one canonical home (roadmap 32.2).-- *-- * Someone signed in here should arrive signed in there: they go through Community's silent sign-in-- * (one quiet round trip; it skips itself when Community already has a valid session and falls back-- * to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.++ * gets the same 301 and openvibe.community is the one canonical home (roadmap 32.2).** /pastes itself stays on Live: it is the Content feed with the Pastes filter (public/js/content-feed.js).*/--const onCommunity = () => process.env.PASTES_ON_COMMUNITY === '1';const communityUrl = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');--function handOver(req, res, target) {-- const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));-- if (!signedIn) return res.redirect(301, `${communityUrl()}${target}`);-- res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });-- return res.redirect(302, `${communityUrl()}/auth/login?silent=1&next=${encodeURIComponent(target)}`);--}----/** Mount the /p/:slug handover when pastes are on Community. Returns whether it was mounted. */++/** Mount the /p/:slug redirect to Community. */function register(app) {-- if (!onCommunity()) return false;-- app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));-- return true;++ app.get('/p/:slug', (req, res) => res.redirect(301, `${communityUrl()}/p/${encodeURIComponent(req.params.slug)}`));}--module.exports = { register, onCommunity, communityUrl };++module.exports = { register, communityUrl };@@@ -22,7 +22,6 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--process.env.PASTES_AUTHORITY = 'community';const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };console.warn = () => {};@@@ -315,17 +314,6 @@ server.on('listening', async () => {assert.ok(none.json.next, 'the client may try again');});-- await check('pastes are listed only when Community owns them', async () => {-- process.env.PASTES_AUTHORITY = '';-- try {-- const r = await get('/api/content/feed?limit=30');-- assert.strictEqual(r.json.sources.pastes, 'unavailable');-- assert.strictEqual(r.json.partial, false, 'not a failure to retry');-- assert.ok(!r.json.items.some((i) => i.kind === 'paste'));-- assert.strictEqual(communityCalls.length, 0);-- } finally { process.env.PASTES_AUTHORITY = 'community'; }-- });--await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {communityMode = 'lying-total';const r = await get('/api/content/feed?type=pastes&limit=5');@@@ -64,28 +64,19 @@ assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),"Media's per-IP comment cooldown and its stored address both need the real client IP");console.log('OK D: proxied requests carry the client IP, not this process\'s loopback');--// ── E: every forwarding route resolves identity first ────────────────────────────────++// ── E: every paste route resolves identity before it reaches Community ───────────────{-- // A forward() route sends whatever actingUserFrom finds on req — so a route with no-- // auth middleware silently posts as nobody.-- const lines = pastes.split('\n').filter(l => /^router\.(get|post|put|delete)\(/.test(l.trim()));-- const needsIdentity = lines.filter(l => /forward\(|forwardEnriched\(/.test(l) && !/forwardAsApp\(/.test(l));-- assert.ok(needsIdentity.length >= 8, `expected the forwarding routes, found ${needsIdentity.length}`);-- for (const line of needsIdentity) {-- if (/'\/config'/.test(line)) continue; // static config, no caller identity++ // toCommunity() names whoever optionalAuth/requireAuth resolved, so a route with no auth++ // middleware silently forwards as nobody.++ const lines = pastes.split('\n').filter(l => /^communityRouter\.(get|post|put|delete|all)\(/.test(l.trim()));++ assert.ok(lines.length >= 6, `expected the forwarding routes, found ${lines.length}`);++ for (const line of lines) {++ if (/'\/:slug\/raw'/.test(line)) continue; // a plain redirect, no caller identityassert.ok(/optionalAuth|requireAuth|requireAdmin/.test(line),`route must resolve the caller before forwarding: ${line.trim()}`);}-- console.log(`OK E: all ${needsIdentity.length - 1} identity-bearing paste routes resolve the caller first`);--}----// ── F: the owner gate compares one id space ──────────────────────────────────────────--{-- const gate = pastes.slice(pastes.indexOf('const viewingSelf'), pastes.indexOf('const mine ='));-- assert.ok(/String\(req\.user\.id\) === String\(user\.id\)/.test(gate), 'owner check must compare local ids');-- assert.ok(!/networkId/.test(gate),-- 'matching the viewed user\'s NETWORK id let whoever holds that number locally read their unlisted pastes');-- console.log('OK F: unlisted-paste owner gate compares local ids only');++ assert.ok(!/^router\./m.test(pastes) && !/mediaRouter/.test(pastes), 'the Media paste router is gone');++ console.log(`OK E: all ${lines.length - 1} identity-bearing paste routes resolve the caller first`);}console.log('✅ media identity space test passed');@@@ -76,7 +76,6 @@ media.request = async (method, p) => {if (p === '/stats') return { vods: 11, clips: 22, pastes: 33, pasteImages: 3, pasteText: 30, durationSeconds: 7200, recent: { vods: { d: 1, w: 2, m: 3 } } };throw new media.MediaApiError('unexpected', 500);};--media.listPastes = async (q = {}) => { asked.push({ kind: 'media-pastes', ...q }); return { pastes: [{ slug: 'm1', visibility: 'public' }, { slug: 'm2', visibility: 'private' }], total: 2 }; };// ── OpenVibe.Community stand-in (pastes-client.request) ──const pastesClient = require('../server/pastes-client');@@@ -194,45 +193,33 @@ async function check(name, fn) {});await check('channel badges: pastes from Community, taken clips from Media; hidden ones for owner/staff only', async () => {-- process.env.PASTES_AUTHORITY = 'community';-- try {-- const anon = await call('GET', '/api/streams/channel/alice');-- assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));-- assert.strictEqual(anon.json.pasteTotal, 2);-- assert.strictEqual(anon.json.clipsTakenTotal, 1);-- lookups._resetCaches();-- const owner = await call('GET', '/api/streams/channel/alice', 3);-- assert.strictEqual(owner.json.pasteTotal, 4);-- assert.strictEqual(owner.json.clipsTakenTotal, 2);-- assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');-- lookups._resetCaches();-- const staff = await call('GET', '/api/streams/channel/alice', 1);-- assert.strictEqual(staff.json.pasteTotal, 4);-- assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');-- lookups._resetCaches();-- communityCalls.length = 0;-- await call('GET', '/api/streams/channel/alice', 7);-- assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');-- const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');-- assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');-- } finally { delete process.env.PASTES_AUTHORITY; }-- });---- await check('pastes while Media still holds them (PASTES_AUTHORITY unset): Media list, public filter', async () => {-- const out = await lookups.userPastes(db.getUserById(3));-- assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['m1']);-- assert.ok(asked.some((q) => q.kind === 'media-pastes' && q.user_id === 3 && q.include_unlisted === undefined));++ const anon = await call('GET', '/api/streams/channel/alice');++ assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));++ assert.strictEqual(anon.json.pasteTotal, 2);++ assert.strictEqual(anon.json.clipsTakenTotal, 1);++ lookups._resetCaches();++ const owner = await call('GET', '/api/streams/channel/alice', 3);++ assert.strictEqual(owner.json.pasteTotal, 4);++ assert.strictEqual(owner.json.clipsTakenTotal, 2);++ assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');++ lookups._resetCaches();++ const staff = await call('GET', '/api/streams/channel/alice', 1);++ assert.strictEqual(staff.json.pasteTotal, 4);++ assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');++ lookups._resetCaches();++ communityCalls.length = 0;++ await call('GET', '/api/streams/channel/alice', 7);++ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');++ const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');++ assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');});await check('setup hub: the paste task counts the person\'s own pastes', async () => {-- process.env.PASTES_AUTHORITY = 'community';-- try {-- const r = await call('GET', '/api/streams/setup-progress', 3);-- assert.strictEqual(r.status, 200);-- const task = r.json.tasks.find((t) => t.id === 'paste');-- assert.strictEqual(task.count, 4);-- assert.strictEqual(task.done, true);-- } finally { delete process.env.PASTES_AUTHORITY; }++ const r = await call('GET', '/api/streams/setup-progress', 3);++ assert.strictEqual(r.status, 200);++ const task = r.json.tasks.find((t) => t.id === 'paste');++ assert.strictEqual(task.count, 4);++ assert.strictEqual(task.done, true);});await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {@@@ -255,14 +242,11 @@ async function check(name, fn) {});await check('avatar history: the avatar-tagged screenshots, from Community', async () => {-- process.env.PASTES_AUTHORITY = 'community';-- try {-- raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();-- const r = await call('GET', '/api/auth/avatar/history', 3);-- assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);-- assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));-- } finally { delete process.env.PASTES_AUTHORITY; }++ raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();++ const r = await call('GET', '/api/auth/avatar/history', 3);++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);++ assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));});await check('offline-screen ranges: most-viewed public VOD and clip per window', async () => {@@@ -295,36 +279,30 @@ async function check(name, fn) {});await check('admin: VOD counts from Media; AI explorer pastes from Community and VODs/clips from Media', async () => {-- process.env.PASTES_AUTHORITY = 'community';-- try {-- raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));-- const st = await call('GET', '/api/admin/stats', 1);-- assert.strictEqual(st.status, 200, st.text.slice(0, 200));-- assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });-- const ex = await call('GET', '/api/admin/ai/explorer/3', 1);-- assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));-- assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');-- const v11 = ex.json.vods.find((v) => v.id === 11);-- assert.strictEqual(v11.ai_overview, 'private overview');-- assert.strictEqual(v11.ai_transcript, 'hello there');-- assert.strictEqual(ex.json.counts.clips, 3);-- assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');-- } finally { delete process.env.PASTES_AUTHORITY; }++ raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));++ const st = await call('GET', '/api/admin/stats', 1);++ assert.strictEqual(st.status, 200, st.text.slice(0, 200));++ assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });++ const ex = await call('GET', '/api/admin/ai/explorer/3', 1);++ assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));++ assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');++ const v11 = ex.json.vods.find((v) => v.id === 11);++ assert.strictEqual(v11.ai_overview, 'private overview');++ assert.strictEqual(v11.ai_transcript, 'hello there');++ assert.strictEqual(ex.json.counts.clips, 3);++ assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');});await check('home stats: Live counts none of the archive itself; Media and Community fill it in', async () => {const local = db._computeHomeStats();for (const k of ['vods', 'clips', 'pastes', 'pasteImages', 'pasteText', 'streamHours']) assert.strictEqual(local[k], null, k);assert.strictEqual(db.getHomeStatSeries('vods'), null, 'the vods series is Media\'s');-- process.env.PASTES_AUTHORITY = 'community';-- try {-- const s = await lookups.withArchiveStats({ ...local });-- assert.strictEqual(s.vods, 11);-- assert.strictEqual(s.streamHours, 2);-- assert.strictEqual(s.pastes, 44, 'Community owns the paste count');-- assert.strictEqual(s.pasteImages, 4);-- assert.strictEqual(s.recent.vods.w, 2);-- } finally { delete process.env.PASTES_AUTHORITY; }++ const s = await lookups.withArchiveStats({ ...local });++ assert.strictEqual(s.vods, 11);++ assert.strictEqual(s.streamHours, 2);++ assert.strictEqual(s.pastes, 44, 'Community owns the paste count');++ assert.strictEqual(s.pasteImages, 4);++ assert.strictEqual(s.recent.vods.w, 2);});await check('streamer overview job: memories are the signal', async () => {@@@ -22,7 +22,6 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--delete process.env.PASTES_ON_COMMUNITY;const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };console.warn = () => {};@@@ -77,7 +76,6 @@ media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Nummedia.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };media.listVods = async () => ({ vods: [] });media.listClips = async () => ({ clips: [] });--media.listPastes = async () => ({ pastes: [] });media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };const pastesClient = require('../server/pastes-client');pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };@@@ -1,14 +1,15 @@/**-- * One canonical page per paste (roadmap 32.2): with PASTES_ON_COMMUNITY=1, /p/<slug> on Live is a-- * redirect to openvibe.community for every client, crawlers and browsers alike.++ * One canonical page per paste (roadmap 32.2): /p/<slug> on Live is a permanent redirect to++ * openvibe.community for every client, crawlers, browsers and signed-in visitors alike.** The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got* the 301: two self-canonical pages and a different answer for people and machines. Checked here:* - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;-- * - /p/<slug> answers 301 to Community whatever the Accept header, and a signed-in visitor goes-- * through Community's silent sign-in (302);-- * - Live's sitemap lists no /p/ URL, and the paste links Live renders point at Community.++ * - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw++ * 302s to Community's raw text (not Media's);++ * - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the++ * SPA, are absolute Community URLs (no relative /p/ href left to bounce through Live).** node test/paste-canonical.test.js*/@@@ -22,7 +23,6 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-paste-canonical-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--process.env.PASTES_ON_COMMUNITY = '1';process.env.OV_COMMUNITY_URL = 'https://openvibe.community';const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };@@@ -90,10 +90,18 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.strictEqual(pasteReads, 0, 'Live never rendered its own copy');});-- await check('a signed-in visitor goes through Community\'s silent sign-in', async () => {++ await check('a signed-in visitor gets the same plain 301 (no silent sign-in hop)', async () => {const r = await get('/p/abc123', { accept: 'text/html', cookie: 'ov_sso_hint=account' });-- assert.strictEqual(r.status, 302);-- assert.strictEqual(r.location, 'https://openvibe.community/auth/login?silent=1&next=%2Fp%2Fabc123');++ assert.strictEqual(r.status, 301);++ assert.strictEqual(r.location, 'https://openvibe.community/p/abc123');++ });++++ await check('server/index.js sends /p/<slug>/raw to Community, screenshots to Media', () => {++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++ const raw = src.slice(src.indexOf("app.get('/p/:slug/raw'"), src.indexOf("app.get('/p/:slug/raw'") + 300);++ assert.ok(/paste-handover'\)\.communityUrl\(\)\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/raw/.test(raw), raw);++ assert.ok(!/pasteRawUrl/.test(raw), 'raw no longer goes through Media');++ assert.ok(/MEDIA_PUBLIC_URL\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/screenshot/.test(src), 'screenshot bytes still on Media');});await check('Live\'s sitemap lists no paste', async () => {@@@ -108,6 +116,21 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(!m.snapshot.includes('https://openvibe.live/p/'), 'no Live paste link');});++ await check('the SPA renders absolute Community paste hrefs from the shell\'s ov-pastes-base', async () => {++ for (const f of ['pastes.js', 'app-home.js', 'app-channel.js']) {++ const src = fs.readFileSync(path.join(__dirname, '../public/js', f), 'utf8');++ assert.ok(!/href="\/p\//.test(src), `${f} renders a relative /p/ href`);++ assert.ok(/href="\$\{(?:esc|escapeHtml)\(pasteHref\(p\.slug\)\)\}"/.test(src), `${f} links through pasteHref()`);++ }++ const app = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');++ assert.ok(/function pasteHref\(slug\)[\s\S]{0,200}meta\[name="ov-pastes-base"\]/.test(app), 'pasteHref reads the shell meta');++ const meta = '<meta name="ov-pastes-base" content="https://openvibe.community">';++ assert.ok(seo.render(await seo._pageMeta('/pastes'), '/pastes').includes(meta), 'SEO pages name the Community base');++ for (const [p, status] of [['/dashboard', 200], ['/no-such-page', 404]]) {++ assert.ok(String(seo.shellHtml(p, status)).includes(meta), `${p} shell names the Community base`);++ }++ });++server.close();try { fs.unlinkSync(tmp); } catch { /* */ }for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }@@@ -1,6 +1,6 @@'use strict';--// PASTES_AUTHORITY=community (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with++// Community is the only paste authority (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with// a service token. AI pastes are ownerless (origin ai + source stream), a person is named by subject, the// SPA's /api/pastes forwards anonymously when nobody is signed in, and staff routes keep requireAdmin.@@@ -18,7 +18,6 @@ process.env.DB_PATH = path.join(tmp, 'live.db');process.env.INTERNAL_API_KEY = 'legacy-key';process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--process.env.PASTES_AUTHORITY = 'community';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const SID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';@@@ -51,6 +50,7 @@ const community = http.createServer((req, res) => {const claims = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.community' });seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, origin: req.headers['x-ov-origin'] || null, sourceRef: req.headers['x-ov-source-ref'] || null, xff: req.headers['x-forwarded-for'] || null, type: req.headers['content-type'] || '', body: Buffer.concat(chunks).toString('latin1'), tokenOk: claims.ok });res.setHeader('Content-Type', 'application/json');++ if (req.url.startsWith('/api/pastes/by-user/failing')) { res.statusCode = 503; return res.end('{}'); }if (req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ id: 9, slug: 'new-slug-1', url: '/p/new-slug-1', paste: { slug: 'new-slug-1', origin: req.headers['x-ov-origin'] || 'user' } })); }res.end(JSON.stringify({ pastes: [], total: 0 }));});@@@ -120,9 +120,27 @@ const community = http.createServer((req, res) => {assert.strictEqual(r.status, 302);assert.ok(r.headers.get('location').endsWith('/p/some-slug/raw'));-- // Default mode goes to Media exactly as before.-- process.env.PASTES_AUTHORITY = '';-- assert.strictEqual(client.onCommunity(), false);++ // Writes that need a person are refused here, before Community.++ const before = seen.length;++ for (const [method, p] of [['PUT', '/some-slug'], ['DELETE', '/some-slug'], ['POST', '/some-slug/like'], ['DELETE', '/some-slug/comments/1']]) {++ r = await fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json' }, body: method === 'DELETE' ? undefined : '{}' });++ assert.strictEqual(r.status, 401, `anonymous ${method} ${p}`);++ }++ assert.strictEqual(seen.length, before, 'none of them reached Community');++ // A person's paste list is forwarded, and reads as empty (not an error) while Community fails.++ r = await fetch(`${base}/by-user/ann?limit=30`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(seen.pop().url, '/api/pastes/by-user/ann?limit=30');++ r = await fetch(`${base}/by-user/failing?limit=30`);++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(await r.json(), { pastes: [], total: 0, username: 'failing' });++++ // Community is the only mode: no switch, no Media fallback, no Media router.++ assert.strictEqual(client.onCommunity, undefined, 'the paste-authority switch is gone');++ assert.strictEqual(require('../server/media-proxy/pastes'), require('../server/media-proxy/pastes').communityRouter, '/api/pastes is the Community router');++ assert.strictEqual(require('../server/media-proxy/pastes').mediaRouter, undefined, 'the Media router is gone');++ const clientSrc = fs.readFileSync(path.join(__dirname, '../server/pastes-client.js'), 'utf8');++ assert.ok(!/media-client/.test(clientSrc) && !clientSrc.includes('PASTES_' + 'AUTHORITY'), 'the pastes client never falls back to Media');srv.close(); net.close(); community.close();fs.rmSync(tmp, { recursive: true, force: true });@@@ -22,7 +22,6 @@ const path = require('path');const tmp = path.join(os.tmpdir(), `ov-seo-moments-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--delete process.env.PASTES_ON_COMMUNITY;const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };console.warn = () => {};@@@ -28,7 +28,6 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--delete process.env.PASTES_ON_COMMUNITY;const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };console.warn = () => {};
@@ -157,10 +157,9 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
@@ -157,10 +157,9 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
# admin settings as `ops_alert_webhook_url`.
# admin settings as `ops_alert_webhook_url`.
OPS_ALERT_WEBHOOK_URL=
OPS_ALERT_WEBHOOK_URL=
-# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
-PASTES_ON_COMMUNITY=0
+# Pastes are served by openvibe.community (Live 301s /p/:slug there and links straight to it)
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
-# Community's loopback API: pastes (PASTES_AUTHORITY=community) and VOD/clip comments, which are
+# Community's loopback API: pastes and VOD/clip comments, which are
# Community threads (server/comments-client.js; Live's service token needs community.comment.write
# Community threads (server/comments-client.js; Live's service token needs community.comment.write
# and community.comment.moderate on openvibe.community)
# and community.comment.moderate on openvibe.community)
OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
@@ -100,7 +100,7 @@ Everything reads the effective value: listing queries return `COALESCE(ai_catego
@@ -100,7 +100,7 @@ Everything reads the effective value: listing queries return `COALESCE(ai_catego
Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media
Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media
split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,
split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,
-same ids) and OpenVibe.Community (pastes, by `legacy_media_id` or slug) hold the old rows too. Since
+same ids) and OpenVibe.Community (pastes, by slug) hold the old rows too. Since
register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read
register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read
of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its
of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its
allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this
allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this
@@ -119,7 +119,7 @@ What used to read them, and what answers now (most of it in `server/media-proxy/
@@ -119,7 +119,7 @@ What used to read them, and what answers now (most of it in `server/media-proxy/
| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own |
| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own |
| AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never |
| AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never |
| Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |
| Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |
-| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` when it owns them | counts only |
+| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` | counts only |
| Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only |
| Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only |
| Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff |
| Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff |
| Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |
| Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |
@@ -221,7 +221,7 @@ SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v
@@ -221,7 +221,7 @@ SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v
SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k
SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k
WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');
WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');
SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p
SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p
- WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.legacy_media_id = p.id OR x.slug = p.slug);
+ WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.slug = p.slug);
SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l
SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l
WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);
WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);
SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc
SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc
@@ -476,7 +476,7 @@ function _channelPasteCardHTML(p, canManage) {
@@ -476,7 +476,7 @@ function _channelPasteCardHTML(p, canManage) {
const thumb = (isShot && p.screenshot_url)
const thumb = (isShot && p.screenshot_url)
? `<div class="ch-paste-thumb"><img src="${esc(p.screenshot_url)}" alt="" loading="lazy"></div>`
? `<div class="ch-paste-thumb"><img src="${esc(p.screenshot_url)}" alt="" loading="lazy"></div>`
: `<div class="ch-paste-thumb ch-paste-thumb-icon"><i class="fa-solid ${isShot ? 'fa-image' : 'fa-code'}"></i></div>`;
: `<div class="ch-paste-thumb ch-paste-thumb-icon"><i class="fa-solid ${isShot ? 'fa-image' : 'fa-code'}"></i></div>`;
- return `<a class="ch-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">
+ return `<a class="ch-paste-card" href="${esc(pasteHref(p.slug))}">
${thumb}
${thumb}
<div class="ch-paste-info">
<div class="ch-paste-info">
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
@@ -1533,7 +1533,7 @@ async function loadHomePastes(opts) {
@@ -1533,7 +1533,7 @@ async function loadHomePastes(opts) {
? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`
? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`
: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;
: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;
return `
return `
- <a class="home-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">
+ <a class="home-paste-card" href="${esc(pasteHref(p.slug))}">
${media}
${media}
<div class="home-paste-body">
<div class="home-paste-body">
<div class="home-paste-info">
<div class="home-paste-info">
@@ -1025,6 +1025,12 @@ function whenRouteReady(pageId, render) {
@@ -1025,6 +1025,12 @@ function whenRouteReady(pageId, render) {
});
});
}
}
+/** A paste's page on openvibe.community (the shell's ov-pastes-base meta names it). */
+function pasteHref(slug) {
+ const meta = document.querySelector('meta[name="ov-pastes-base"]');
+ return `${meta ? meta.content : ''}/p/${encodeURIComponent(slug)}`;
+}
+
/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive
/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive
* signed in; guests go straight to the page. */
* signed in; guests go straight to the page. */
function _pasteHandOver(target) {
function _pasteHandOver(target) {
@@ -72,7 +72,7 @@ function renderPasteCard(p) {
@@ -72,7 +72,7 @@ function renderPasteCard(p) {
const nsfwBlur = p.is_nsfw ? ' paste-card-nsfw-blur' : '';
const nsfwBlur = p.is_nsfw ? ' paste-card-nsfw-blur' : '';
const _cardInner = `
const _cardInner = `
- <a class="paste-card${nsfwBlur}" href="/p/${p.slug}" onclick="return handleLinkClick(event, '/p/${p.slug}')">
+ <a class="paste-card${nsfwBlur}" href="${escapeHtml(pasteHref(p.slug))}">
${thumb}
${thumb}
<div class="paste-card-info">
<div class="paste-card-info">
<div class="paste-card-title">${pinBadge}${nsfwBadge}${escapeHtml(p.title)}${burnBadge}</div>
<div class="paste-card-title">${pinBadge}${nsfwBadge}${escapeHtml(p.title)}${burnBadge}</div>
@@ -425,9 +425,8 @@ async function tick(opts = {}) {
@@ -425,9 +425,8 @@ async function tick(opts = {}) {
// never knew Community pastes (every moment since the move had a broken image).
// never knew Community pastes (every moment since the move had a broken image).
let shot = paste.screenshot_url || null;
let shot = paste.screenshot_url || null;
if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }
if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }
- img = media.publicUrl(shot) || (pastesClient.onCommunity()
- ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`
- : `${media.pasteUrl(pasteSlug)}/screenshot`);
+ img = media.publicUrl(shot)
+ || `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`;
}
}
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
@@ -233,8 +233,8 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -233,8 +233,8 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
}
}
});
});
-// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community — or
-// OpenVibe.Media while PASTES_AUTHORITY is unset). Pastes have no metadata filter upstream, so
+// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community).
+// Pastes have no metadata filter upstream, so
// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.
// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.
router.get('/avatar/history', requireAuth, async (req, res) => {
router.get('/avatar/history', requireAuth, async (req, res) => {
try {
try {
@@ -536,7 +536,7 @@ effectsRouter.post('/hardware', (req, res) => {
@@ -536,7 +536,7 @@ effectsRouter.post('/hardware', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
-// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).
+// /paste from chat, through Live's pastes client (OpenVibe.Community).
effectsRouter.post('/paste', async (req, res) => {
effectsRouter.post('/paste', async (req, res) => {
const b = req.body || {};
const b = req.body || {};
try {
try {
@@ -269,9 +269,8 @@ const SOURCES = {
@@ -269,9 +269,8 @@ const SOURCES = {
},
},
};
};
-/** People's or AI pastes from OpenVibe.Community. Pastes are only listed once Community owns them. */
+/** People's or AI pastes from OpenVibe.Community. */
async function communityPastes(origin, offset, n, { sort, since }) {
async function communityPastes(origin, offset, n, { sort, since }) {
- if (!pastesClient.onCommunity()) { const e = new Error('pastes are listed from OpenVibe.Community only'); e.unavailable = true; throw e; }
const r = await pastesClient.request('GET', '', {
const r = await pastesClient.request('GET', '', {
query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },
query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },
timeoutMs: SOURCE_TIMEOUT_MS,
timeoutMs: SOURCE_TIMEOUT_MS,
@@ -541,7 +541,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
@@ -541,7 +541,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
-// Pastes on openvibe.community (PASTES_ON_COMMUNITY=1): /p/<slug> redirects there, and it is mounted
+// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted
// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
require('./web/paste-handover').register(app);
require('./web/paste-handover').register(app);
try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
@@ -643,15 +643,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
@@ -643,15 +643,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
});
-// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
-// against THIS origin — bounce them to the Media public host, where the
-// canonical paste page lives.
+// Old relative paste URLs on THIS origin: screenshot bytes still live on the Media public host;
+// raw text is served by OpenVibe.Community, the paste's home.
app.get('/p/:slug/screenshot', (req, res) => {
app.get('/p/:slug/screenshot', (req, res) => {
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
res.redirect(302, `${mediaClient.MEDIA_PUBLIC_URL}/p/${encodeURIComponent(req.params.slug)}/screenshot`);
res.redirect(302, `${mediaClient.MEDIA_PUBLIC_URL}/p/${encodeURIComponent(req.params.slug)}/screenshot`);
});
});
app.get('/p/:slug/raw', (req, res) => {
app.get('/p/:slug/raw', (req, res) => {
- res.redirect(302, mediaClient.pasteRawUrl(req.params.slug));
+ res.redirect(302, `${require('./web/paste-handover').communityUrl()}/p/${encodeURIComponent(req.params.slug)}/raw`);
});
});
// Offline-screen assets (channel offline background: webp images + transcoded webm)
// Offline-screen assets (channel offline background: webp images + transcoded webm)
@@ -271,43 +271,6 @@ function recutClip(clipId, opts = {}) {
@@ -271,43 +271,6 @@ function recutClip(clipId, opts = {}) {
return request('POST', `/clips/${clipId}/recut`, opts);
return request('POST', `/clips/${clipId}/recut`, opts);
}
}
-// ── Pastes ───────────────────────────────────────────────────────────────────
-
-/**
- * POST /pastes { title?, content?, language?, user_id?, visibility?, screenshot (multipart)? }
- * → { id, slug, url }. Pass `screenshot` as Buffer or {buffer, filename, contentType}.
- */
-function createPaste({ screenshot, ...fields } = {}, opts = {}) {
- if (screenshot) {
- const fd = _formData(fields, screenshot, 'screenshot');
- return request('POST', '/pastes', { body: fd, timeoutMs: 60000, ...opts });
- }
- return request('POST', '/pastes', { body: fields, ...opts });
-}
-
-async function getPaste(slug, opts = {}) {
- const out = await request('GET', `/pastes/${encodeURIComponent(slug)}`, opts);
- return (out && out.paste) || out; // Media wraps single pastes as { paste }
-}
-
-function listPastes(query = {}, opts = {}) {
- return request('GET', '/pastes', { query, ...opts });
-}
-
-/** Pastes awaiting AI analysis (app-key only on Media). */
-function listPastesNeedingAi(limit = 5, opts = {}) {
- return request('GET', '/pastes', { query: { needs_ai: 1, limit }, ...opts });
-}
-
-/** Write AI results back to a paste. */
-function setPasteAi(slug, { ai_summary, ai_tags } = {}, opts = {}) {
- return request('POST', `/pastes/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags }, ...opts });
-}
-
-function deletePaste(slug, opts = {}) {
- return request('DELETE', `/pastes/${encodeURIComponent(slug)}`, opts);
-}
-
// ── Files ────────────────────────────────────────────────────────────────────
// ── Files ────────────────────────────────────────────────────────────────────
/** POST /files (multipart) → { key, url, size, mime } */
/** POST /files (multipart) → { key, url, size, mime } */
@@ -451,8 +414,6 @@ module.exports = {
@@ -451,8 +414,6 @@ module.exports = {
getVod, listVods, updateVod, deleteVod,
getVod, listVods, updateVod, deleteVod,
// clips
// clips
createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,
createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,
- // pastes
- createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,
// files + thumbnails
// files + thumbnails
uploadFile, getFileMeta, deleteFile,
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
uploadThumbnail, generateThumbnail,
@@ -265,19 +265,14 @@ async function topContentRanges(user) {
@@ -265,19 +265,14 @@ async function topContentRanges(user) {
};
};
}
}
-// ── Pastes (OpenVibe.Community; OpenVibe.Media while PASTES_AUTHORITY is unset) ──────────────
+// ── Pastes (OpenVibe.Community) ─────────────────────────────────────────────────────────────
/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */
/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */
async function _listUserPastes(user, { limit = 30, type, hidden = false }) {
async function _listUserPastes(user, { limit = 30, type, hidden = false }) {
- let out;
- if (pastesClient.onCommunity()) {
- // Community lists one person's pastes by username. Hidden ones only for the owner (act as
- // them) or staff (X-OV-Staff): Live says which, after its own check of the caller.
- const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
- out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
- } else {
- out = await media.listPastes({ user_id: user.id, include_unlisted: hidden ? 1 : undefined, type, limit }, { timeoutMs: TIMEOUT_MS });
- }
+ // Community lists one person's pastes by username. Hidden ones only for the owner (act as them)
+ // or staff (X-OV-Staff): Live says which, after its own check of the caller.
+ const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
+ const out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
let pastes = rowsOf(out, 'pastes');
let pastes = rowsOf(out, 'pastes');
if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');
if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');
return { pastes, total: totalOf(out, pastes) };
return { pastes, total: totalOf(out, pastes) };
@@ -341,11 +336,10 @@ async function siteMediaStats() {
@@ -341,11 +336,10 @@ async function siteMediaStats() {
let _pasteStats = { at: 0, data: null };
let _pasteStats = { at: 0, data: null };
/**
/**
- * Paste totals from the service that owns pastes: Community's counts under PASTES_AUTHORITY=community
- * ({ pastes, pasteText, pasteImages }), else null (Media's /stats already carries them).
+ * Paste totals from OpenVibe.Community ({ pastes, pasteText, pasteImages }), or null while it is
+ * unreachable. Cached five minutes; retried 30 s after a failure.
*/
*/
async function sitePasteStats() {
async function sitePasteStats() {
- if (!pastesClient.onCommunity()) return null;
if (Date.now() - _pasteStats.at < SITE_STATS_TTL_MS) return _pasteStats.data;
if (Date.now() - _pasteStats.at < SITE_STATS_TTL_MS) return _pasteStats.data;
try {
try {
const out = await pastesClient.request('GET', '/admin/stats', { act: { staff: true }, timeoutMs: TIMEOUT_MS });
const out = await pastesClient.request('GET', '/admin/stats', { act: { staff: true }, timeoutMs: TIMEOUT_MS });
@@ -1,44 +1,21 @@
@@ -1,44 +1,21 @@
/**
/**
- * OpenVibe.Live — /api/pastes proxy (OpenVibe.Media backed)
+ * OpenVibe.Live — /api/pastes proxy (OpenVibe.Community backed)
*
*
- * The paste system (incl. like/comment/fork endpoints, preserved from the
- * inherited code) lives in OpenVibe.Media under /api/v1/live/pastes/... .
- * This router forwards the SPA's existing /api/pastes/* calls 1:1. Identity is resolved
- * HERE — every forwarding route runs optionalAuth/requireAuth first, and media-client
- * sends the resulting Live-local user id to Media. Forwarding the caller's Network JWT
- * and letting Media read the identity out of it (what this used to do) filed writes
- * under the Network's id for the account, which is a different number from the local
- * one and belongs to an unrelated local user.
+ * OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every
+ * call there with Live's service token; the person is named by their canonical subject (never by a
+ * Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.
*
*
- * Live-local exceptions:
+ * Live-local exception:
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
- * - screenshot upload → re-wrapped as multipart POST /pastes
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
-const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
const { can } = require('../auth/permissions');
const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
-// Admin/moderation endpoints on Media are app-key-only: authorize the caller
-// as a Live admin here, then forward WITHOUT the user token so the app key applies.
-function forwardAsApp(subPath) {
- return (req, res) => {
- const p = typeof subPath === 'function' ? subPath(req) : subPath;
- media.proxy(req, res, `/pastes${p}`)
- .catch((err) => {
- console.warn('[Pastes proxy]', err.message);
- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
- });
- };
-}
-
-const router = express.Router();
-const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 15 * 1024 * 1024 } });
-// Anonymous pastes and comments are allowed, but Media does not rate-limit app-key calls, so the
-// limit for anonymous writes lives here.
+// Anonymous pastes and comments are allowed; the limit for anonymous writes lives here.
// (A restore drill refuses every write, so it has no limiter and no limiter timer.)
// (A restore drill refuses every write, so it has no limiter and no limiter timer.)
const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next() : require('express-rate-limit')({
const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next() : require('express-rate-limit')({
windowMs: 10 * 60 * 1000,
windowMs: 10 * 60 * 1000,
@@ -49,216 +26,12 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
@@ -49,216 +26,12 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
message: { error: 'Too many anonymous posts — sign in or try again later' },
message: { error: 'Too many anonymous posts — sign in or try again later' },
});
});
-/**
- * Every request reaches Media with Live's app key. Media treats an app-key call that names no acting
- * user as the app itself — full authority: it trusts `user_id` in the body and query, lists private
- * pastes, and moderates comments freely. A signed-in caller is pinned by X-OV-User-Id and gets
- * normal ownership checks; an anonymous one would get app authority. So identity fields a browser
- * sends are dropped here, always, and owner-view switches are dropped for anonymous callers.
- */
-const IDENTITY_FIELDS = ['user_id', 'userId', 'author_id', 'owner_id'];
-const OWNER_VIEW_FIELDS = ['include_unlisted', 'include_private', 'mine'];
-function scrubIdentity(req) {
- const anon = media.actingUserFrom(req) == null;
- if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
- for (const k of IDENTITY_FIELDS) delete req.body[k];
- }
- if (anon && req.query) for (const k of OWNER_VIEW_FIELDS) delete req.query[k];
- return anon;
-}
-
-function forward(subPath) {
- return (req, res) => {
- scrubIdentity(req);
- const p = typeof subPath === 'function' ? subPath(req) : subPath;
- media.proxy(req, res, `/pastes${p}`, { actingUser: media.actingUserFrom(req) })
- .catch((err) => {
- console.warn('[Pastes proxy]', err.message);
- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
- });
- };
-}
-
-const slugPath = (suffix = '') => (req) => `/${encodeURIComponent(req.params.slug)}${suffix}`;
-
-// ── Screenshot upload (multipart re-wrap) ────────────────────
-router.post('/screenshot', optionalAuth, anonWriteLimiter, shotUpload.single('screenshot'), async (req, res) => {
- try {
- if (!req.file) return res.status(400).json({ error: 'No screenshot uploaded' });
- const out = await media.createPaste({
- title: req.body.title || 'Screenshot',
- content: req.body.description || '',
- language: 'text',
- visibility: req.body.visibility || 'public',
- user_id: req.user?.id || undefined,
- stream_id: req.body.stream_id || undefined,
- metadata: req.body.metadata || undefined,
- screenshot: {
- buffer: req.file.buffer,
- filename: req.file.originalname || 'screenshot.png',
- contentType: req.file.mimetype || 'image/png',
- },
- }, { actingUser: media.actingUserFrom(req) });
- const paste = out?.paste || out; // Media returns { id, slug, url, paste }
- res.status(201).json({ paste, url: out?.url || (paste?.slug ? `/p/${paste.slug}` : null) });
- } catch (err) {
- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
- console.warn('[Pastes proxy] screenshot:', err.message);
- res.status(502).json({ error: 'Media service unavailable' });
- }
-});
-
-// ── Set an image paste as my avatar (Live-local user record) ─
-router.post('/:slug/set-avatar', requireAuth, async (req, res) => {
- try {
- let paste;
- try { paste = await media.getPaste(req.params.slug); } catch { paste = null; }
- if (!paste) return res.status(404).json({ error: 'Paste not found' });
- if (paste.type !== 'screenshot' && !paste.screenshot_url && !paste.screenshot_path) {
- return res.status(400).json({ error: 'That paste is not an image' });
- }
- const avatarUrl = media.publicUrl(paste.screenshot_url)
- || (paste.screenshot_path ? media.screenshotUrl(require('path').basename(paste.screenshot_path)) : media.pasteRawUrl(paste.slug));
- db.updateUserAvatar(req.user.id, avatarUrl, paste.id || null);
- // The avatar belongs to the network account: every other OpenVibe site shows it too.
- try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* picked up at the next sign-in */ }
- res.json({ success: true, avatar_url: avatarUrl });
- } catch (err) {
- console.warn('[Pastes proxy] set-avatar:', err.message);
- res.status(500).json({ error: 'Failed to set avatar' });
- }
-});
-
-// ── Everything else: transparent passthrough ─────────────────
-// Media only stores our numeric user ids — resolve author names locally so
-// cards don't all read "Anonymous", and translate ?username= into user_id.
-function _nameUsers(rows) {
- for (const p of rows || []) {
- if (p && p.user_id != null && !p.username) {
- const u = db.getUserById(p.user_id);
- if (u) { p.username = u.username; p.display_name = u.display_name; p.profile_color = u.profile_color; p.avatar_url = u.avatar_url; }
- }
- }
- return rows;
-}
-function forwardEnriched(subPath, pick) {
- return async (req, res) => {
- try {
- const anon = scrubIdentity(req);
- const query = { ...req.query };
- if (query.username && query.username !== 'all') {
- const u = db.getUserByUsername(query.username);
- delete query.username;
- if (!u) return res.json({ pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
- query.user_id = u.id;
- }
- const p = typeof subPath === 'function' ? subPath(req) : subPath;
- const out = await media.request('GET', `/pastes${p}`, { query, actingUser: media.actingUserFrom(req) });
- // Media shows private pastes to the app itself, which is what an anonymous call looks like.
- if (anon && out && out.paste && out.paste.visibility === 'private') return res.status(404).json({ error: 'Paste not found' });
- if (anon && out && Array.isArray(out.pastes)) out.pastes = out.pastes.filter((x) => !x || x.visibility !== 'private');
- _nameUsers(pick(out));
- res.json(out);
- } catch (err) {
- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
- console.warn('[Pastes proxy]', err.message);
- res.status(502).json({ error: 'Media service unavailable' });
- }
- };
-}
-router.get('/', optionalAuth, forwardEnriched('', (o) => o?.pastes));
-router.post('/', optionalAuth, anonWriteLimiter, forward(''));
-router.get('/config', forward('/config'));
-router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));
-router.delete('/admin/forks', requireAdmin, forwardAsApp('/admin/forks'));
-router.post('/bulk', requireAdmin, forwardAsApp('/bulk'));
-// Media doesn't know usernames — resolve locally, then list by user id.
-// Rows written before the id-space fix (when the SPA's Network JWT was forwarded to
-// Media, which then filed the write under the NETWORK id) still carry that id, so keep
-// asking for both and merge. New rows are always written with the local id.
-router.get('/by-user/:username', optionalAuth, async (req, res) => {
- try {
- const user = db.getUserByUsername(req.params.username);
- if (!user) return res.status(404).json({ error: 'User not found' });
- let networkId = null;
- try {
- const row = db.getDb().prepare("SELECT service_user_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(user.id);
- if (row && row.service_user_id != null) networkId = row.service_user_id;
- } catch { /* */ }
- const limit = Math.min(Math.max(parseInt(req.query.limit || '30', 10), 1), 100);
- const sort = req.query.sort === 'oldest' ? 'oldest' : 'newest';
- // Viewing your OWN paste list includes your unlisted/private ones — otherwise an
- // unlisted paste is invisible everywhere and looks like it was never created.
- // Anyone else viewing this user's page still sees public pastes only.
- //
- // Local ids only. req.user.id has always been the local one, so also accepting a
- // match against the VIEWED user's network id just meant that whoever happened to
- // hold that number as their local id — an unrelated person — was treated as the
- // owner here, and Media trusts this gate rather than re-checking it.
- const viewingSelf = !!(req.user && String(req.user.id) === String(user.id));
- const mine = viewingSelf ? { include_unlisted: 1 } : {};
- const lists = await Promise.all([
- media.listPastes({ user_id: user.id, limit, sort, ...mine }).catch(() => null),
- networkId != null && String(networkId) !== String(user.id)
- ? media.listPastes({ user_id: networkId, limit, sort, ...mine }).catch(() => null)
- : null,
- ]);
- const seen = new Set();
- const pastes = lists.flatMap(l => l?.pastes || [])
- .filter(p => !seen.has(p.slug) && seen.add(p.slug))
- .sort((a, b) => (sort === 'oldest' ? 1 : -1) * (new Date(a.created_at) - new Date(b.created_at)))
- .slice(0, limit);
- res.json({ pastes: _nameUsers(pastes), total: pastes.length, username: user.username });
- } catch (err) {
- console.warn('[Pastes proxy] by-user:', err.message);
- res.status(502).json({ error: 'Media service unavailable' });
- }
-});
-router.get('/:slug', optionalAuth, forwardEnriched(slugPath(), (o) => (o?.paste ? [o.paste] : [o])));
-router.put('/:slug', requireAuth, forward(slugPath()));
-router.delete('/:slug', requireAuth, forward(slugPath()));
-// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)
-// have no Media API v1 endpoints yet — they 404 until Media grows them.
-// Censor replaces the screenshot: multer consumed the multipart body, so re-wrap
-// it as a fresh multipart upstream request (app-key auth — Media censor is admin-only).
-router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), async (req, res) => {
- try {
- if (!req.file) return res.status(400).json({ error: 'No replacement screenshot uploaded' });
- const fd = media._formData({}, {
- buffer: req.file.buffer,
- filename: req.file.originalname || 'censored.png',
- contentType: req.file.mimetype || 'image/png',
- }, 'screenshot');
- const out = await media.request('POST', `/pastes/${encodeURIComponent(req.params.slug)}/censor`, { body: fd, timeoutMs: 60000 });
- res.json(out);
- } catch (err) {
- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
- console.warn('[Pastes proxy] censor:', err.message);
- res.status(502).json({ error: 'Media service unavailable' });
- }
-});
-router.post('/:slug/fork', optionalAuth, anonWriteLimiter, forward(slugPath('/fork')));
-// Raw content is public on Media (/p/:slug/raw) — bounce the API-shaped URL there.
-router.get('/:slug/raw', (req, res) => res.redirect(302, media.pasteRawUrl(req.params.slug)));
-router.post('/:slug/like', requireAuth, forward(slugPath('/like')));
-router.post('/:slug/copy', optionalAuth, forward(slugPath('/copy')));
-router.get('/:slug/comments', optionalAuth, forwardEnriched(slugPath('/comments'), (o) => o?.comments));
-// optionalAuth, not requireAuth: anonymous comments are allowed, and Media decides
-// whether they are enabled. What matters is that a signed-in commenter is named.
-router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath('/comments')));
-// Deleting needs an identity: without one Media sees the app key alone and lets it delete anything.
-router.delete('/:slug/comments/:commentId', requireAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
-
-// ── Community authority (PASTES_AUTHORITY=community, roadmap Wave 5) ──────────────
-// OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every
-// call there with Live's service token; the person is named by their canonical subject (never by a
-// Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.
-// Only set-avatar stays here, because it changes Live's own user row.
const pastesClient = require('../pastes-client');
const pastesClient = require('../pastes-client');
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
const { Readable } = require('stream');
const { Readable } = require('stream');
-function toCommunity(subPath) {
+/** `whenDown(req)`: the answer while Community is unreachable or failing (5xx), instead of a 502. */
+function toCommunity(subPath, whenDown = null) {
return async (req, res) => {
return async (req, res) => {
try {
try {
const p = typeof subPath === 'function' ? subPath(req) : subPath;
const p = typeof subPath === 'function' ? subPath(req) : subPath;
@@ -280,6 +53,7 @@ function toCommunity(subPath) {
@@ -280,6 +53,7 @@ function toCommunity(subPath) {
else if (ct) { headers['Content-Type'] = ct; opts.body = Readable.toWeb(req); opts.duplex = 'half'; }
else if (ct) { headers['Content-Type'] = ct; opts.body = Readable.toWeb(req); opts.duplex = 'half'; }
}
}
const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);
const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);
+ if (whenDown && upstream.status >= 500) return res.json(whenDown(req));
if (upstream.status === 401) principal.invalidate('openvibe.community');
if (upstream.status === 401) principal.invalidate('openvibe.community');
res.status(upstream.status);
res.status(upstream.status);
for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
@@ -289,7 +63,10 @@ function toCommunity(subPath) {
@@ -289,7 +63,10 @@ function toCommunity(subPath) {
res.send(Buffer.from(await upstream.arrayBuffer()));
res.send(Buffer.from(await upstream.arrayBuffer()));
} catch (err) {
} catch (err) {
console.warn('[Pastes → Community]', err.message);
console.warn('[Pastes → Community]', err.message);
- if (!res.headersSent) res.status(502).json({ error: 'Paste service unavailable' });
+ if (!res.headersSent) {
+ if (whenDown) return res.json(whenDown(req));
+ res.status(502).json({ error: 'Paste service unavailable' });
+ }
}
}
};
};
}
}
@@ -316,8 +93,13 @@ communityRouter.delete('/admin/forks', requireAdmin, toCommunity('/admin/forks')
@@ -316,8 +93,13 @@ communityRouter.delete('/admin/forks', requireAdmin, toCommunity('/admin/forks')
communityRouter.post('/bulk', requireAdmin, toCommunity('/bulk'));
communityRouter.post('/bulk', requireAdmin, toCommunity('/bulk'));
communityRouter.post('/:slug/censor', requireAdmin, toCommunity((req) => `/${encodeURIComponent(req.params.slug)}/censor`));
communityRouter.post('/:slug/censor', requireAdmin, toCommunity((req) => `/${encodeURIComponent(req.params.slug)}/censor`));
communityRouter.post(['/', '/screenshot', '/:slug/fork', '/:slug/comments'], optionalAuth, anonWriteLimiter, toCommunity((req) => req.path === '/' ? '' : req.path));
communityRouter.post(['/', '/screenshot', '/:slug/fork', '/:slug/comments'], optionalAuth, anonWriteLimiter, toCommunity((req) => req.path === '/' ? '' : req.path));
+// Editing, deleting, liking and removing a comment need a signed-in person: refused here, before Community.
+communityRouter.put('/:slug', requireAuth, toCommunity((req) => req.path));
+communityRouter.delete(['/:slug', '/:slug/comments/:commentId'], requireAuth, toCommunity((req) => req.path));
+communityRouter.post('/:slug/like', requireAuth, toCommunity((req) => req.path));
+// A channel's or dashboard's paste tab shows an empty list while Community is down, not an error.
+communityRouter.get('/by-user/:username', optionalAuth, toCommunity((req) => req.path, (req) => ({ pastes: [], total: 0, username: req.params.username })));
communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '' : req.path));
communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '' : req.path));
-module.exports = (req, res, next) => (pastesClient.onCommunity() ? communityRouter : router)(req, res, next);
-module.exports.mediaRouter = router;
+module.exports = communityRouter;
module.exports.communityRouter = communityRouter;
module.exports.communityRouter = communityRouter;
@@ -2,20 +2,15 @@
@@ -2,20 +2,15 @@
/**
/**
* Where Live's pastes live (roadmap Wave 5).
* Where Live's pastes live (roadmap Wave 5).
*
*
- * PASTES_AUTHORITY=community: OpenVibe.Community owns pastes. Live calls its /api/pastes with a
- * service token (community.paste.create/write/moderate) and names the person it acts for in
- * X-OV-Subject (their canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never
- * filed under a person (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.
- * Anything else (default): the old path through OpenVibe.Media's app API (media-client).
- *
- * Same function shapes as media-client's paste helpers, so call sites only swap the require.
+ * OpenVibe.Community owns pastes. Live calls its /api/pastes with a service token
+ * (community.paste.create/write/moderate) and names the person it acts for in X-OV-Subject (their
+ * canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never filed under a person
+ * (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.
*/
*/
-const media = require('./media-client');
const principal = require('./net/network-principal');
const principal = require('./net/network-principal');
const COMMUNITY_URL = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
const COMMUNITY_URL = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
const AUDIENCE = 'openvibe.community';
const AUDIENCE = 'openvibe.community';
-const onCommunity = () => process.env.PASTES_AUTHORITY === 'community';
class CommunityApiError extends Error {
class CommunityApiError extends Error {
constructor(status, body) {
constructor(status, body) {
@@ -83,9 +78,8 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20
@@ -83,9 +78,8 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20
return out;
return out;
}
}
-/** media-client-compatible createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */
+/** createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */
async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
- if (!onCommunity()) return media.createPaste({ screenshot, user_id, ...fields }, opts);
const act = opts.origin === 'ai'
const act = opts.origin === 'ai'
? { origin: 'ai', sourceRef: fields.stream_id ? { service: 'live', type: 'stream', id: String(fields.stream_id) } : undefined }
? { origin: 'ai', sourceRef: fields.stream_id ? { service: 'live', type: 'stream', id: String(fields.stream_id) } : undefined }
: { liveUserId: user_id };
: { liveUserId: user_id };
@@ -100,24 +94,20 @@ async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
@@ -100,24 +94,20 @@ async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
}
}
async function getPaste(slug) {
async function getPaste(slug) {
- if (!onCommunity()) return media.getPaste(slug);
const out = await request('GET', `/${encodeURIComponent(slug)}`, { query: { no_view: 1 } });
const out = await request('GET', `/${encodeURIComponent(slug)}`, { query: { no_view: 1 } });
return (out && out.paste) || out;
return (out && out.paste) || out;
}
}
function listPastes(query = {}) {
function listPastes(query = {}) {
- if (!onCommunity()) return media.listPastes(query);
return request('GET', '', { query });
return request('GET', '', { query });
}
}
function listPastesNeedingAi(limit = 5) {
function listPastesNeedingAi(limit = 5) {
- if (!onCommunity()) return media.listPastesNeedingAi(limit);
return request('GET', '', { query: { needs_ai: 1, limit } });
return request('GET', '', { query: { needs_ai: 1, limit } });
}
}
function setPasteAi(slug, { ai_summary, ai_tags } = {}) {
function setPasteAi(slug, { ai_summary, ai_tags } = {}) {
- if (!onCommunity()) return media.setPasteAi(slug, { ai_summary, ai_tags });
return request('POST', `/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags } });
return request('POST', `/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags } });
}
}
-module.exports = { onCommunity, request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };
+module.exports = { request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };
@@ -57,7 +57,7 @@ const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () =
@@ -57,7 +57,7 @@ const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () =
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
return {
return {
items: (out.items || []).map((it) => ({
items: (out.items || []).map((it) => ({
- url: it.kind === 'paste' && PASTES_BASE ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
+ url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
desc: it.excerpt,
desc: it.excerpt,
})),
})),
@@ -153,11 +153,12 @@ function _overlayAiState(row, kind) {
@@ -153,11 +153,12 @@ function _overlayAiState(row, kind) {
} catch { /* */ }
} catch { /* */ }
}
}
-// Pastes live on openvibe.community when PASTES_ON_COMMUNITY=1: /p/<slug> is a redirect there
-// (server/web/paste-handover.js, mounted before this middleware), the SPA router sends it there
-// too, and every paste link Live renders points straight at Community, the paste's one canonical
-// page. Live's sitemap never lists /p/ (roadmap 32.2).
-const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
+// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,
+// mounted before this middleware), and every paste link Live renders, here and in the SPA (which
+// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the
+// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).
+const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
+const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
const SITE_NAME = 'OpenVibe.Live';
const SITE_NAME = 'OpenVibe.Live';
@@ -887,7 +888,7 @@ function _headBlock(meta) {
@@ -887,7 +888,7 @@ function _headBlock(meta) {
const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;
const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const parts = [
const parts = [
- PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',
+ PASTES_META,
`<title>${esc(meta.title)}</title>`,
`<title>${esc(meta.title)}</title>`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
@@ -973,7 +974,7 @@ function shellHtml(urlPath, status) {
@@ -973,7 +974,7 @@ function shellHtml(urlPath, status) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
const p = String(urlPath || '/');
const p = String(urlPath || '/');
- html = assets.renderRoute(html, p);
+ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);
if (p === '/' && status !== 404) return html;
if (p === '/' && status !== 404) return html;
const headEnd = html.search(/<\/head>/i);
const headEnd = html.search(/<\/head>/i);
if (headEnd === -1) return html;
if (headEnd === -1) return html;
@@ -1029,7 +1030,7 @@ async function middleware(req, res, next) {
@@ -1029,7 +1030,7 @@ async function middleware(req, res, next) {
if (!SEO_ROUTE_RE.test(p)) return next();
if (!SEO_ROUTE_RE.test(p)) return next();
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// so if the mount order ever changes).
// so if the mount order ever changes).
- if (PASTES_BASE && p.startsWith('/p/')) return next();
+ if (p.startsWith('/p/')) return next();
// Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and
// Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and
// monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent
// monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent
// */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
// */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
@@ -1,36 +1,23 @@
@@ -1,36 +1,23 @@
'use strict';
'use strict';
/**
/**
- * Pastes live on openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep working
- * as redirects, so links in chat, search results and clipboards survive.
+ * Pastes live on openvibe.community: the old Live paste URLs keep working as a permanent redirect,
+ * so links in chat, search results and clipboards survive. Every paste link Live renders already
+ * points at Community, so this only answers old links.
*
*
* Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every
* Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every
* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
* each naming itself canonical, and a different answer for people and machines. Now every client
* each naming itself canonical, and a different answer for people and machines. Now every client
- * gets the same answer and openvibe.community is the one canonical home (roadmap 32.2).
- *
- * Someone signed in here should arrive signed in there: they go through Community's silent sign-in
- * (one quiet round trip; it skips itself when Community already has a valid session and falls back
- * to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.
+ * gets the same 301 and openvibe.community is the one canonical home (roadmap 32.2).
*
*
* /pastes itself stays on Live: it is the Content feed with the Pastes filter (public/js/content-feed.js).
* /pastes itself stays on Live: it is the Content feed with the Pastes filter (public/js/content-feed.js).
*/
*/
-const onCommunity = () => process.env.PASTES_ON_COMMUNITY === '1';
const communityUrl = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
const communityUrl = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
-function handOver(req, res, target) {
- const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));
- if (!signedIn) return res.redirect(301, `${communityUrl()}${target}`);
- res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });
- return res.redirect(302, `${communityUrl()}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
-}
-
-/** Mount the /p/:slug handover when pastes are on Community. Returns whether it was mounted. */
+/** Mount the /p/:slug redirect to Community. */
function register(app) {
function register(app) {
- if (!onCommunity()) return false;
- app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
- return true;
+ app.get('/p/:slug', (req, res) => res.redirect(301, `${communityUrl()}/p/${encodeURIComponent(req.params.slug)}`));
}
}
-module.exports = { register, onCommunity, communityUrl };
+module.exports = { register, communityUrl };
@@ -22,7 +22,6 @@ const http = require('http');
@@ -22,7 +22,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-process.env.PASTES_AUTHORITY = 'community';
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
console.warn = () => {};
@@ -315,17 +314,6 @@ server.on('listening', async () => {
@@ -315,17 +314,6 @@ server.on('listening', async () => {
assert.ok(none.json.next, 'the client may try again');
assert.ok(none.json.next, 'the client may try again');
});
});
- await check('pastes are listed only when Community owns them', async () => {
- process.env.PASTES_AUTHORITY = '';
- try {
- const r = await get('/api/content/feed?limit=30');
- assert.strictEqual(r.json.sources.pastes, 'unavailable');
- assert.strictEqual(r.json.partial, false, 'not a failure to retry');
- assert.ok(!r.json.items.some((i) => i.kind === 'paste'));
- assert.strictEqual(communityCalls.length, 0);
- } finally { process.env.PASTES_AUTHORITY = 'community'; }
- });
-
await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {
await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {
communityMode = 'lying-total';
communityMode = 'lying-total';
const r = await get('/api/content/feed?type=pastes&limit=5');
const r = await get('/api/content/feed?type=pastes&limit=5');
@@ -64,28 +64,19 @@ assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
@@ -64,28 +64,19 @@ assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
"Media's per-IP comment cooldown and its stored address both need the real client IP");
"Media's per-IP comment cooldown and its stored address both need the real client IP");
console.log('OK D: proxied requests carry the client IP, not this process\'s loopback');
console.log('OK D: proxied requests carry the client IP, not this process\'s loopback');
-// ── E: every forwarding route resolves identity first ────────────────────────────────
+// ── E: every paste route resolves identity before it reaches Community ───────────────
{
{
- // A forward() route sends whatever actingUserFrom finds on req — so a route with no
- // auth middleware silently posts as nobody.
- const lines = pastes.split('\n').filter(l => /^router\.(get|post|put|delete)\(/.test(l.trim()));
- const needsIdentity = lines.filter(l => /forward\(|forwardEnriched\(/.test(l) && !/forwardAsApp\(/.test(l));
- assert.ok(needsIdentity.length >= 8, `expected the forwarding routes, found ${needsIdentity.length}`);
- for (const line of needsIdentity) {
- if (/'\/config'/.test(line)) continue; // static config, no caller identity
+ // toCommunity() names whoever optionalAuth/requireAuth resolved, so a route with no auth
+ // middleware silently forwards as nobody.
+ const lines = pastes.split('\n').filter(l => /^communityRouter\.(get|post|put|delete|all)\(/.test(l.trim()));
+ assert.ok(lines.length >= 6, `expected the forwarding routes, found ${lines.length}`);
+ for (const line of lines) {
+ if (/'\/:slug\/raw'/.test(line)) continue; // a plain redirect, no caller identity
assert.ok(/optionalAuth|requireAuth|requireAdmin/.test(line),
assert.ok(/optionalAuth|requireAuth|requireAdmin/.test(line),
`route must resolve the caller before forwarding: ${line.trim()}`);
`route must resolve the caller before forwarding: ${line.trim()}`);
}
}
- console.log(`OK E: all ${needsIdentity.length - 1} identity-bearing paste routes resolve the caller first`);
-}
-
-// ── F: the owner gate compares one id space ──────────────────────────────────────────
-{
- const gate = pastes.slice(pastes.indexOf('const viewingSelf'), pastes.indexOf('const mine ='));
- assert.ok(/String\(req\.user\.id\) === String\(user\.id\)/.test(gate), 'owner check must compare local ids');
- assert.ok(!/networkId/.test(gate),
- 'matching the viewed user\'s NETWORK id let whoever holds that number locally read their unlisted pastes');
- console.log('OK F: unlisted-paste owner gate compares local ids only');
+ assert.ok(!/^router\./m.test(pastes) && !/mediaRouter/.test(pastes), 'the Media paste router is gone');
+ console.log(`OK E: all ${lines.length - 1} identity-bearing paste routes resolve the caller first`);
}
}
console.log('✅ media identity space test passed');
console.log('✅ media identity space test passed');
@@ -76,7 +76,6 @@ media.request = async (method, p) => {
@@ -76,7 +76,6 @@ media.request = async (method, p) => {
if (p === '/stats') return { vods: 11, clips: 22, pastes: 33, pasteImages: 3, pasteText: 30, durationSeconds: 7200, recent: { vods: { d: 1, w: 2, m: 3 } } };
if (p === '/stats') return { vods: 11, clips: 22, pastes: 33, pasteImages: 3, pasteText: 30, durationSeconds: 7200, recent: { vods: { d: 1, w: 2, m: 3 } } };
throw new media.MediaApiError('unexpected', 500);
throw new media.MediaApiError('unexpected', 500);
};
};
-media.listPastes = async (q = {}) => { asked.push({ kind: 'media-pastes', ...q }); return { pastes: [{ slug: 'm1', visibility: 'public' }, { slug: 'm2', visibility: 'private' }], total: 2 }; };
// ── OpenVibe.Community stand-in (pastes-client.request) ──
// ── OpenVibe.Community stand-in (pastes-client.request) ──
const pastesClient = require('../server/pastes-client');
const pastesClient = require('../server/pastes-client');
@@ -194,45 +193,33 @@ async function check(name, fn) {
@@ -194,45 +193,33 @@ async function check(name, fn) {
});
});
await check('channel badges: pastes from Community, taken clips from Media; hidden ones for owner/staff only', async () => {
await check('channel badges: pastes from Community, taken clips from Media; hidden ones for owner/staff only', async () => {
- process.env.PASTES_AUTHORITY = 'community';
- try {
- const anon = await call('GET', '/api/streams/channel/alice');
- assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));
- assert.strictEqual(anon.json.pasteTotal, 2);
- assert.strictEqual(anon.json.clipsTakenTotal, 1);
- lookups._resetCaches();
- const owner = await call('GET', '/api/streams/channel/alice', 3);
- assert.strictEqual(owner.json.pasteTotal, 4);
- assert.strictEqual(owner.json.clipsTakenTotal, 2);
- assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');
- lookups._resetCaches();
- const staff = await call('GET', '/api/streams/channel/alice', 1);
- assert.strictEqual(staff.json.pasteTotal, 4);
- assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');
- lookups._resetCaches();
- communityCalls.length = 0;
- await call('GET', '/api/streams/channel/alice', 7);
- assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');
- const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');
- assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');
- } finally { delete process.env.PASTES_AUTHORITY; }
- });
-
- await check('pastes while Media still holds them (PASTES_AUTHORITY unset): Media list, public filter', async () => {
- const out = await lookups.userPastes(db.getUserById(3));
- assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['m1']);
- assert.ok(asked.some((q) => q.kind === 'media-pastes' && q.user_id === 3 && q.include_unlisted === undefined));
+ const anon = await call('GET', '/api/streams/channel/alice');
+ assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));
+ assert.strictEqual(anon.json.pasteTotal, 2);
+ assert.strictEqual(anon.json.clipsTakenTotal, 1);
+ lookups._resetCaches();
+ const owner = await call('GET', '/api/streams/channel/alice', 3);
+ assert.strictEqual(owner.json.pasteTotal, 4);
+ assert.strictEqual(owner.json.clipsTakenTotal, 2);
+ assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');
+ lookups._resetCaches();
+ const staff = await call('GET', '/api/streams/channel/alice', 1);
+ assert.strictEqual(staff.json.pasteTotal, 4);
+ assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');
+ lookups._resetCaches();
+ communityCalls.length = 0;
+ await call('GET', '/api/streams/channel/alice', 7);
+ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');
+ const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');
+ assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');
});
});
await check('setup hub: the paste task counts the person\'s own pastes', async () => {
await check('setup hub: the paste task counts the person\'s own pastes', async () => {
- process.env.PASTES_AUTHORITY = 'community';
- try {
- const r = await call('GET', '/api/streams/setup-progress', 3);
- assert.strictEqual(r.status, 200);
- const task = r.json.tasks.find((t) => t.id === 'paste');
- assert.strictEqual(task.count, 4);
- assert.strictEqual(task.done, true);
- } finally { delete process.env.PASTES_AUTHORITY; }
+ const r = await call('GET', '/api/streams/setup-progress', 3);
+ assert.strictEqual(r.status, 200);
+ const task = r.json.tasks.find((t) => t.id === 'paste');
+ assert.strictEqual(task.count, 4);
+ assert.strictEqual(task.done, true);
});
});
await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {
await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {
@@ -255,14 +242,11 @@ async function check(name, fn) {
@@ -255,14 +242,11 @@ async function check(name, fn) {
});
});
await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
- process.env.PASTES_AUTHORITY = 'community';
- try {
- raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();
- const r = await call('GET', '/api/auth/avatar/history', 3);
- assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);
- assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));
- } finally { delete process.env.PASTES_AUTHORITY; }
+ raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();
+ const r = await call('GET', '/api/auth/avatar/history', 3);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);
+ assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));
});
});
await check('offline-screen ranges: most-viewed public VOD and clip per window', async () => {
await check('offline-screen ranges: most-viewed public VOD and clip per window', async () => {
@@ -295,36 +279,30 @@ async function check(name, fn) {
@@ -295,36 +279,30 @@ async function check(name, fn) {
});
});
await check('admin: VOD counts from Media; AI explorer pastes from Community and VODs/clips from Media', async () => {
await check('admin: VOD counts from Media; AI explorer pastes from Community and VODs/clips from Media', async () => {
- process.env.PASTES_AUTHORITY = 'community';
- try {
- raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));
- const st = await call('GET', '/api/admin/stats', 1);
- assert.strictEqual(st.status, 200, st.text.slice(0, 200));
- assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });
- const ex = await call('GET', '/api/admin/ai/explorer/3', 1);
- assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));
- assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');
- const v11 = ex.json.vods.find((v) => v.id === 11);
- assert.strictEqual(v11.ai_overview, 'private overview');
- assert.strictEqual(v11.ai_transcript, 'hello there');
- assert.strictEqual(ex.json.counts.clips, 3);
- assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');
- } finally { delete process.env.PASTES_AUTHORITY; }
+ raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));
+ const st = await call('GET', '/api/admin/stats', 1);
+ assert.strictEqual(st.status, 200, st.text.slice(0, 200));
+ assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });
+ const ex = await call('GET', '/api/admin/ai/explorer/3', 1);
+ assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));
+ assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');
+ const v11 = ex.json.vods.find((v) => v.id === 11);
+ assert.strictEqual(v11.ai_overview, 'private overview');
+ assert.strictEqual(v11.ai_transcript, 'hello there');
+ assert.strictEqual(ex.json.counts.clips, 3);
+ assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');
});
});
await check('home stats: Live counts none of the archive itself; Media and Community fill it in', async () => {
await check('home stats: Live counts none of the archive itself; Media and Community fill it in', async () => {
const local = db._computeHomeStats();
const local = db._computeHomeStats();
for (const k of ['vods', 'clips', 'pastes', 'pasteImages', 'pasteText', 'streamHours']) assert.strictEqual(local[k], null, k);
for (const k of ['vods', 'clips', 'pastes', 'pasteImages', 'pasteText', 'streamHours']) assert.strictEqual(local[k], null, k);
assert.strictEqual(db.getHomeStatSeries('vods'), null, 'the vods series is Media\'s');
assert.strictEqual(db.getHomeStatSeries('vods'), null, 'the vods series is Media\'s');
- process.env.PASTES_AUTHORITY = 'community';
- try {
- const s = await lookups.withArchiveStats({ ...local });
- assert.strictEqual(s.vods, 11);
- assert.strictEqual(s.streamHours, 2);
- assert.strictEqual(s.pastes, 44, 'Community owns the paste count');
- assert.strictEqual(s.pasteImages, 4);
- assert.strictEqual(s.recent.vods.w, 2);
- } finally { delete process.env.PASTES_AUTHORITY; }
+ const s = await lookups.withArchiveStats({ ...local });
+ assert.strictEqual(s.vods, 11);
+ assert.strictEqual(s.streamHours, 2);
+ assert.strictEqual(s.pastes, 44, 'Community owns the paste count');
+ assert.strictEqual(s.pasteImages, 4);
+ assert.strictEqual(s.recent.vods.w, 2);
});
});
await check('streamer overview job: memories are the signal', async () => {
await check('streamer overview job: memories are the signal', async () => {
@@ -22,7 +22,6 @@ const http = require('http');
@@ -22,7 +22,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
console.warn = () => {};
@@ -77,7 +76,6 @@ media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Num
@@ -77,7 +76,6 @@ media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Num
media.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
media.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
media.listVods = async () => ({ vods: [] });
media.listVods = async () => ({ vods: [] });
media.listClips = async () => ({ clips: [] });
media.listClips = async () => ({ clips: [] });
-media.listPastes = async () => ({ pastes: [] });
media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
const pastesClient = require('../server/pastes-client');
const pastesClient = require('../server/pastes-client');
pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };
pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };
@@ -1,14 +1,15 @@
@@ -1,14 +1,15 @@
/**
/**
- * One canonical page per paste (roadmap 32.2): with PASTES_ON_COMMUNITY=1, /p/<slug> on Live is a
- * redirect to openvibe.community for every client, crawlers and browsers alike.
+ * One canonical page per paste (roadmap 32.2): /p/<slug> on Live is a permanent redirect to
+ * openvibe.community for every client, crawlers, browsers and signed-in visitors alike.
*
*
* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
* the 301: two self-canonical pages and a different answer for people and machines. Checked here:
* the 301: two self-canonical pages and a different answer for people and machines. Checked here:
* - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;
* - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;
- * - /p/<slug> answers 301 to Community whatever the Accept header, and a signed-in visitor goes
- * through Community's silent sign-in (302);
- * - Live's sitemap lists no /p/ URL, and the paste links Live renders point at Community.
+ * - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw
+ * 302s to Community's raw text (not Media's);
+ * - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the
+ * SPA, are absolute Community URLs (no relative /p/ href left to bounce through Live).
*
*
* node test/paste-canonical.test.js
* node test/paste-canonical.test.js
*/
*/
@@ -22,7 +23,6 @@ const http = require('http');
@@ -22,7 +23,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-paste-canonical-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-paste-canonical-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-process.env.PASTES_ON_COMMUNITY = '1';
process.env.OV_COMMUNITY_URL = 'https://openvibe.community';
process.env.OV_COMMUNITY_URL = 'https://openvibe.community';
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@ -90,10 +90,18 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -90,10 +90,18 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.strictEqual(pasteReads, 0, 'Live never rendered its own copy');
assert.strictEqual(pasteReads, 0, 'Live never rendered its own copy');
});
});
- await check('a signed-in visitor goes through Community\'s silent sign-in', async () => {
+ await check('a signed-in visitor gets the same plain 301 (no silent sign-in hop)', async () => {
const r = await get('/p/abc123', { accept: 'text/html', cookie: 'ov_sso_hint=account' });
const r = await get('/p/abc123', { accept: 'text/html', cookie: 'ov_sso_hint=account' });
- assert.strictEqual(r.status, 302);
- assert.strictEqual(r.location, 'https://openvibe.community/auth/login?silent=1&next=%2Fp%2Fabc123');
+ assert.strictEqual(r.status, 301);
+ assert.strictEqual(r.location, 'https://openvibe.community/p/abc123');
+ });
+
+ await check('server/index.js sends /p/<slug>/raw to Community, screenshots to Media', () => {
+ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+ const raw = src.slice(src.indexOf("app.get('/p/:slug/raw'"), src.indexOf("app.get('/p/:slug/raw'") + 300);
+ assert.ok(/paste-handover'\)\.communityUrl\(\)\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/raw/.test(raw), raw);
+ assert.ok(!/pasteRawUrl/.test(raw), 'raw no longer goes through Media');
+ assert.ok(/MEDIA_PUBLIC_URL\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/screenshot/.test(src), 'screenshot bytes still on Media');
});
});
await check('Live\'s sitemap lists no paste', async () => {
await check('Live\'s sitemap lists no paste', async () => {
@@ -108,6 +116,21 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -108,6 +116,21 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(!m.snapshot.includes('https://openvibe.live/p/'), 'no Live paste link');
assert.ok(!m.snapshot.includes('https://openvibe.live/p/'), 'no Live paste link');
});
});
+ await check('the SPA renders absolute Community paste hrefs from the shell\'s ov-pastes-base', async () => {
+ for (const f of ['pastes.js', 'app-home.js', 'app-channel.js']) {
+ const src = fs.readFileSync(path.join(__dirname, '../public/js', f), 'utf8');
+ assert.ok(!/href="\/p\//.test(src), `${f} renders a relative /p/ href`);
+ assert.ok(/href="\$\{(?:esc|escapeHtml)\(pasteHref\(p\.slug\)\)\}"/.test(src), `${f} links through pasteHref()`);
+ }
+ const app = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
+ assert.ok(/function pasteHref\(slug\)[\s\S]{0,200}meta\[name="ov-pastes-base"\]/.test(app), 'pasteHref reads the shell meta');
+ const meta = '<meta name="ov-pastes-base" content="https://openvibe.community">';
+ assert.ok(seo.render(await seo._pageMeta('/pastes'), '/pastes').includes(meta), 'SEO pages name the Community base');
+ for (const [p, status] of [['/dashboard', 200], ['/no-such-page', 404]]) {
+ assert.ok(String(seo.shellHtml(p, status)).includes(meta), `${p} shell names the Community base`);
+ }
+ });
+
server.close();
server.close();
try { fs.unlinkSync(tmp); } catch { /* */ }
try { fs.unlinkSync(tmp); } catch { /* */ }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
-// PASTES_AUTHORITY=community (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with
+// Community is the only paste authority (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with
// a service token. AI pastes are ownerless (origin ai + source stream), a person is named by subject, the
// a service token. AI pastes are ownerless (origin ai + source stream), a person is named by subject, the
// SPA's /api/pastes forwards anonymously when nobody is signed in, and staff routes keep requireAdmin.
// SPA's /api/pastes forwards anonymously when nobody is signed in, and staff routes keep requireAdmin.
@@ -18,7 +18,6 @@ process.env.DB_PATH = path.join(tmp, 'live.db');
@@ -18,7 +18,6 @@ process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.INTERNAL_API_KEY = 'legacy-key';
process.env.INTERNAL_API_KEY = 'legacy-key';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-process.env.PASTES_AUTHORITY = 'community';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const SID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const SID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -51,6 +50,7 @@ const community = http.createServer((req, res) => {
@@ -51,6 +50,7 @@ const community = http.createServer((req, res) => {
const claims = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.community' });
const claims = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.community' });
seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, origin: req.headers['x-ov-origin'] || null, sourceRef: req.headers['x-ov-source-ref'] || null, xff: req.headers['x-forwarded-for'] || null, type: req.headers['content-type'] || '', body: Buffer.concat(chunks).toString('latin1'), tokenOk: claims.ok });
seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, origin: req.headers['x-ov-origin'] || null, sourceRef: req.headers['x-ov-source-ref'] || null, xff: req.headers['x-forwarded-for'] || null, type: req.headers['content-type'] || '', body: Buffer.concat(chunks).toString('latin1'), tokenOk: claims.ok });
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
+ if (req.url.startsWith('/api/pastes/by-user/failing')) { res.statusCode = 503; return res.end('{}'); }
if (req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ id: 9, slug: 'new-slug-1', url: '/p/new-slug-1', paste: { slug: 'new-slug-1', origin: req.headers['x-ov-origin'] || 'user' } })); }
if (req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ id: 9, slug: 'new-slug-1', url: '/p/new-slug-1', paste: { slug: 'new-slug-1', origin: req.headers['x-ov-origin'] || 'user' } })); }
res.end(JSON.stringify({ pastes: [], total: 0 }));
res.end(JSON.stringify({ pastes: [], total: 0 }));
});
});
@@ -120,9 +120,27 @@ const community = http.createServer((req, res) => {
@@ -120,9 +120,27 @@ const community = http.createServer((req, res) => {
assert.strictEqual(r.status, 302);
assert.strictEqual(r.status, 302);
assert.ok(r.headers.get('location').endsWith('/p/some-slug/raw'));
assert.ok(r.headers.get('location').endsWith('/p/some-slug/raw'));
- // Default mode goes to Media exactly as before.
- process.env.PASTES_AUTHORITY = '';
- assert.strictEqual(client.onCommunity(), false);
+ // Writes that need a person are refused here, before Community.
+ const before = seen.length;
+ for (const [method, p] of [['PUT', '/some-slug'], ['DELETE', '/some-slug'], ['POST', '/some-slug/like'], ['DELETE', '/some-slug/comments/1']]) {
+ r = await fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json' }, body: method === 'DELETE' ? undefined : '{}' });
+ assert.strictEqual(r.status, 401, `anonymous ${method} ${p}`);
+ }
+ assert.strictEqual(seen.length, before, 'none of them reached Community');
+ // A person's paste list is forwarded, and reads as empty (not an error) while Community fails.
+ r = await fetch(`${base}/by-user/ann?limit=30`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(seen.pop().url, '/api/pastes/by-user/ann?limit=30');
+ r = await fetch(`${base}/by-user/failing?limit=30`);
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(await r.json(), { pastes: [], total: 0, username: 'failing' });
+
+ // Community is the only mode: no switch, no Media fallback, no Media router.
+ assert.strictEqual(client.onCommunity, undefined, 'the paste-authority switch is gone');
+ assert.strictEqual(require('../server/media-proxy/pastes'), require('../server/media-proxy/pastes').communityRouter, '/api/pastes is the Community router');
+ assert.strictEqual(require('../server/media-proxy/pastes').mediaRouter, undefined, 'the Media router is gone');
+ const clientSrc = fs.readFileSync(path.join(__dirname, '../server/pastes-client.js'), 'utf8');
+ assert.ok(!/media-client/.test(clientSrc) && !clientSrc.includes('PASTES_' + 'AUTHORITY'), 'the pastes client never falls back to Media');
srv.close(); net.close(); community.close();
srv.close(); net.close(); community.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -22,7 +22,6 @@ const path = require('path');
@@ -22,7 +22,6 @@ const path = require('path');
const tmp = path.join(os.tmpdir(), `ov-seo-moments-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-seo-moments-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
console.warn = () => {};
@@ -28,7 +28,6 @@ const http = require('http');
@@ -28,7 +28,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
console.warn = () => {};