openvibers/openvibe.media Commits
Make the paste API read-only and answer 410 pastes.moved to writes (#9)
Changes
@@@ -7,7 +7,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonicalobject model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community--on 2026-09-22; Media's paste routes stay as the rollback path ([Pastes](#pastes)).++on 2026-09-22; Media's paste API is read-only and writes answer 410 ([Pastes](#pastes)).Implements **Media API v1** from `../CONTRACTS.md`.@@@ -89,7 +89,7 @@ server/vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLIvod/health-scanner.js probe/decode/master-recovery primitivesvod/health-job.js background health scan + quarantine cleanup + master sweep-- pastes/routes.js /api/v1/:app/pastes (+ admin: stats/forks/bulk/censor)++ pastes/routes.js /api/v1/:app/pastes (read-only; writes answer 410 pastes.moved)files/routes.js /api/v1/:app/filesadmin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)thumbnails/ thumbnail service + /api/v1/:app/thumbnails@@@ -274,41 +274,27 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every### Pastes--**Production (since 2026-09-22):** OpenVibe.Community is the paste authority.--With `PASTES_FROZEN_APPS=live`, paste writes for app `live` answer 410, and++**Read-only since the move:** OpenVibe.Community owns pastes (since++2026-09-22). The paste API here answers reads only: every other method++(POST, PUT, DELETE, …) answers 410 `{ "code": "pastes.moved" }` for every app.`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text`/raw` into 301s to Community. Screenshot bytes are still served from here, and--new screenshots are uploaded to the token-only `community` tenant. The routes--below stay as the rollback path.++new screenshots are uploaded to the token-only `community` tenant.++Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,++`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and++`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so++Media has no API that deletes them.++An unauthenticated write still answers 401 before the 410. `GET /pastes/config`++reports the old limits for information only.| method | path | notes ||---|---|---|--| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` || GET | `/pastes?limit&offset&type&search&user_id` | public list || GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) || GET | `/pastes/:slug` | full paste (private: owner/app only) |--| PUT | `/pastes/:slug` | update (owner/app) |--| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |--| POST | `/pastes/:slug/fork` | fork a text paste |--| POST | `/pastes/:slug/like` | toggle like (needs a user identity) |--| POST | `/pastes/:slug/copy` | track a copy event |--| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |--| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |++| GET | `/pastes/:slug/comments` | threaded comments |--**Paste admin** (app-key auth only — the app's server fronts its admins):----| method | path | notes |--|---|---|---|--| GET | `/pastes/admin/stats` | app-scoped `{ total, textPastes, screenshots, forks, totalViews, totalCopies, totalLikes }` |--| GET | `/pastes/admin/forks?limit&offset` | list forked pastes |--| DELETE | `/pastes/admin/forks` | delete ALL forks (screenshots unlinked too) → `{ deleted }` |--| POST | `/pastes/bulk` | `{ slugs: [...], action: delete\|public\|unlisted\|private }` (max 500) → `{ done, skipped }` |--| POST | `/pastes/:slug/censor` | multipart `screenshot` (PNG/JPEG/WebP ≤ 16 MB) replaces a screenshot paste's image (old file deleted); `:slug` also accepts a numeric paste id |----Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,--`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers--are trusted server-to-server. AI summary/tags generation was **dropped** (Live--owns AI) but the columns remain for imported rows.++AI summary/tags columns remain for imported rows.### Files@@@ -19,7 +19,7 @@"media.lifecycle.transition"],"capabilitiesRegistered": true,-- "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_FROZEN_APPS=live, PASTES_MOVED_TO); the paste routes stay as the rollback path",++ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's paste API is read-only and every write answers 410 pastes.moved","deployed": true,"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.","features": [@@@ -115,7 +115,7 @@ after the outcome event.| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction || file upload | `db.createFile` || thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |--| screenshot upload / update / censor | pastes routes |++| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API is read-only and writes answer 410 `pastes.moved` || avatar ingest | `avatars/ingest` || tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |@@@ -518,11 +518,11 @@ While any hold applies:- **No delete path works.**- v2 `DELETE` answers 409.-- - The v1 deletes for vods, clips, files and pastes, and the admin bulk delete, answer 409 or report `held`; the paste bulk delete skips it.++ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (paste deletes answer 410 `pastes.moved` for every paste since the move).- Quarantine cleanup and the boot junk sweep skip the object.- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).- `deleteVodObjects` keeps the bytes.-- - Paste screenshot removal keeps the file, and censor answers 409.++ - Paste screenshot removal keeps the file.- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.@@@ -105,37 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Coecho "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"--echo "-- pastes: create + public page + raw"++echo "-- pastes: read-only (OpenVibe.Community owns pastes)"R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \-- -d '{"title":"Smoke paste","content":"hello from the smoke test\nline two","user_id":7}')--SLUG=$(jsonget "$R" slug)--[ -n "$SLUG" ] && ok "paste created slug=$SLUG" || bad "paste create ($R)"--PAGE=$(curl -s "$BASE/p/$SLUG")--echo "$PAGE" | grep -q 'Smoke paste' && ok "/p/$SLUG HTML page renders title" || bad "/p page content"--echo "$PAGE" | grep -q 'OpenVibe' && ok "/p page carries OpenVibe branding" || bad "/p page branding"--RAW=$(curl -s "$BASE/p/$SLUG/raw")--check "/p/$SLUG/raw returns content" "hello from the smoke test--line two" "$RAW"----echo "-- pastes admin: screenshot + censor + stats"--R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \++ -d '{"title":"Smoke paste","content":"hello from the smoke test"}')++check "paste create → pastes.moved" "pastes.moved" "$(jsonget "$R" code)"++CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")--SSLUG=$(jsonget "$R" slug)--[ -n "$SSLUG" ] && ok "screenshot paste created slug=$SSLUG" || bad "screenshot paste ($R)"--R=$(curl -s -X POST "$BASE/api/v1/live/pastes/$SSLUG/censor" -H "$AUTH" \-- -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")--check "censor replaces screenshot" "$SSLUG" "$(jsonget "$R" paste.slug)"--CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/p/$SSLUG/screenshot")--check "censored screenshot serves" "200" "$CODE"--CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes/$SLUG/censor" -H "$AUTH" \-- -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")--check "censor on text paste → 400" "400" "$CODE"--R=$(curl -s "$BASE/api/v1/live/pastes/admin/stats" -H "$AUTH")--PT=$(jsonget "$R" stats.total); PS=$(jsonget "$R" stats.screenshots)--[ "${PT:-0}" -ge 2 ] && ok "paste stats total=$PT" || bad "paste stats total (got '$PT')"--[ "${PS:-0}" -ge 1 ] && ok "paste stats screenshots=$PS" || bad "paste stats screenshots (got '$PS')"--CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes/admin/stats")--check "paste admin without key → 401" "401" "$CODE"++check "screenshot paste create → 410" "410" "$CODE"++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")++check "paste list still reads" "200" "$CODE"echo "-- admin storage: overview + vod listing"R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")@@@ -630,7 +630,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:const obj = await load(req, res, 'delete');if (!obj) return;if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');-- if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files, pastes)');++ if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files); paste screenshots and avatars are frozen since pastes moved to OpenVibe.Community');if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });@@@ -1,100 +1,39 @@/**-- * OpenVibe.Media — Pastes API (mounted at /api/v1/:app/pastes)++ * OpenVibe.Media — Pastes API, read-only (mounted at /api/v1/:app/pastes)*-- * Pastebin-style sharing ported from the predecessor: text pastes, code-- * snippets, and screenshots (EXIF-stripped via sharp). Public pages live at-- * /p/:slug (HTML) and /p/:slug/raw.++ * OpenVibe.Community owns pastes since the 2026-09-22 cutover (PASTES_MOVED_TO).++ * Media keeps the rows and answers reads for anything still pointed here; public++ * pages live at /p/:slug (HTML) and /p/:slug/raw.*-- * POST / { title?, content?, language?, user_id?, visibility?,-- * burn_after_read?, is_nsfw?, screenshot (multipart)? }-- * → { id, slug, url }-- * GET / list (?limit&offset&type&search&user_id)++ * GET / list (?limit&offset&type&search&user_id&include_unlisted&sort)++ * GET /config paste limits (+ the caller's count today)* GET /:slug paste meta/content-- * PUT /:slug update-- * DELETE /:slug delete (+ screenshot local & legacy B2)-- * POST /:slug/fork fork a text paste-- * POST /:slug/like toggle-like (user identity required)-- * POST /:slug/copy track a copy event* GET /:slug/comments list comments (+replies)-- * POST /:slug/comments add comment-- * DELETE /:slug/comments/:id delete comment*-- * Admin endpoints (app-key auth only — the app's server fronts its admins):-- * GET /admin/stats app-scoped paste stats-- * GET /admin/forks list forked pastes-- * DELETE /admin/forks delete ALL forks (+ screenshots)-- * POST /bulk { slugs, action: delete|public|unlisted|private }-- * POST /:slug/censor replace a screenshot with a censored image (multipart)-- *-- * Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are-- * trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)-- * but the columns remain for imported rows.++ * Every other method answers 410 { code: 'pastes.moved' }.*/'use strict';const express = require('express');--const crypto = require('crypto');const fs = require('fs');--const multer = require('multer');--const sharp = require('sharp');const db = require('../db/database');const { tenantAuth, tenantCors } = require('../auth');const router = express.Router({ mergeParams: true });router.use(tenantCors);--// Apps whose pastes moved to OpenVibe.Community (PASTES_FROZEN_APPS, roadmap Wave 5) get no more writes--// here; reads keep working for anything still pointed at Media during the switch.++// Pastes moved to OpenVibe.Community: no writes here, for any app. The caller is++// authenticated first, so an anonymous write still answers 401 like the reads.++const writeAuth = tenantAuth({ allowUser: true });router.use((req, res, next) => {-- const frozen = String(process.env.PASTES_FROZEN_APPS || '').split(',').map(s => s.trim()).filter(Boolean);-- if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS' && frozen.includes(String(req.params.app))) {-- return res.status(410).json({ error: 'Pastes for this app moved to OpenVibe.Community', code: 'pastes.moved' });-- }-- next();++ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();++ writeAuth(req, res, () => res.status(410).json({ error: 'Pastes moved to OpenVibe.Community', code: 'pastes.moved' }));});--// ── Screenshot upload storage (shared with the avatar ingest: server/pastes/storage.js) ──++// Screenshot directory, shared with the avatar ingest (server/pastes/storage.js).const { SCREENSHOTS_DIR, generateSlug } = require('./storage');--const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };--const screenshotStorage = multer.diskStorage({-- destination: (_req, _file, cb) => cb(null, SCREENSHOTS_DIR),-- filename: (_req, file, cb) => {-- const ext = MIME_TO_EXT[file.mimetype] || '.png';-- cb(null, `ss-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);-- },--});--const screenshotUpload = multer({-- storage: screenshotStorage,-- limits: { fileSize: 16 * 1024 * 1024 }, // hard cap; per-app setting enforced below-- fileFilter: (_req, file, cb) => {-- if (/^image\/(png|jpeg|webp|gif)$/.test(file.mimetype)) cb(null, true);-- else cb(new Error('Only PNG, JPEG, WebP, or GIF images allowed'));-- },--});--// ── Helpers ─────────────────────────────────────────────────--function sanitizeTitle(title) {-- return String(title || '').trim().slice(0, 200) || 'Untitled';--}----function detectLanguage(content, hint) {-- if (hint && hint !== 'auto') return hint;-- const first = String(content || '').slice(0, 500);-- if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';-- if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';-- if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';-- if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';-- if (/^\{[\s\n]*"/.test(first)) return 'json';-- if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';-- if (/^#!\/(bin|usr)/m.test(first)) return 'bash';-- if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';-- if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';-- if (/^<\?php/m.test(first)) return 'php';-- if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';-- return 'text';--}--async function getPasteConfig() {return {maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,@@@ -142,34 +81,11 @@ function pastePublic(paste) {};}--// Effective actor for rate limits: user-JWT callers get cooldowns; app-key--// callers are trusted server-to-server (their platform enforces its own).++// Effective actor for the daily count in GET /config: user-JWT callers are++// counted by their token; app-key callers name the user in the request.function _actor(req) {-- if (req.authType === 'user') return { userId: req.userId, ip: req.ip, limited: true };-- return { userId: (req.body && req.body.user_id) || null, ip: req.ip, limited: false };--}----async function _rateLimitCheck(req, res) {-- const actor = _actor(req);-- if (!actor.limited) return true;-- const cfg = await getPasteConfig();-- if (cfg.cooldownSeconds > 0) {-- const lastTime = await db.getLastPasteTime(req.appId, actor.userId, actor.ip);-- const elapsed = (Date.now() - lastTime) / 1000;-- if (elapsed < cfg.cooldownSeconds) {-- const wait = Math.ceil(cfg.cooldownSeconds - elapsed);-- res.status(429).json({ error: `Please wait ${wait}s before creating another paste`, cooldown: wait });-- return false;-- }-- }-- if (cfg.maxPerUserPerDay > 0) {-- const todayCount = await db.countUserPastesToday(req.appId, actor.userId, actor.ip);-- if (todayCount >= cfg.maxPerUserPerDay) {-- res.status(429).json({ error: `Daily paste limit reached (${cfg.maxPerUserPerDay}/day)` });-- return false;-- }-- }-- return true;++ if (req.authType === 'user') return { userId: req.userId, ip: req.ip };++ return { userId: (req.body && req.body.user_id) || null, ip: req.ip };}async function _getPasteScoped(req, res) {@@@ -281,119 +197,6 @@ router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {}});--// ═════════════════════════════════════════════════════════════--// ── Admin (app-key only) — registered before /:slug ─────────--// ═════════════════════════════════════════════════════════════----// ── Paste stats ─────────────────────────────────────────────--router.get('/admin/stats', tenantAuth(), async (req, res) => {-- try {-- res.json({ stats: await db.getPasteStats(req.appId) });-- } catch (err) {-- console.error('[Pastes] Stats error:', err.message);-- res.status(500).json({ error: 'Failed to get paste stats' });-- }--});----// ── List forked pastes ──────────────────────────────────────--router.get('/admin/forks', tenantAuth(), async (req, res) => {-- try {-- const limit = Math.min(Math.max(parseInt(req.query.limit) || 100, 1), 500);-- const offset = Math.max(parseInt(req.query.offset) || 0, 0);-- const forks = await db.all(`-- SELECT id, slug, user_id, type, title, forked_from, visibility,-- views, copies, likes, created_at-- FROM pastes-- WHERE app_id = ? AND forked_from IS NOT NULL-- ORDER BY created_at DESC LIMIT ? OFFSET ?-- `, [req.appId, limit, offset]);-- const total = (await db.get('SELECT COUNT(*) AS c FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId])).c;-- res.json({ forks, total, limit, offset });-- } catch (err) {-- console.error('[Pastes] List forks error:', err.message);-- res.status(500).json({ error: 'Failed to list forks' });-- }--});----// ── Delete ALL forks (ported predecessor semantics) ─────────--router.delete('/admin/forks', tenantAuth(), async (req, res) => {-- try {-- const forks = await db.all('SELECT id, screenshot_path FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]);-- for (const f of forks) await removePasteScreenshot(f); // unlink screenshots so they don't leak on disk-- await db.run('DELETE FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]); // objects: the row-delete trigger-- res.json({ success: true, deleted: forks.length });-- } catch (err) {-- console.error('[Pastes] Delete forks error:', err.message);-- res.status(500).json({ error: 'Failed to delete forks' });-- }--});----// ── Bulk action: delete | public | unlisted | private ───────--router.post('/bulk', tenantAuth(), async (req, res) => {-- try {-- const { slugs, action } = req.body || {};-- if (!Array.isArray(slugs) || !slugs.length) return res.status(400).json({ error: 'No slugs provided' });-- if (!['delete', 'public', 'unlisted', 'private'].includes(action)) return res.status(400).json({ error: 'Invalid action' });-- let done = 0, skipped = 0;-- for (const slug of slugs.slice(0, 500)) {-- const paste = await db.getPasteBySlug(String(slug), req.appId);-- if (!paste) { skipped++; continue; }-- if (action === 'delete') {-- // A held screenshot is skipped (its row and bytes stay), not failed halfway.-- if (await require('../objects/model').isHeldRow(paste)) { skipped++; continue; }-- await removePasteScreenshot(paste);-- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]); // its object: the row-delete trigger-- } else {-- await db.withObject('paste', paste.id, async () => await db.run('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ?', [action, paste.id]));-- }-- done++;-- }-- res.json({ done, skipped });-- } catch (err) {-- console.error('[Pastes] Bulk error:', err.message);-- res.status(500).json({ error: 'Bulk action failed' });-- }--});----// ── Censor a screenshot (replace with an uploaded image) ────--router.post('/:slug/censor', tenantAuth(), async (req, res) => {-- // Accept slug or numeric paste id (the predecessor keyed this by slug).-- let paste = await db.getPasteBySlug(String(req.params.slug), req.appId);-- if (!paste && /^\d+$/.test(String(req.params.slug))) {-- paste = await db.get('SELECT * FROM pastes WHERE id = ? AND app_id = ?', [parseInt(req.params.slug, 10), req.appId]);-- }-- if (!paste) return res.status(404).json({ error: 'Paste not found' });-- if (paste.type !== 'screenshot' || !paste.screenshot_path) {-- return res.status(400).json({ error: 'Not a screenshot paste' });-- }-- if (await require('../objects/model').isHeldRow(paste)) return res.status(409).json({ error: 'Screenshot is under a retention hold', code: 'media.object.held' });---- const censorUpload = multer({-- storage: screenshotStorage,-- limits: { fileSize: 16 * 1024 * 1024 }, // 16 MB for censored exports-- fileFilter: (_req, file, cb) => {-- if (/^image\/(png|jpeg|webp)$/.test(file.mimetype)) cb(null, true);-- else cb(new Error('Only PNG, JPEG, or WebP images allowed'));-- },-- }).single('screenshot');---- censorUpload(req, res, async (err) => {-- if (err) return res.status(400).json({ error: err.message || 'Upload failed' });-- if (!req.file) return res.status(400).json({ error: 'Censored image is required' });---- try {-- // Delete the old screenshot file, then swap in the censored one.-- try { fs.unlinkSync(paste.screenshot_path); } catch { /* */ }-- await db.withObject('paste', paste.id, async () => await db.run('UPDATE pastes SET screenshot_path = ?, updated_at = ov_now() WHERE id = ?',-- [req.file.path, paste.id]));-- res.json({ paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });-- } catch (err2) {-- console.error('[Pastes] Censor error:', err2.message);-- if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }-- res.status(500).json({ error: 'Failed to save censored image' });-- }-- });--});// ── Get single paste by slug ────────────────────────────────router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {@@@ -421,251 +224,10 @@ router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {}});--// ── Create paste (text, or screenshot via multipart) ────────--router.post('/', tenantAuth({ allowUser: true }), screenshotUpload.single('screenshot'), async (req, res) => {-- try {-- const body = req.body || {};-- const cfg = await getPasteConfig();---- if (!await _rateLimitCheck(req, res)) {-- if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }-- return;-- }---- const actor = _actor(req);-- const userId = req.authType === 'user' ? req.userId : (body.user_id != null ? body.user_id : null);-- const vis = ['unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';-- const burn = body.burn_after_read ? 1 : 0;-- const nsfw = body.is_nsfw ? 1 : 0;-- const slug = await generateSlug();---- // ── Screenshot paste ────────────────────────────────-- if (req.file) {-- if (req.file.size > cfg.screenshotMaxSizeMb * 1024 * 1024) {-- try { fs.unlinkSync(req.file.path); } catch { /* */ }-- return res.status(400).json({ error: `File too large (max ${cfg.screenshotMaxSizeMb} MB)` });-- }---- // Strip EXIF/GPS metadata by re-encoding with sharp (GIFs kept as-is-- // so animations survive).-- const mime = req.file.mimetype;-- if (mime === 'image/png' || mime === 'image/jpeg' || mime === 'image/webp') {-- try {-- const img = sharp(req.file.path);-- let buf;-- if (mime === 'image/png') buf = await img.png().toBuffer();-- else if (mime === 'image/jpeg') buf = await img.jpeg({ quality: 90 }).toBuffer();-- else buf = await img.webp({ quality: 90 }).toBuffer();-- fs.writeFileSync(req.file.path, buf);-- req.file.size = buf.length;-- } catch (stripErr) {-- console.warn('[Pastes] Metadata strip warning:', stripErr.message);-- // Continue with original file if re-encoding fails-- }-- }---- const metadata = JSON.stringify({-- page_url: body.page_url || null,-- user_agent: body.user_agent || req.get('user-agent') || null,-- original_name: req.file.originalname,-- size_bytes: req.file.size,-- mime_type: req.file.mimetype,-- });---- // The row and the screenshot's object in one transaction.-- await db.withObject('paste', (r) => r.lastInsertRowid, async () => await db.run(-- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, screenshot_path, metadata, burn_after_read, is_nsfw, ip_address)-- VALUES (?, ?, ?, 'screenshot', ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [req.appId, slug, userId, sanitizeTitle(body.title || 'Screenshot'),-- body.description || body.content || '', vis, body.stream_id || null, req.file.path, metadata, burn, nsfw, actor.ip]-- ));---- const paste = await db.getPasteBySlug(slug, req.appId);-- return res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });-- }---- // ── Text paste ──────────────────────────────────────-- const content = body.content;-- if (!content || typeof content !== 'string' || content.trim().length === 0) {-- return res.status(400).json({ error: 'Content is required' });-- }-- const maxBytes = cfg.maxSizeKb * 1024;-- if (content.length > maxBytes) {-- return res.status(400).json({ error: `Paste too large (max ${cfg.maxSizeKb} KB)` });-- }---- const lang = detectLanguage(content, body.language);-- await db.run(-- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, burn_after_read, is_nsfw, ip_address)-- VALUES (?, ?, ?, 'paste', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [req.appId, slug, userId, sanitizeTitle(body.title), content.trim(), lang, vis,-- body.stream_id || null, burn, nsfw, actor.ip]-- );---- const paste = await db.getPasteBySlug(slug, req.appId);-- res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });-- } catch (err) {-- console.error('[Pastes] Create error:', err.message);-- if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }-- res.status(500).json({ error: 'Failed to create paste' });-- }--});----/**-- * Store AI results for a paste. App-key only: Media owns the pastes but has no LLM, so-- * the owning app analyses and writes back here.-- *-- * Paste AI silently stopped at the migration — the analysis job still queried the app's-- * OWN pastes table, which no longer receives rows, so every paste created since had no-- * summary. There was no route to write one either; this is it.-- */--router.post('/:slug/ai', tenantAuth(), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- const { ai_summary, ai_tags } = req.body || {};-- const summary = ai_summary == null ? null : String(ai_summary).slice(0, 2000);-- const tags = ai_tags == null ? null-- : (typeof ai_tags === 'string' ? ai_tags : JSON.stringify(ai_tags)).slice(0, 2000);-- await db.run(-- 'UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = ov_now() WHERE id = ?',-- [summary, tags, paste.id]-- );-- res.json({ ok: true, paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });-- } catch (err) {-- console.error('[Pastes] AI update error:', err.message);-- res.status(500).json({ error: 'Failed to store paste AI' });-- }--});----// ── Update paste ────────────────────────────────────────────--router.put('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {-- return res.status(403).json({ error: 'Not authorized for this paste' });-- }---- const { title, content, language, visibility, pinned, is_nsfw } = req.body || {};-- const updates = [];-- const params = [];---- if (title !== undefined) { updates.push('title = ?'); params.push(sanitizeTitle(title)); }-- if (content !== undefined && paste.type === 'paste') {-- const cfg = await getPasteConfig();-- if (content.length > cfg.maxSizeKb * 1024) return res.status(400).json({ error: 'Too large' });-- updates.push('content = ?'); params.push(content);-- updates.push('language = ?'); params.push(detectLanguage(content, language));-- }-- if (visibility !== undefined) { updates.push('visibility = ?'); params.push(['unlisted', 'private'].includes(visibility) ? visibility : 'public'); }-- if (is_nsfw !== undefined) { updates.push('is_nsfw = ?'); params.push(is_nsfw ? 1 : 0); }-- if (pinned !== undefined && req.authType === 'app') {-- updates.push('pinned = ?'); params.push(pinned ? 1 : 0);-- }---- if (updates.length === 0) return res.status(400).json({ error: 'Nothing to update' });---- updates.push('updated_at = ov_now()');-- params.push(paste.id);-- // A screenshot's title/visibility are its object's too: row and object in one transaction (text pastes have none).-- await db.withObject('paste', paste.id, async () => await db.run(`UPDATE pastes SET ${updates.join(', ')} WHERE id = ?`, params));-- res.json({ paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });-- } catch (err) {-- console.error('[Pastes] Update error:', err.message);-- res.status(500).json({ error: 'Failed to update paste' });-- }--});----// ── Delete paste ────────────────────────────────────────────--router.delete('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {-- return res.status(403).json({ error: 'Not authorized for this paste' });-- }-- if (await require('../objects/model').isHeldRow(paste)) return res.status(409).json({ error: 'Screenshot is under a retention hold', code: 'media.object.held' });---- await removePasteScreenshot(paste);-- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]); // its object: the row-delete trigger-- res.json({ success: true });-- } catch (err) {-- console.error('[Pastes] Delete error:', err.message);-- res.status(500).json({ error: 'Failed to delete paste' });-- }--});----// ── Fork (copy) a paste ─────────────────────────────────────--router.post('/:slug/fork', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const original = await _getPasteScoped(req, res);-- if (!original) return;-- if (original.type !== 'paste') return res.status(400).json({ error: 'Only text pastes can be forked' });-- if (!await _rateLimitCheck(req, res)) return;---- const actor = _actor(req);-- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);-- const slug = await generateSlug();-- await db.run(-- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, forked_from, ip_address)-- VALUES (?, ?, ?, 'paste', ?, ?, ?, 'public', ?, ?) RETURNING id`,-- [req.appId, slug, userId, `Fork of ${original.title}`,-- original.content, original.language, original.id, actor.ip]-- );---- const paste = await db.getPasteBySlug(slug, req.appId);-- res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });-- } catch (err) {-- console.error('[Pastes] Fork error:', err.message);-- res.status(500).json({ error: 'Failed to fork paste' });-- }--});----// ── Like / Unlike a paste ───────────────────────────────────--router.post('/:slug/like', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);-- if (userId == null) return res.status(400).json({ error: 'user_id required to like' });---- const alreadyLiked = await db.hasUserLikedPaste(paste.id, userId);-- const result = alreadyLiked ? await db.unlikePaste(paste.id, userId) : await db.likePaste(paste.id, userId);-- res.json({ liked: !alreadyLiked, likes: result?.likes || 0 });-- } catch (err) {-- console.error('[Pastes] Like error:', err.message);-- res.status(500).json({ error: 'Failed to toggle like' });-- }--});----// ── Track a copy event ──────────────────────────────────────--router.post('/:slug/copy', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;-- await db.incrementPasteCopies(paste.slug);-- res.json({ copies: (paste.copies || 0) + 1 });-- } catch (err) {-- res.status(500).json({ error: 'Failed to track copy' });-- }--});--// ═════════════════════════════════════════════════════════════// ── Paste Comments (supports anonymous via app callers) ─────// ═════════════════════════════════════════════════════════════--// Rate-limit state: IP → timestamp of last comment--const commentCooldowns = new Map();--// (A restore drill takes no comments and runs no timers.)--const _cooldownSweep = require('../drill').enabled ? null : setInterval(() => {-- const now = Date.now();-- for (const [ip, ts] of commentCooldowns) {-- if (now - ts > 120_000) commentCooldowns.delete(ip);-- }--}, 600_000);--if (_cooldownSweep && _cooldownSweep.unref) _cooldownSweep.unref();--router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {try {const paste = await _getPasteScoped(req, res);@@@ -689,116 +251,7 @@ router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res)}});--router.post('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;---- const ip = req.ip || 'unknown';-- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);---- // ── Anon check ──────────────────────────────────────-- if (userId == null && await db.getSetting('paste_comment_anon_allowed') === false) {-- return res.status(401).json({ error: 'You must be logged in to comment' });-- }---- // ── Message validation ──────────────────────────────-- const message = (req.body?.message || '').trim();-- const maxLen = Number(await db.getSetting('paste_comment_max_length')) || 2000;-- if (!message) return res.status(400).json({ error: 'Comment cannot be empty' });-- if (message.length > maxLen) {-- return res.status(400).json({ error: `Comment must be under ${maxLen} characters` });-- }---- // ── Anon name validation ────────────────────────────-- let anonName = null;-- if (userId == null) {-- anonName = (req.body?.anon_name || '').trim().substring(0, 32) || 'Anonymous';-- anonName = anonName.replace(/[^a-zA-Z0-9 _\-]/g, '').trim() || 'Anonymous';-- }---- // ── Rate limits (user-JWT/browser callers) ──────────-- if (req.authType === 'user') {-- const cooldownSec = Number(await db.getSetting('paste_comment_cooldown_seconds')) || 10;-- const lastComment = commentCooldowns.get(ip);-- if (lastComment && (Date.now() - lastComment) < cooldownSec * 1000) {-- const wait = Math.ceil((cooldownSec * 1000 - (Date.now() - lastComment)) / 1000);-- return res.status(429).json({ error: `Please wait ${wait}s before commenting again` });-- }-- const recentFromIp = await db.getRecentPasteCommentsByIp(ip, 60);-- if (recentFromIp.length >= 5) {-- return res.status(429).json({ error: 'Too many comments. Please slow down.' });-- }-- if (recentFromIp.length > 0 && recentFromIp[0].message === message) {-- return res.status(400).json({ error: 'Duplicate comment' });-- }-- }---- // ── Parent comment validation ───────────────────────-- const parentId = req.body?.parent_id ? parseInt(req.body.parent_id) : null;-- let parent = null;-- if (parentId) {-- parent = await db.getPasteCommentById(parentId);-- if (!parent || parent.paste_id !== paste.id) {-- return res.status(400).json({ error: 'Invalid parent comment' });-- }-- // Prevent deeply nested replies — only allow replies to top-level-- if (parent.parent_id) {-- return res.status(400).json({ error: 'Cannot reply to a reply — reply to the original comment instead' });-- }-- }---- const result = await db.createPasteComment({-- paste_id: paste.id,-- user_id: userId,-- parent_id: parentId,-- anon_name: anonName,-- message,-- ip_address: ip,-- });---- commentCooldowns.set(ip, Date.now());---- const comment = await db.getPasteCommentById(result.lastInsertRowid);-- res.status(201).json({ comment });-- } catch (err) {-- console.error('[PasteComments] Create error:', err.message);-- res.status(500).json({ error: 'Failed to post comment' });-- }--});----router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), async (req, res) => {-- try {-- const paste = await _getPasteScoped(req, res);-- if (!paste) return;---- const comment = await db.getPasteCommentById(parseInt(req.params.commentId));-- if (!comment || comment.paste_id !== paste.id) {-- return res.status(404).json({ error: 'Comment not found' });-- }---- // App callers moderate freely; user callers must be the comment author-- // or the paste owner.-- if (req.authType === 'user') {-- const isAuthor = comment.user_id != null && comment.user_id === req.userId;-- const isPasteOwner = paste.user_id != null && paste.user_id === req.userId;-- if (!isAuthor && !isPasteOwner) {-- return res.status(403).json({ error: 'Not authorized to delete this comment' });-- }-- }---- await db.deletePasteComment(comment.id);-- res.json({ message: 'Comment deleted' });-- } catch (err) {-- console.error('[PasteComments] Delete error:', err.message);-- res.status(500).json({ error: 'Failed to delete comment' });-- }--});--module.exports = router;--module.exports.pastePublic = pastePublic;module.exports.generateSlug = generateSlug;module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;--module.exports.screenshotUrl = screenshotUrl;module.exports.removePasteScreenshot = removePasteScreenshot;--module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;@@@ -203,20 +203,19 @@ const sharp = require('sharp');assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');++ // Paste writes moved to OpenVibe.Community: POST /pastes answers 410 and writes nothing.++ const pastesBefore = [await count('pastes'), await count('media_objects')];r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));-- assert.strictEqual(r.status, 201);-- const slug = r.body.slug;++ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);++ assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');++ // A screenshot paste row (imported before the move) still commits with its object.++ const slug = 'shot1';++ const shotFile = path.join(process.env.PASTES_PATH, 'shot1.png');++ fs.writeFileSync(shotFile, png);++ await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, type, title, content, language, visibility, screenshot_path)++ VALUES ('live', ?, 'screenshot', 'Shot', '', 'text', 'unlisted', ?) RETURNING id`, [slug, shotFile]));({ obj } = await linked('pastes', 'slug', slug, 'screenshot paste'));assert.deepStrictEqual([obj.kind, obj.visibility, obj.legacy_ref], ['screenshot', 'unlisted', `legacy:live:paste:${slug}`]);-- assert.strictEqual((await call('PUT', `/pastes/${slug}`, { title: 'Shot 2', visibility: 'private' })).status, 200);-- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste update')).obj.visibility, 'private');-- assert.strictEqual((await call('POST', '/pastes/bulk', { slugs: [slug], action: 'public' })).status, 200);-- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste bulk')).obj.visibility, 'public');-- const censor = await call('POST', `/pastes/${slug}/censor`, form('screenshot', png, 'image/png', 'censored.png'));-- assert.strictEqual(censor.status, 200);-- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste censor')).obj.size_bytes, png.length);-- const text = await call('POST', '/pastes', { content: 'just text' });-- assert.strictEqual((await db.get('SELECT object_id FROM pastes WHERE slug = ?', [text.body.slug])).object_id, null, 'a text paste has no bytes and no object');// Avatar ingest's write (server/avatars/ingest.js): the avatar row and its object together.const avatarFile = path.join(process.env.PASTES_PATH, 'avatar.webp');@@@ -247,8 +246,6 @@ const sharp = require('sharp');await unchanged('createClip: neither row');assert.strictEqual((await call('POST', '/files', form('file', Buffer.from('never stored'), 'text/plain', 'never.txt'))).status, 500);await unchanged('POST /files: neither row');-- assert.strictEqual((await call('POST', '/pastes', form('screenshot', png, 'image/png', 'never.png'))).status, 500);-- await unchanged('POST /pastes screenshot: neither row');// Updates: the row keeps its old values when its object cannot follow.await assert.rejects(async () => await db.setVodVisibility(vodId, 'public'), /object write refused/);@@@ -1,6 +1,6 @@'use strict';--// After the Wave 5 cutover: paste writes for a frozen app answer 410, the paste page and its text 301 to--// OpenVibe.Community, and screenshot bytes are still served from here. Unset, nothing changes.++// Pastes moved to OpenVibe.Community: paste writes always answer 410 pastes.moved and reads keep working.++// With PASTES_MOVED_TO set, the paste page and its text 301 to Community; screenshot bytes are still served here.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -28,14 +28,22 @@ const express = require('express');const get = (p) => fetch(base + p, { redirect: 'manual' });const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });-- // Before the switch: business as usual.++ // Writes answer 410 for every app, with or without PASTES_MOVED_TO, and create nothing.++ const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;++ const rows = await pasteCount();++ let r = await post();++ assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');++ assert.strictEqual(await pasteCount(), rows, 'a refused write creates no paste');++ // The caller is authenticated before the 410: an anonymous write answers 401, like the reads.++ r = await fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });++ assert.strictEqual(r.status, 401);++++ // Before the redirect switch: the pages are served here.assert.strictEqual((await get('/p/text-1')).status, 200);assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');-- assert.strictEqual((await post()).status, 201);process.env.PASTES_MOVED_TO = 'https://openvibe.community/';-- process.env.PASTES_FROZEN_APPS = 'live';-- let r = await post();++ r = await post();assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });assert.strictEqual(r.status, 200, 'reads still work');@@@ -21,7 +21,6 @@ const http = require('http');MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',});for (const k of Object.keys(process.env)) if (/^MEDIA_(B2|R2)_/.test(k)) delete process.env[k];-- delete process.env.PASTES_FROZEN_APPS;const db = require('../server/db/database');const model = require('../server/objects/model');@@@ -174,15 +173,17 @@ const http = require('http');assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');-- // ── A held screenshot paste ──++ // ── A held screenshot paste: paste writes moved to OpenVibe.Community, so deletes answer 410 ──const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });r = await call('DELETE', '/api/v1/live/pastes/shot1');-- assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held']);++ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });-- assert.deepStrictEqual([r.status, r.body.done, r.body.skipped], [200, 0, 1], 'bulk delete skips it');++ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved'], 'bulk delete is gone too');assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));-- console.log('✅ a held screenshot paste: DELETE answers 409, bulk delete skips it');++ const shotObj = await model.getObject(shot.object_id);++ assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');++ console.log('✅ a held screenshot paste: DELETE and bulk delete answer 410 pastes.moved; row, bytes and object stay');// ── Listing and release ──r = await call('GET', H);
@@ -7,7 +7,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
@@ -7,7 +7,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
-on 2026-09-22; Media's paste routes stay as the rollback path ([Pastes](#pastes)).
+on 2026-09-22; Media's paste API is read-only and writes answer 410 ([Pastes](#pastes)).
Implements **Media API v1** from `../CONTRACTS.md`.
Implements **Media API v1** from `../CONTRACTS.md`.
@@ -89,7 +89,7 @@ server/
@@ -89,7 +89,7 @@ server/
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-job.js background health scan + quarantine cleanup + master sweep
vod/health-job.js background health scan + quarantine cleanup + master sweep
- pastes/routes.js /api/v1/:app/pastes (+ admin: stats/forks/bulk/censor)
+ pastes/routes.js /api/v1/:app/pastes (read-only; writes answer 410 pastes.moved)
files/routes.js /api/v1/:app/files
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
@@ -274,41 +274,27 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
@@ -274,41 +274,27 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
### Pastes
### Pastes
-**Production (since 2026-09-22):** OpenVibe.Community is the paste authority.
-With `PASTES_FROZEN_APPS=live`, paste writes for app `live` answer 410, and
+**Read-only since the move:** OpenVibe.Community owns pastes (since
+2026-09-22). The paste API here answers reads only: every other method
+(POST, PUT, DELETE, …) answers 410 `{ "code": "pastes.moved" }` for every app.
`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text
`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text
`/raw` into 301s to Community. Screenshot bytes are still served from here, and
`/raw` into 301s to Community. Screenshot bytes are still served from here, and
-new screenshots are uploaded to the token-only `community` tenant. The routes
-below stay as the rollback path.
+new screenshots are uploaded to the token-only `community` tenant.
+Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
+`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and
+`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so
+Media has no API that deletes them.
+An unauthenticated write still answers 401 before the 410. `GET /pastes/config`
+reports the old limits for information only.
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
-| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` |
| GET | `/pastes?limit&offset&type&search&user_id` | public list |
| GET | `/pastes?limit&offset&type&search&user_id` | public list |
| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |
| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |
| GET | `/pastes/:slug` | full paste (private: owner/app only) |
| GET | `/pastes/:slug` | full paste (private: owner/app only) |
-| PUT | `/pastes/:slug` | update (owner/app) |
-| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |
-| POST | `/pastes/:slug/fork` | fork a text paste |
-| POST | `/pastes/:slug/like` | toggle like (needs a user identity) |
-| POST | `/pastes/:slug/copy` | track a copy event |
-| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
-| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |
+| GET | `/pastes/:slug/comments` | threaded comments |
-**Paste admin** (app-key auth only — the app's server fronts its admins):
-
-| method | path | notes |
-|---|---|---|
-| GET | `/pastes/admin/stats` | app-scoped `{ total, textPastes, screenshots, forks, totalViews, totalCopies, totalLikes }` |
-| GET | `/pastes/admin/forks?limit&offset` | list forked pastes |
-| DELETE | `/pastes/admin/forks` | delete ALL forks (screenshots unlinked too) → `{ deleted }` |
-| POST | `/pastes/bulk` | `{ slugs: [...], action: delete\|public\|unlisted\|private }` (max 500) → `{ done, skipped }` |
-| POST | `/pastes/:slug/censor` | multipart `screenshot` (PNG/JPEG/WebP ≤ 16 MB) replaces a screenshot paste's image (old file deleted); `:slug` also accepts a numeric paste id |
-
-Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,
-`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers
-are trusted server-to-server. AI summary/tags generation was **dropped** (Live
-owns AI) but the columns remain for imported rows.
+AI summary/tags columns remain for imported rows.
### Files
### Files
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"media.lifecycle.transition"
"media.lifecycle.transition"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
- "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_FROZEN_APPS=live, PASTES_MOVED_TO); the paste routes stay as the rollback path",
+ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's paste API is read-only and every write answers 410 pastes.moved",
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.",
"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.",
"features": [
"features": [
@@ -115,7 +115,7 @@ after the outcome event.
@@ -115,7 +115,7 @@ after the outcome event.
| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction |
| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction |
| file upload | `db.createFile` |
| file upload | `db.createFile` |
| thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |
| thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |
-| screenshot upload / update / censor | pastes routes |
+| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API is read-only and writes answer 410 `pastes.moved` |
| avatar ingest | `avatars/ingest` |
| avatar ingest | `avatars/ingest` |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |
@@ -518,11 +518,11 @@ While any hold applies:
@@ -518,11 +518,11 @@ While any hold applies:
- **No delete path works.**
- **No delete path works.**
- v2 `DELETE` answers 409.
- v2 `DELETE` answers 409.
- - The v1 deletes for vods, clips, files and pastes, and the admin bulk delete, answer 409 or report `held`; the paste bulk delete skips it.
+ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (paste deletes answer 410 `pastes.moved` for every paste since the move).
- Quarantine cleanup and the boot junk sweep skip the object.
- Quarantine cleanup and the boot junk sweep skip the object.
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- `deleteVodObjects` keeps the bytes.
- `deleteVodObjects` keeps the bytes.
- - Paste screenshot removal keeps the file, and censor answers 409.
+ - Paste screenshot removal keeps the file.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
@@ -105,37 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
@@ -105,37 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
-echo "-- pastes: create + public page + raw"
+echo "-- pastes: read-only (OpenVibe.Community owns pastes)"
R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
- -d '{"title":"Smoke paste","content":"hello from the smoke test\nline two","user_id":7}')
-SLUG=$(jsonget "$R" slug)
-[ -n "$SLUG" ] && ok "paste created slug=$SLUG" || bad "paste create ($R)"
-PAGE=$(curl -s "$BASE/p/$SLUG")
-echo "$PAGE" | grep -q 'Smoke paste' && ok "/p/$SLUG HTML page renders title" || bad "/p page content"
-echo "$PAGE" | grep -q 'OpenVibe' && ok "/p page carries OpenVibe branding" || bad "/p page branding"
-RAW=$(curl -s "$BASE/p/$SLUG/raw")
-check "/p/$SLUG/raw returns content" "hello from the smoke test
-line two" "$RAW"
-
-echo "-- pastes admin: screenshot + censor + stats"
-R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
+ -d '{"title":"Smoke paste","content":"hello from the smoke test"}')
+check "paste create → pastes.moved" "pastes.moved" "$(jsonget "$R" code)"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
-SSLUG=$(jsonget "$R" slug)
-[ -n "$SSLUG" ] && ok "screenshot paste created slug=$SSLUG" || bad "screenshot paste ($R)"
-R=$(curl -s -X POST "$BASE/api/v1/live/pastes/$SSLUG/censor" -H "$AUTH" \
- -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")
-check "censor replaces screenshot" "$SSLUG" "$(jsonget "$R" paste.slug)"
-CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/p/$SSLUG/screenshot")
-check "censored screenshot serves" "200" "$CODE"
-CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes/$SLUG/censor" -H "$AUTH" \
- -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")
-check "censor on text paste → 400" "400" "$CODE"
-R=$(curl -s "$BASE/api/v1/live/pastes/admin/stats" -H "$AUTH")
-PT=$(jsonget "$R" stats.total); PS=$(jsonget "$R" stats.screenshots)
-[ "${PT:-0}" -ge 2 ] && ok "paste stats total=$PT" || bad "paste stats total (got '$PT')"
-[ "${PS:-0}" -ge 1 ] && ok "paste stats screenshots=$PS" || bad "paste stats screenshots (got '$PS')"
-CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes/admin/stats")
-check "paste admin without key → 401" "401" "$CODE"
+check "screenshot paste create → 410" "410" "$CODE"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")
+check "paste list still reads" "200" "$CODE"
echo "-- admin storage: overview + vod listing"
echo "-- admin storage: overview + vod listing"
R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
@@ -630,7 +630,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
@@ -630,7 +630,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
const obj = await load(req, res, 'delete');
const obj = await load(req, res, 'delete');
if (!obj) return;
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
- if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files, pastes)');
+ if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files); paste screenshots and avatars are frozen since pastes moved to OpenVibe.Community');
if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));
if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));
const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });
const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });
@@ -1,100 +1,39 @@
@@ -1,100 +1,39 @@
/**
/**
- * OpenVibe.Media — Pastes API (mounted at /api/v1/:app/pastes)
+ * OpenVibe.Media — Pastes API, read-only (mounted at /api/v1/:app/pastes)
*
*
- * Pastebin-style sharing ported from the predecessor: text pastes, code
- * snippets, and screenshots (EXIF-stripped via sharp). Public pages live at
- * /p/:slug (HTML) and /p/:slug/raw.
+ * OpenVibe.Community owns pastes since the 2026-09-22 cutover (PASTES_MOVED_TO).
+ * Media keeps the rows and answers reads for anything still pointed here; public
+ * pages live at /p/:slug (HTML) and /p/:slug/raw.
*
*
- * POST / { title?, content?, language?, user_id?, visibility?,
- * burn_after_read?, is_nsfw?, screenshot (multipart)? }
- * → { id, slug, url }
- * GET / list (?limit&offset&type&search&user_id)
+ * GET / list (?limit&offset&type&search&user_id&include_unlisted&sort)
+ * GET /config paste limits (+ the caller's count today)
* GET /:slug paste meta/content
* GET /:slug paste meta/content
- * PUT /:slug update
- * DELETE /:slug delete (+ screenshot local & legacy B2)
- * POST /:slug/fork fork a text paste
- * POST /:slug/like toggle-like (user identity required)
- * POST /:slug/copy track a copy event
* GET /:slug/comments list comments (+replies)
* GET /:slug/comments list comments (+replies)
- * POST /:slug/comments add comment
- * DELETE /:slug/comments/:id delete comment
*
*
- * Admin endpoints (app-key auth only — the app's server fronts its admins):
- * GET /admin/stats app-scoped paste stats
- * GET /admin/forks list forked pastes
- * DELETE /admin/forks delete ALL forks (+ screenshots)
- * POST /bulk { slugs, action: delete|public|unlisted|private }
- * POST /:slug/censor replace a screenshot with a censored image (multipart)
- *
- * Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are
- * trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)
- * but the columns remain for imported rows.
+ * Every other method answers 410 { code: 'pastes.moved' }.
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
-const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
-const multer = require('multer');
-const sharp = require('sharp');
const db = require('../db/database');
const db = require('../db/database');
const { tenantAuth, tenantCors } = require('../auth');
const { tenantAuth, tenantCors } = require('../auth');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
-// Apps whose pastes moved to OpenVibe.Community (PASTES_FROZEN_APPS, roadmap Wave 5) get no more writes
-// here; reads keep working for anything still pointed at Media during the switch.
+// Pastes moved to OpenVibe.Community: no writes here, for any app. The caller is
+// authenticated first, so an anonymous write still answers 401 like the reads.
+const writeAuth = tenantAuth({ allowUser: true });
router.use((req, res, next) => {
router.use((req, res, next) => {
- const frozen = String(process.env.PASTES_FROZEN_APPS || '').split(',').map(s => s.trim()).filter(Boolean);
- if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS' && frozen.includes(String(req.params.app))) {
- return res.status(410).json({ error: 'Pastes for this app moved to OpenVibe.Community', code: 'pastes.moved' });
- }
- next();
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
+ writeAuth(req, res, () => res.status(410).json({ error: 'Pastes moved to OpenVibe.Community', code: 'pastes.moved' }));
});
});
-// ── Screenshot upload storage (shared with the avatar ingest: server/pastes/storage.js) ──
+// Screenshot directory, shared with the avatar ingest (server/pastes/storage.js).
const { SCREENSHOTS_DIR, generateSlug } = require('./storage');
const { SCREENSHOTS_DIR, generateSlug } = require('./storage');
-const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
-const screenshotStorage = multer.diskStorage({
- destination: (_req, _file, cb) => cb(null, SCREENSHOTS_DIR),
- filename: (_req, file, cb) => {
- const ext = MIME_TO_EXT[file.mimetype] || '.png';
- cb(null, `ss-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);
- },
-});
-const screenshotUpload = multer({
- storage: screenshotStorage,
- limits: { fileSize: 16 * 1024 * 1024 }, // hard cap; per-app setting enforced below
- fileFilter: (_req, file, cb) => {
- if (/^image\/(png|jpeg|webp|gif)$/.test(file.mimetype)) cb(null, true);
- else cb(new Error('Only PNG, JPEG, WebP, or GIF images allowed'));
- },
-});
-
// ── Helpers ─────────────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────────
-function sanitizeTitle(title) {
- return String(title || '').trim().slice(0, 200) || 'Untitled';
-}
-
-function detectLanguage(content, hint) {
- if (hint && hint !== 'auto') return hint;
- const first = String(content || '').slice(0, 500);
- if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';
- if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';
- if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';
- if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';
- if (/^\{[\s\n]*"/.test(first)) return 'json';
- if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';
- if (/^#!\/(bin|usr)/m.test(first)) return 'bash';
- if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';
- if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';
- if (/^<\?php/m.test(first)) return 'php';
- if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';
- return 'text';
-}
-
async function getPasteConfig() {
async function getPasteConfig() {
return {
return {
maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,
maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,
@@ -142,34 +81,11 @@ function pastePublic(paste) {
@@ -142,34 +81,11 @@ function pastePublic(paste) {
};
};
}
}
-// Effective actor for rate limits: user-JWT callers get cooldowns; app-key
-// callers are trusted server-to-server (their platform enforces its own).
+// Effective actor for the daily count in GET /config: user-JWT callers are
+// counted by their token; app-key callers name the user in the request.
function _actor(req) {
function _actor(req) {
- if (req.authType === 'user') return { userId: req.userId, ip: req.ip, limited: true };
- return { userId: (req.body && req.body.user_id) || null, ip: req.ip, limited: false };
-}
-
-async function _rateLimitCheck(req, res) {
- const actor = _actor(req);
- if (!actor.limited) return true;
- const cfg = await getPasteConfig();
- if (cfg.cooldownSeconds > 0) {
- const lastTime = await db.getLastPasteTime(req.appId, actor.userId, actor.ip);
- const elapsed = (Date.now() - lastTime) / 1000;
- if (elapsed < cfg.cooldownSeconds) {
- const wait = Math.ceil(cfg.cooldownSeconds - elapsed);
- res.status(429).json({ error: `Please wait ${wait}s before creating another paste`, cooldown: wait });
- return false;
- }
- }
- if (cfg.maxPerUserPerDay > 0) {
- const todayCount = await db.countUserPastesToday(req.appId, actor.userId, actor.ip);
- if (todayCount >= cfg.maxPerUserPerDay) {
- res.status(429).json({ error: `Daily paste limit reached (${cfg.maxPerUserPerDay}/day)` });
- return false;
- }
- }
- return true;
+ if (req.authType === 'user') return { userId: req.userId, ip: req.ip };
+ return { userId: (req.body && req.body.user_id) || null, ip: req.ip };
}
}
async function _getPasteScoped(req, res) {
async function _getPasteScoped(req, res) {
@@ -281,119 +197,6 @@ router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -281,119 +197,6 @@ router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {
}
}
});
});
-// ═════════════════════════════════════════════════════════════
-// ── Admin (app-key only) — registered before /:slug ─────────
-// ═════════════════════════════════════════════════════════════
-
-// ── Paste stats ─────────────────────────────────────────────
-router.get('/admin/stats', tenantAuth(), async (req, res) => {
- try {
- res.json({ stats: await db.getPasteStats(req.appId) });
- } catch (err) {
- console.error('[Pastes] Stats error:', err.message);
- res.status(500).json({ error: 'Failed to get paste stats' });
- }
-});
-
-// ── List forked pastes ──────────────────────────────────────
-router.get('/admin/forks', tenantAuth(), async (req, res) => {
- try {
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 100, 1), 500);
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
- const forks = await db.all(`
- SELECT id, slug, user_id, type, title, forked_from, visibility,
- views, copies, likes, created_at
- FROM pastes
- WHERE app_id = ? AND forked_from IS NOT NULL
- ORDER BY created_at DESC LIMIT ? OFFSET ?
- `, [req.appId, limit, offset]);
- const total = (await db.get('SELECT COUNT(*) AS c FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId])).c;
- res.json({ forks, total, limit, offset });
- } catch (err) {
- console.error('[Pastes] List forks error:', err.message);
- res.status(500).json({ error: 'Failed to list forks' });
- }
-});
-
-// ── Delete ALL forks (ported predecessor semantics) ─────────
-router.delete('/admin/forks', tenantAuth(), async (req, res) => {
- try {
- const forks = await db.all('SELECT id, screenshot_path FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]);
- for (const f of forks) await removePasteScreenshot(f); // unlink screenshots so they don't leak on disk
- await db.run('DELETE FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]); // objects: the row-delete trigger
- res.json({ success: true, deleted: forks.length });
- } catch (err) {
- console.error('[Pastes] Delete forks error:', err.message);
- res.status(500).json({ error: 'Failed to delete forks' });
- }
-});
-
-// ── Bulk action: delete | public | unlisted | private ───────
-router.post('/bulk', tenantAuth(), async (req, res) => {
- try {
- const { slugs, action } = req.body || {};
- if (!Array.isArray(slugs) || !slugs.length) return res.status(400).json({ error: 'No slugs provided' });
- if (!['delete', 'public', 'unlisted', 'private'].includes(action)) return res.status(400).json({ error: 'Invalid action' });
- let done = 0, skipped = 0;
- for (const slug of slugs.slice(0, 500)) {
- const paste = await db.getPasteBySlug(String(slug), req.appId);
- if (!paste) { skipped++; continue; }
- if (action === 'delete') {
- // A held screenshot is skipped (its row and bytes stay), not failed halfway.
- if (await require('../objects/model').isHeldRow(paste)) { skipped++; continue; }
- await removePasteScreenshot(paste);
- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]); // its object: the row-delete trigger
- } else {
- await db.withObject('paste', paste.id, async () => await db.run('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ?', [action, paste.id]));
- }
- done++;
- }
- res.json({ done, skipped });
- } catch (err) {
- console.error('[Pastes] Bulk error:', err.message);
- res.status(500).json({ error: 'Bulk action failed' });
- }
-});
-
-// ── Censor a screenshot (replace with an uploaded image) ────
-router.post('/:slug/censor', tenantAuth(), async (req, res) => {
- // Accept slug or numeric paste id (the predecessor keyed this by slug).
- let paste = await db.getPasteBySlug(String(req.params.slug), req.appId);
- if (!paste && /^\d+$/.test(String(req.params.slug))) {
- paste = await db.get('SELECT * FROM pastes WHERE id = ? AND app_id = ?', [parseInt(req.params.slug, 10), req.appId]);
- }
- if (!paste) return res.status(404).json({ error: 'Paste not found' });
- if (paste.type !== 'screenshot' || !paste.screenshot_path) {
- return res.status(400).json({ error: 'Not a screenshot paste' });
- }
- if (await require('../objects/model').isHeldRow(paste)) return res.status(409).json({ error: 'Screenshot is under a retention hold', code: 'media.object.held' });
-
- const censorUpload = multer({
- storage: screenshotStorage,
- limits: { fileSize: 16 * 1024 * 1024 }, // 16 MB for censored exports
- fileFilter: (_req, file, cb) => {
- if (/^image\/(png|jpeg|webp)$/.test(file.mimetype)) cb(null, true);
- else cb(new Error('Only PNG, JPEG, or WebP images allowed'));
- },
- }).single('screenshot');
-
- censorUpload(req, res, async (err) => {
- if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
- if (!req.file) return res.status(400).json({ error: 'Censored image is required' });
-
- try {
- // Delete the old screenshot file, then swap in the censored one.
- try { fs.unlinkSync(paste.screenshot_path); } catch { /* */ }
- await db.withObject('paste', paste.id, async () => await db.run('UPDATE pastes SET screenshot_path = ?, updated_at = ov_now() WHERE id = ?',
- [req.file.path, paste.id]));
- res.json({ paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });
- } catch (err2) {
- console.error('[Pastes] Censor error:', err2.message);
- if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
- res.status(500).json({ error: 'Failed to save censored image' });
- }
- });
-});
// ── Get single paste by slug ────────────────────────────────
// ── Get single paste by slug ────────────────────────────────
router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -421,251 +224,10 @@ router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -421,251 +224,10 @@ router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
}
}
});
});
-// ── Create paste (text, or screenshot via multipart) ────────
-router.post('/', tenantAuth({ allowUser: true }), screenshotUpload.single('screenshot'), async (req, res) => {
- try {
- const body = req.body || {};
- const cfg = await getPasteConfig();
-
- if (!await _rateLimitCheck(req, res)) {
- if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
- return;
- }
-
- const actor = _actor(req);
- const userId = req.authType === 'user' ? req.userId : (body.user_id != null ? body.user_id : null);
- const vis = ['unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';
- const burn = body.burn_after_read ? 1 : 0;
- const nsfw = body.is_nsfw ? 1 : 0;
- const slug = await generateSlug();
-
- // ── Screenshot paste ────────────────────────────────
- if (req.file) {
- if (req.file.size > cfg.screenshotMaxSizeMb * 1024 * 1024) {
- try { fs.unlinkSync(req.file.path); } catch { /* */ }
- return res.status(400).json({ error: `File too large (max ${cfg.screenshotMaxSizeMb} MB)` });
- }
-
- // Strip EXIF/GPS metadata by re-encoding with sharp (GIFs kept as-is
- // so animations survive).
- const mime = req.file.mimetype;
- if (mime === 'image/png' || mime === 'image/jpeg' || mime === 'image/webp') {
- try {
- const img = sharp(req.file.path);
- let buf;
- if (mime === 'image/png') buf = await img.png().toBuffer();
- else if (mime === 'image/jpeg') buf = await img.jpeg({ quality: 90 }).toBuffer();
- else buf = await img.webp({ quality: 90 }).toBuffer();
- fs.writeFileSync(req.file.path, buf);
- req.file.size = buf.length;
- } catch (stripErr) {
- console.warn('[Pastes] Metadata strip warning:', stripErr.message);
- // Continue with original file if re-encoding fails
- }
- }
-
- const metadata = JSON.stringify({
- page_url: body.page_url || null,
- user_agent: body.user_agent || req.get('user-agent') || null,
- original_name: req.file.originalname,
- size_bytes: req.file.size,
- mime_type: req.file.mimetype,
- });
-
- // The row and the screenshot's object in one transaction.
- await db.withObject('paste', (r) => r.lastInsertRowid, async () => await db.run(
- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, screenshot_path, metadata, burn_after_read, is_nsfw, ip_address)
- VALUES (?, ?, ?, 'screenshot', ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [req.appId, slug, userId, sanitizeTitle(body.title || 'Screenshot'),
- body.description || body.content || '', vis, body.stream_id || null, req.file.path, metadata, burn, nsfw, actor.ip]
- ));
-
- const paste = await db.getPasteBySlug(slug, req.appId);
- return res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
- }
-
- // ── Text paste ──────────────────────────────────────
- const content = body.content;
- if (!content || typeof content !== 'string' || content.trim().length === 0) {
- return res.status(400).json({ error: 'Content is required' });
- }
- const maxBytes = cfg.maxSizeKb * 1024;
- if (content.length > maxBytes) {
- return res.status(400).json({ error: `Paste too large (max ${cfg.maxSizeKb} KB)` });
- }
-
- const lang = detectLanguage(content, body.language);
- await db.run(
- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, burn_after_read, is_nsfw, ip_address)
- VALUES (?, ?, ?, 'paste', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [req.appId, slug, userId, sanitizeTitle(body.title), content.trim(), lang, vis,
- body.stream_id || null, burn, nsfw, actor.ip]
- );
-
- const paste = await db.getPasteBySlug(slug, req.appId);
- res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
- } catch (err) {
- console.error('[Pastes] Create error:', err.message);
- if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
- res.status(500).json({ error: 'Failed to create paste' });
- }
-});
-
-/**
- * Store AI results for a paste. App-key only: Media owns the pastes but has no LLM, so
- * the owning app analyses and writes back here.
- *
- * Paste AI silently stopped at the migration — the analysis job still queried the app's
- * OWN pastes table, which no longer receives rows, so every paste created since had no
- * summary. There was no route to write one either; this is it.
- */
-router.post('/:slug/ai', tenantAuth(), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- const { ai_summary, ai_tags } = req.body || {};
- const summary = ai_summary == null ? null : String(ai_summary).slice(0, 2000);
- const tags = ai_tags == null ? null
- : (typeof ai_tags === 'string' ? ai_tags : JSON.stringify(ai_tags)).slice(0, 2000);
- await db.run(
- 'UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = ov_now() WHERE id = ?',
- [summary, tags, paste.id]
- );
- res.json({ ok: true, paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });
- } catch (err) {
- console.error('[Pastes] AI update error:', err.message);
- res.status(500).json({ error: 'Failed to store paste AI' });
- }
-});
-
-// ── Update paste ────────────────────────────────────────────
-router.put('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
- return res.status(403).json({ error: 'Not authorized for this paste' });
- }
-
- const { title, content, language, visibility, pinned, is_nsfw } = req.body || {};
- const updates = [];
- const params = [];
-
- if (title !== undefined) { updates.push('title = ?'); params.push(sanitizeTitle(title)); }
- if (content !== undefined && paste.type === 'paste') {
- const cfg = await getPasteConfig();
- if (content.length > cfg.maxSizeKb * 1024) return res.status(400).json({ error: 'Too large' });
- updates.push('content = ?'); params.push(content);
- updates.push('language = ?'); params.push(detectLanguage(content, language));
- }
- if (visibility !== undefined) { updates.push('visibility = ?'); params.push(['unlisted', 'private'].includes(visibility) ? visibility : 'public'); }
- if (is_nsfw !== undefined) { updates.push('is_nsfw = ?'); params.push(is_nsfw ? 1 : 0); }
- if (pinned !== undefined && req.authType === 'app') {
- updates.push('pinned = ?'); params.push(pinned ? 1 : 0);
- }
-
- if (updates.length === 0) return res.status(400).json({ error: 'Nothing to update' });
-
- updates.push('updated_at = ov_now()');
- params.push(paste.id);
- // A screenshot's title/visibility are its object's too: row and object in one transaction (text pastes have none).
- await db.withObject('paste', paste.id, async () => await db.run(`UPDATE pastes SET ${updates.join(', ')} WHERE id = ?`, params));
- res.json({ paste: pastePublic(await db.getPasteBySlug(paste.slug, req.appId)) });
- } catch (err) {
- console.error('[Pastes] Update error:', err.message);
- res.status(500).json({ error: 'Failed to update paste' });
- }
-});
-
-// ── Delete paste ────────────────────────────────────────────
-router.delete('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
- return res.status(403).json({ error: 'Not authorized for this paste' });
- }
- if (await require('../objects/model').isHeldRow(paste)) return res.status(409).json({ error: 'Screenshot is under a retention hold', code: 'media.object.held' });
-
- await removePasteScreenshot(paste);
- await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]); // its object: the row-delete trigger
- res.json({ success: true });
- } catch (err) {
- console.error('[Pastes] Delete error:', err.message);
- res.status(500).json({ error: 'Failed to delete paste' });
- }
-});
-
-// ── Fork (copy) a paste ─────────────────────────────────────
-router.post('/:slug/fork', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const original = await _getPasteScoped(req, res);
- if (!original) return;
- if (original.type !== 'paste') return res.status(400).json({ error: 'Only text pastes can be forked' });
- if (!await _rateLimitCheck(req, res)) return;
-
- const actor = _actor(req);
- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
- const slug = await generateSlug();
- await db.run(
- `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, forked_from, ip_address)
- VALUES (?, ?, ?, 'paste', ?, ?, ?, 'public', ?, ?) RETURNING id`,
- [req.appId, slug, userId, `Fork of ${original.title}`,
- original.content, original.language, original.id, actor.ip]
- );
-
- const paste = await db.getPasteBySlug(slug, req.appId);
- res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
- } catch (err) {
- console.error('[Pastes] Fork error:', err.message);
- res.status(500).json({ error: 'Failed to fork paste' });
- }
-});
-
-// ── Like / Unlike a paste ───────────────────────────────────
-router.post('/:slug/like', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
- if (userId == null) return res.status(400).json({ error: 'user_id required to like' });
-
- const alreadyLiked = await db.hasUserLikedPaste(paste.id, userId);
- const result = alreadyLiked ? await db.unlikePaste(paste.id, userId) : await db.likePaste(paste.id, userId);
- res.json({ liked: !alreadyLiked, likes: result?.likes || 0 });
- } catch (err) {
- console.error('[Pastes] Like error:', err.message);
- res.status(500).json({ error: 'Failed to toggle like' });
- }
-});
-
-// ── Track a copy event ──────────────────────────────────────
-router.post('/:slug/copy', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
- await db.incrementPasteCopies(paste.slug);
- res.json({ copies: (paste.copies || 0) + 1 });
- } catch (err) {
- res.status(500).json({ error: 'Failed to track copy' });
- }
-});
-
// ═════════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════════
// ── Paste Comments (supports anonymous via app callers) ─────
// ── Paste Comments (supports anonymous via app callers) ─────
// ═════════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════════
-// Rate-limit state: IP → timestamp of last comment
-const commentCooldowns = new Map();
-// (A restore drill takes no comments and runs no timers.)
-const _cooldownSweep = require('../drill').enabled ? null : setInterval(() => {
- const now = Date.now();
- for (const [ip, ts] of commentCooldowns) {
- if (now - ts > 120_000) commentCooldowns.delete(ip);
- }
-}, 600_000);
-if (_cooldownSweep && _cooldownSweep.unref) _cooldownSweep.unref();
-
router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {
router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {
try {
try {
const paste = await _getPasteScoped(req, res);
const paste = await _getPasteScoped(req, res);
@@ -689,116 +251,7 @@ router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res)
@@ -689,116 +251,7 @@ router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res)
}
}
});
});
-router.post('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
-
- const ip = req.ip || 'unknown';
- const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
-
- // ── Anon check ──────────────────────────────────────
- if (userId == null && await db.getSetting('paste_comment_anon_allowed') === false) {
- return res.status(401).json({ error: 'You must be logged in to comment' });
- }
-
- // ── Message validation ──────────────────────────────
- const message = (req.body?.message || '').trim();
- const maxLen = Number(await db.getSetting('paste_comment_max_length')) || 2000;
- if (!message) return res.status(400).json({ error: 'Comment cannot be empty' });
- if (message.length > maxLen) {
- return res.status(400).json({ error: `Comment must be under ${maxLen} characters` });
- }
-
- // ── Anon name validation ────────────────────────────
- let anonName = null;
- if (userId == null) {
- anonName = (req.body?.anon_name || '').trim().substring(0, 32) || 'Anonymous';
- anonName = anonName.replace(/[^a-zA-Z0-9 _\-]/g, '').trim() || 'Anonymous';
- }
-
- // ── Rate limits (user-JWT/browser callers) ──────────
- if (req.authType === 'user') {
- const cooldownSec = Number(await db.getSetting('paste_comment_cooldown_seconds')) || 10;
- const lastComment = commentCooldowns.get(ip);
- if (lastComment && (Date.now() - lastComment) < cooldownSec * 1000) {
- const wait = Math.ceil((cooldownSec * 1000 - (Date.now() - lastComment)) / 1000);
- return res.status(429).json({ error: `Please wait ${wait}s before commenting again` });
- }
- const recentFromIp = await db.getRecentPasteCommentsByIp(ip, 60);
- if (recentFromIp.length >= 5) {
- return res.status(429).json({ error: 'Too many comments. Please slow down.' });
- }
- if (recentFromIp.length > 0 && recentFromIp[0].message === message) {
- return res.status(400).json({ error: 'Duplicate comment' });
- }
- }
-
- // ── Parent comment validation ───────────────────────
- const parentId = req.body?.parent_id ? parseInt(req.body.parent_id) : null;
- let parent = null;
- if (parentId) {
- parent = await db.getPasteCommentById(parentId);
- if (!parent || parent.paste_id !== paste.id) {
- return res.status(400).json({ error: 'Invalid parent comment' });
- }
- // Prevent deeply nested replies — only allow replies to top-level
- if (parent.parent_id) {
- return res.status(400).json({ error: 'Cannot reply to a reply — reply to the original comment instead' });
- }
- }
-
- const result = await db.createPasteComment({
- paste_id: paste.id,
- user_id: userId,
- parent_id: parentId,
- anon_name: anonName,
- message,
- ip_address: ip,
- });
-
- commentCooldowns.set(ip, Date.now());
-
- const comment = await db.getPasteCommentById(result.lastInsertRowid);
- res.status(201).json({ comment });
- } catch (err) {
- console.error('[PasteComments] Create error:', err.message);
- res.status(500).json({ error: 'Failed to post comment' });
- }
-});
-
-router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), async (req, res) => {
- try {
- const paste = await _getPasteScoped(req, res);
- if (!paste) return;
-
- const comment = await db.getPasteCommentById(parseInt(req.params.commentId));
- if (!comment || comment.paste_id !== paste.id) {
- return res.status(404).json({ error: 'Comment not found' });
- }
-
- // App callers moderate freely; user callers must be the comment author
- // or the paste owner.
- if (req.authType === 'user') {
- const isAuthor = comment.user_id != null && comment.user_id === req.userId;
- const isPasteOwner = paste.user_id != null && paste.user_id === req.userId;
- if (!isAuthor && !isPasteOwner) {
- return res.status(403).json({ error: 'Not authorized to delete this comment' });
- }
- }
-
- await db.deletePasteComment(comment.id);
- res.json({ message: 'Comment deleted' });
- } catch (err) {
- console.error('[PasteComments] Delete error:', err.message);
- res.status(500).json({ error: 'Failed to delete comment' });
- }
-});
-
module.exports = router;
module.exports = router;
-module.exports.pastePublic = pastePublic;
module.exports.generateSlug = generateSlug;
module.exports.generateSlug = generateSlug;
module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
-module.exports.screenshotUrl = screenshotUrl;
module.exports.removePasteScreenshot = removePasteScreenshot;
module.exports.removePasteScreenshot = removePasteScreenshot;
-module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
@@ -203,20 +203,19 @@ const sharp = require('sharp');
@@ -203,20 +203,19 @@ const sharp = require('sharp');
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
+ // Paste writes moved to OpenVibe.Community: POST /pastes answers 410 and writes nothing.
+ const pastesBefore = [await count('pastes'), await count('media_objects')];
r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
- assert.strictEqual(r.status, 201);
- const slug = r.body.slug;
+ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
+ assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');
+ // A screenshot paste row (imported before the move) still commits with its object.
+ const slug = 'shot1';
+ const shotFile = path.join(process.env.PASTES_PATH, 'shot1.png');
+ fs.writeFileSync(shotFile, png);
+ await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, type, title, content, language, visibility, screenshot_path)
+ VALUES ('live', ?, 'screenshot', 'Shot', '', 'text', 'unlisted', ?) RETURNING id`, [slug, shotFile]));
({ obj } = await linked('pastes', 'slug', slug, 'screenshot paste'));
({ obj } = await linked('pastes', 'slug', slug, 'screenshot paste'));
assert.deepStrictEqual([obj.kind, obj.visibility, obj.legacy_ref], ['screenshot', 'unlisted', `legacy:live:paste:${slug}`]);
assert.deepStrictEqual([obj.kind, obj.visibility, obj.legacy_ref], ['screenshot', 'unlisted', `legacy:live:paste:${slug}`]);
- assert.strictEqual((await call('PUT', `/pastes/${slug}`, { title: 'Shot 2', visibility: 'private' })).status, 200);
- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste update')).obj.visibility, 'private');
- assert.strictEqual((await call('POST', '/pastes/bulk', { slugs: [slug], action: 'public' })).status, 200);
- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste bulk')).obj.visibility, 'public');
- const censor = await call('POST', `/pastes/${slug}/censor`, form('screenshot', png, 'image/png', 'censored.png'));
- assert.strictEqual(censor.status, 200);
- assert.strictEqual((await linked('pastes', 'slug', slug, 'paste censor')).obj.size_bytes, png.length);
- const text = await call('POST', '/pastes', { content: 'just text' });
- assert.strictEqual((await db.get('SELECT object_id FROM pastes WHERE slug = ?', [text.body.slug])).object_id, null, 'a text paste has no bytes and no object');
// Avatar ingest's write (server/avatars/ingest.js): the avatar row and its object together.
// Avatar ingest's write (server/avatars/ingest.js): the avatar row and its object together.
const avatarFile = path.join(process.env.PASTES_PATH, 'avatar.webp');
const avatarFile = path.join(process.env.PASTES_PATH, 'avatar.webp');
@@ -247,8 +246,6 @@ const sharp = require('sharp');
@@ -247,8 +246,6 @@ const sharp = require('sharp');
await unchanged('createClip: neither row');
await unchanged('createClip: neither row');
assert.strictEqual((await call('POST', '/files', form('file', Buffer.from('never stored'), 'text/plain', 'never.txt'))).status, 500);
assert.strictEqual((await call('POST', '/files', form('file', Buffer.from('never stored'), 'text/plain', 'never.txt'))).status, 500);
await unchanged('POST /files: neither row');
await unchanged('POST /files: neither row');
- assert.strictEqual((await call('POST', '/pastes', form('screenshot', png, 'image/png', 'never.png'))).status, 500);
- await unchanged('POST /pastes screenshot: neither row');
// Updates: the row keeps its old values when its object cannot follow.
// Updates: the row keeps its old values when its object cannot follow.
await assert.rejects(async () => await db.setVodVisibility(vodId, 'public'), /object write refused/);
await assert.rejects(async () => await db.setVodVisibility(vodId, 'public'), /object write refused/);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
-// After the Wave 5 cutover: paste writes for a frozen app answer 410, the paste page and its text 301 to
-// OpenVibe.Community, and screenshot bytes are still served from here. Unset, nothing changes.
+// Pastes moved to OpenVibe.Community: paste writes always answer 410 pastes.moved and reads keep working.
+// With PASTES_MOVED_TO set, the paste page and its text 301 to Community; screenshot bytes are still served here.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -28,14 +28,22 @@ const express = require('express');
@@ -28,14 +28,22 @@ const express = require('express');
const get = (p) => fetch(base + p, { redirect: 'manual' });
const get = (p) => fetch(base + p, { redirect: 'manual' });
const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
- // Before the switch: business as usual.
+ // Writes answer 410 for every app, with or without PASTES_MOVED_TO, and create nothing.
+ const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;
+ const rows = await pasteCount();
+ let r = await post();
+ assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
+ assert.strictEqual(await pasteCount(), rows, 'a refused write creates no paste');
+ // The caller is authenticated before the 410: an anonymous write answers 401, like the reads.
+ r = await fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
+ assert.strictEqual(r.status, 401);
+
+ // Before the redirect switch: the pages are served here.
assert.strictEqual((await get('/p/text-1')).status, 200);
assert.strictEqual((await get('/p/text-1')).status, 200);
assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');
assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');
- assert.strictEqual((await post()).status, 201);
process.env.PASTES_MOVED_TO = 'https://openvibe.community/';
process.env.PASTES_MOVED_TO = 'https://openvibe.community/';
- process.env.PASTES_FROZEN_APPS = 'live';
- let r = await post();
+ r = await post();
assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
assert.strictEqual(r.status, 200, 'reads still work');
assert.strictEqual(r.status, 200, 'reads still work');
@@ -21,7 +21,6 @@ const http = require('http');
@@ -21,7 +21,6 @@ const http = require('http');
MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',
MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',
});
});
for (const k of Object.keys(process.env)) if (/^MEDIA_(B2|R2)_/.test(k)) delete process.env[k];
for (const k of Object.keys(process.env)) if (/^MEDIA_(B2|R2)_/.test(k)) delete process.env[k];
- delete process.env.PASTES_FROZEN_APPS;
const db = require('../server/db/database');
const db = require('../server/db/database');
const model = require('../server/objects/model');
const model = require('../server/objects/model');
@@ -174,15 +173,17 @@ const http = require('http');
@@ -174,15 +173,17 @@ const http = require('http');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
- // ── A held screenshot paste ──
+ // ── A held screenshot paste: paste writes moved to OpenVibe.Community, so deletes answer 410 ──
const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
r = await call('DELETE', '/api/v1/live/pastes/shot1');
r = await call('DELETE', '/api/v1/live/pastes/shot1');
- assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held']);
+ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });
r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });
- assert.deepStrictEqual([r.status, r.body.done, r.body.skipped], [200, 0, 1], 'bulk delete skips it');
+ assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved'], 'bulk delete is gone too');
assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
- console.log('✅ a held screenshot paste: DELETE answers 409, bulk delete skips it');
+ const shotObj = await model.getObject(shot.object_id);
+ assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');
+ console.log('✅ a held screenshot paste: DELETE and bulk delete answer 410 pastes.moved; row, bytes and object stay');
// ── Listing and release ──
// ── Listing and release ──
r = await call('GET', H);
r = await call('GET', H);