openvibers/openvibe.media Commits

Make the paste API read-only and answer 410 pastes.moved to writes (#9)

OP OpenVibers committed 2 Oct 2026 · 7f81034b56 · 9 files, +73 −650

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Media/pull/9

Changes

ddiff --git a/README.md b/README.md
iindex 9131145..23fa16c 100644
---- a/README.md
++++ b/README.md
@@@ -7,7 +7,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
--on 2026-09-22; Media's 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, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
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
admin/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
ddiff --git a/STATUS.json b/STATUS.json
iindex 006270e..6791d83 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex be448e1..ef06f92 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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.
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 47fef50..72f2754 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -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 '^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")
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 6ddeb27..a8718e9 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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}` });
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 8a2631e..1fa019e 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -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;
ddiff --git a/test/object-first-writes.test.js b/test/object-first-writes.test.js
iindex e356812..6b0d5f5 100644
---- a/test/object-first-writes.test.js
++++ b/test/object-first-writes.test.js
@@@ -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/);
ddiff --git a/test/pastes-moved.test.js b/test/pastes-moved.test.js
iindex 7329ff1..d4cdd93 100644
---- a/test/pastes-moved.test.js
++++ b/test/pastes-moved.test.js
@@@ -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');
ddiff --git a/test/retention-holds.test.js b/test/retention-holds.test.js
iindex b7dedae..f7a0851 100644
---- a/test/retention-holds.test.js
++++ b/test/retention-holds.test.js
@@@ -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);