openvibers/openvibe.community Commits
Image pastes are reviewed into three categories: hidden or quarantined (#263)
Changes
@@@ -6,7 +6,10 @@`legacy:live:paste:<slug>` object it keeps `previous_media_ref` and retires the object through Media's internal legacy-pasteroute. `--apply` pages with an id cursor and reaches every paste in one run, `--retire-ids <file>` retires named objectswhose paste record was lost (one Media id per line), and the dry run prints the moves, retires and refusals it would make.--++- Review follow-up: a failed review keeps the owner's requested visibility/audience/NSFW choice in++ `review_requested_*`; `server/pastes/review-retry.js` retries pending reviews with backoff and restores the++ visibility and share audience when Vision later says fine. Migration `0018_paste_review_retry.sql`, Contracts++ v0.294.0.- Sharing reaches pastes (design 2026-11-06, the pilot): `openvibe-sdk` v0.85.0 and `openvibe-shared` v4.114.0, with`openvibe-contracts` v0.291.0. `migrations/0015_sharing.sql` adds openvibe-sdk/share's tables (expand), and`migrations/0016_sharing_backfill.sql` registers every existing paste once (public→public, unlisted→link,@@@ -19,6 +22,12 @@`members` and `transfer_to` (one call makes a paste only two people can open and offers it). Screenshots upload toMedia as private objects; `npm run screenshots:private` re-hosts the existing unlisted ones. Burn-after-readingpastes cannot use `people`. `docs/sharing.md` has the curls; `/docs/sharing`, `/llms.txt` and `/llms-full.txt` link it.++- Unsafe-image review (VIS-6): image pastes are reviewed by OpenVibe.Vision (`OV_VISION_URL`) before they are++ returned. `fine` keeps its visibility, `sex_acts` becomes private and NSFW, `minor_sexual` becomes private,++ NSFW and quarantined with the operator report id. A Vision outage makes the paste private and pending; the++ owner cannot re-publish a reviewed paste. A hidden review also closes the paste's share row and removes its++ members, so the sharing gate cannot reopen it. Migration `0017_paste_reviews.sql`, `server/pastes/review.js`,++ tests `test/paste-review.test.js`.- Threads carry content labels (L1, labels and choices, 2026-11-02): `migrations/0013_thread_content_labels.sql` adds`threads.content_labels` (`{ labels, suggested }`, common.content-labels@1). The author or a moderator sets them on the@@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)--- `openvibe-contracts` v0.291.0, `openvibe-sdk` v0.85.0 (events outbox, per-actor limits, no-looks, share),++- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.85.0 (events outbox, per-actor limits, no-looks, share),`openvibe-shared` v4.115.1 (the Share sheet), pinned by release tarball## How it fits the network@@@ -105,6 +105,16 @@ Moments feeds read it.(`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with`media.object.upload`); the paste keeps the public URL and `media_ref``legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.++- **Unsafe-image review (VIS-6)**: with `OV_VISION_URL` and Community's service principal, an image paste is++ reviewed before it is returned. Vision sorts it into exactly three categories: `fine` keeps its visibility;++ `sex_acts` becomes private and NSFW (gone from every public list, Pulse, search, sitemap and anonymous page,++ kept for its owner); `minor_sexual` becomes private, NSFW and quarantined with the operator report id. If++ Vision is unreachable the paste becomes private and `review_status='pending'`, never public; the visibility its++ owner asked for is kept in `review_requested_visibility`/`_audience`/`_nsfw`, and the background retry++ (`server/pastes/review-retry.js`, `COMMUNITY_REVIEW_RETRY_MS`, off at 0) restores it when a later review says++ `fine`. Text pastes are not reviewed. `paste.review_category`, `review_status`, `review_report_id`,++ `reviewed_at` and the requested fields are in the paste shape; the enforcement lives in `server/pastes/review.js`.++ Tests: `test/paste-review.test.js`.- **Slugs**: public pastes get a short `adj-noun-NN` slug; a new unlisted or private paste (anda fork of one) gets `adj-noun-` + 16 random base62 characters, because its slug is the onlything keeping it unlisted. Existing slugs never change and keep working.@@@ -668,6 +678,8 @@ robots/llms/sitemap are public for 1 h, `/feed.xml` 15 min and the thread feeds## Configuration++- `OV_VISION_URL` — OpenVibe.Vision's base URL for the unsafe-image review; unset means no image review runs.++Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0600).| Variable | Default | Meaning |@@@ -851,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)<!-- versions:start -->--- openvibe-contracts: v0.291.0++- openvibe-contracts: v0.294.0- openvibe-sdk: v0.85.0- openvibe-shared: v4.115.1<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4200,"domain": "openvibe.community",-- "contracts": "openvibe-contracts v0.291.0",++ "contracts": "openvibe-contracts v0.294.0","sdk": "openvibe-sdk v0.85.0","shared": "openvibe-shared v4.115.1","capabilities": [nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- VIS-6's last item: an unsafe-image review sorts a paste into exactly three categories.++-- fine allowed, including nudity++-- sex_acts never public: private and NSFW (hidden from every public list, feed and search), kept for its owner++-- minor_sexual always blocked: quarantined with the operator's report id from OpenVibe.Vision++-- A visible image paste is 'pending' while Vision answers; a review failure leaves it private and pending,++-- never public. Text pastes are not reviewed (no image).++ALTER TABLE pastes ADD COLUMN review_category text COLLATE "C";++ALTER TABLE pastes ADD COLUMN review_status text COLLATE "C";++ALTER TABLE pastes ADD COLUMN review_report_id text COLLATE "C";++ALTER TABLE pastes ADD COLUMN reviewed_at text COLLATE "C";++ALTER TABLE pastes ADD CONSTRAINT pastes_review_category++ CHECK (review_category IS NULL OR review_category IN ('fine', 'sex_acts', 'minor_sexual'));++ALTER TABLE pastes ADD CONSTRAINT pastes_review_status++ CHECK (review_status IS NULL OR review_status IN ('pending', 'clear', 'hidden', 'quarantined'));++ALTER TABLE pastes ADD CONSTRAINT pastes_review_report++ CHECK (review_report_id IS NULL OR review_report_id ~ '^vor_[0-9A-HJKMNP-TV-Z]{26}$');++CREATE INDEX pastes_review_pending ON pastes (review_status) WHERE review_status = 'pending';++CREATE INDEX pastes_review_category ON pastes (review_category) WHERE review_category IS NOT NULL;nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- VIS-6 review follow-up: a failed review keeps the visibility its owner asked for, so a retry that comes++-- back fine can restore it. The private column remains what the public surfaces read while pending.++ALTER TABLE pastes ADD COLUMN review_requested_visibility text COLLATE "C";++ALTER TABLE pastes ADD COLUMN review_requested_audience text COLLATE "C";++ALTER TABLE pastes ADD COLUMN review_requested_nsfw bigint;++ALTER TABLE pastes ADD COLUMN review_attempts bigint NOT NULL DEFAULT 0;++ALTER TABLE pastes ADD COLUMN review_next_at text COLLATE "C";++ALTER TABLE pastes ADD CONSTRAINT pastes_review_requested_visibility++ CHECK (review_requested_visibility IS NULL OR review_requested_visibility IN ('public', 'unlisted', 'private'));++ALTER TABLE pastes ADD CONSTRAINT pastes_review_requested_audience++ CHECK (review_requested_audience IS NULL OR review_requested_audience IN ('public', 'link', 'people', 'private'));++CREATE INDEX pastes_review_retry ON pastes (review_next_at) WHERE review_status = 'pending';@@@ -17,7 +17,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0"@@@ -1192,9 +1192,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.291.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",-- "integrity": "sha512-/jZNmCA6rLPFe2Jt1FJbC8rfELcMMd0fk7466HzoXrsKzGTXQaB/hr2sjnnm407+mcEYtKBcdB6Oisjyjehh1Q==",++ "version": "0.294.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",++ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0"@@@ -247,10 +247,12 @@ async function createApp(opts = {}) {// ── Pastes: Community's own store is the only authority ──{-- const service = createPasteService({ db, network, media: pasteMedia, config, limits: opts.pasteLimits, pulse, indexnow, shares: sharing });++ const review = opts.review || require('./pastes/review').createPasteReview({ config });++ const service = createPasteService({ db, network, media: pasteMedia, config, limits: opts.pasteLimits, pulse, indexnow, shares: sharing, review });source.use(service);app.locals.pastes = service;app.locals.pasteMedia = pasteMedia;++ app.locals.pasteReview = review;}// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).const anonWriteLimiter = rateLimit({@@@ -46,6 +46,12 @@ module.exports = {// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),++ // OpenVibe.Vision — the unsafe-image review (VIS-6). Unset or without Community's service principal:++ // no review runs and image pastes keep their old behaviour.++ vision: {++ url: (process.env.OV_VISION_URL || process.env.VISION_URL || '').replace(/\/$/, ''),++ },++// OpenVibe.Media — public host that serves raw paste text and screenshots.mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),// Media's internal address: new screenshot uploads go to its file store (community app).@@@ -56,6 +56,12 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdktry { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }// A paste whose audience becomes people/private leaves Search at once (sharing's onAudienceChange).if (app.locals.sharing) app.locals.sharing.useSearch(searchDocs);++ // Pending unsafe-image reviews retry with backoff; a later fine restores the owner's visibility.++ let reviewRetry = null;++ try {++ reviewRetry = require('./pastes/review-retry').createReviewRetry({ db: require('./db').getDb(), review: app.locals.pasteReview });++ if (reviewRetry.start()) console.log('[Community] paste review retry: on');++ } catch (err) { console.warn('[Pastes] review retry not started:', err.message); }// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans@@@ -73,6 +79,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk() => { if (subscriptions) subscriptions.stop(); },() => { if (profiles) profilesDone = profiles.stop(); },() => { if (searchDocs) searchDocs.stop(); },++ () => { if (reviewRetry) reviewRetry.stop(); },],close: [() => within(1000, profilesDone),nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++++/**++ * Retry pending paste reviews (VIS-6 follow-up).++ *++ * A review that could not reach Vision leaves the paste private and `pending`, but keeps the visibility its++ * owner asked for (`review_requested_visibility`, `review_requested_audience`, `review_requested_nsfw`). This++ * job retries with backoff; when Vision answers `fine` it restores that visibility and the share row, so an++ * owner's public paste is not left private by a reviewer outage. A retry that comes back hidden/quarantined++ * stays closed. Off with `COMMUNITY_REVIEW_RETRY_MS=0`; tests call `runOnce()` directly.++ */++function createReviewRetry({ db, review, intervalMs = Number(process.env.COMMUNITY_REVIEW_RETRY_MS || 60_000), log = console } = {}) {++ let timer = null;++++ function nextAt(attempts) {++ const seconds = Math.min(6 * 3600, 60 * Math.pow(5, Math.min(5, Math.max(0, attempts))));++ return new Date(Date.now() + seconds * 1000).toISOString().replace('T', ' ').slice(0, 19);++ }++++ async function restore(row) {++ const visibility = row.review_requested_visibility || 'private';++ const nsfw = Number(row.review_requested_nsfw) || 0;++ const audience = row.review_requested_audience++ || (visibility === 'public' ? 'public' : visibility === 'unlisted' ? 'link' : 'private');++ await db.prepare('UPDATE pastes SET visibility = ?, is_nsfw = ? WHERE id = ?').run(visibility, nsfw, row.id);++ await db.prepare("UPDATE shares SET audience = ?, revision = revision + 1 WHERE service = 'community' AND kind = 'paste' AND resource_id = ?")++ .run(audience, row.slug).catch(() => {});++ }++++ async function runOnce({ limit = 20 } = {}) {++ if (!db || !review || !review.enabled) return { checked: 0, cleared: 0, closed: 0, failed: 0 };++ const rows = await db.prepare(`SELECT * FROM pastes++ WHERE type = 'screenshot' AND review_status = 'pending' AND deleted_at IS NULL++ AND screenshot_url IS NOT NULL AND (review_next_at IS NULL OR review_next_at <= ov_now())++ ORDER BY updated_at ASC LIMIT ?`).all(Math.min(100, Math.max(1, limit)));++ let cleared = 0; let closed = 0; let failed = 0;++ for (const row of rows) {++ const updated = await review.reviewPaste(db, row, row.screenshot_url);++ if (!updated) { failed++; continue; }++ if (updated.review_category === 'fine') { await restore(updated); cleared++; continue; }++ if (updated.review_status === 'hidden' || updated.review_status === 'quarantined') { closed++; continue; }++ const attempts = Number(row.review_attempts || 0) + 1;++ await db.prepare('UPDATE pastes SET review_attempts = ?, review_next_at = ? WHERE id = ?').run(attempts, nextAt(attempts), row.id);++ failed++;++ }++ if (rows.length) log.log(`[Pastes] review retry: ${rows.length} checked, ${cleared} cleared, ${closed} closed, ${failed} still pending`);++ return { checked: rows.length, cleared, closed, failed };++ }++++ function start() {++ if (timer || !intervalMs || !review || !review.enabled) return false;++ timer = setInterval(() => { runOnce().catch((err) => log.warn('[Pastes] review retry:', err.message)); }, intervalMs);++ if (timer.unref) timer.unref();++ return true;++ }++ function stop() { if (timer) { clearInterval(timer); timer = null; } }++ return { start, stop, runOnce, nextAt, restore };++}++++module.exports = { createReviewRetry };nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++++/**++ * Community's side of the unsafe-image review (VIS-6).++ *++ * An image paste goes to OpenVibe.Vision with its Media URL. Vision answers exactly one category:++ *++ * fine clear; the paste keeps the visibility its owner chose.++ * sex_acts private and NSFW: every public list, the Pulse feed, search and the sitemap leave it out;++ * the owner can still open it, and an adults-only section can use it later.++ * minor_sexual private, NSFW and quarantined, with the operator report id Vision made. The hard line is++ * always blocked, whoever posted it.++ *++ * If Vision cannot answer, the paste is made private and stays pending: an unreviewed image is never public.++ * Text pastes are not sent. No image bytes are copied here; Community keeps only the Media link it already had.++ */++const { serviceAuth } = require('openvibe-contracts');++++function createPasteReview({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String((config.vision && config.vision.url) || process.env.OV_VISION_URL || process.env.VISION_URL || '').replace(/\/+$/, '');++ const enabled = Boolean(base && config.oauth && config.oauth.clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.vision',++ scope: 'vision.image.review',++ fetchImpl,++ }) : null;++++ async function classify({ paste, imageUrl }, retried = false) {++ const mediaId = /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(paste.media_ref || '')) ? paste.media_ref : null;++ const res = await fetchImpl(`${base}/api/v1/review`, {++ method: 'POST',++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({++ object_type: 'community.paste',++ object_id: paste.slug,++ object_app: 'community',++ media_object_id: mediaId,++ media_app: mediaId ? 'community' : null,++ owner_subject: paste.owner_subject || null,++ image_url: imageUrl,++ preview_url: imageUrl,++ }),++ signal: AbortSignal.timeout(20000),++ });++ if (res.status === 401 && !retried) { try { tokens.invalidate(); } catch { /* older client */ } return await classify({ paste, imageUrl }, true); }++ const body = await res.json().catch(() => null);++ if (!res.ok) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);++ if (!body || !['fine', 'sex_acts', 'minor_sexual'].includes(body.category)) throw new Error('Vision review returned no known category');++ return body;++ }++++ /**++ * A hidden paste is the owner's alone, so the share row must close too: after the sharing pilot the++ * share audience is the authority (server/sharing.js), and the old visibility column alone would let++ * canView reopen it. Members, pending transfers and access requests are removed for the same reason.++ */++ async function closeShares(db, paste) {++ await db.prepare("UPDATE shares SET audience = 'private', revision = revision + 1 WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").run(paste.slug);++ await db.prepare("DELETE FROM share_members WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").run(paste.slug);++ await db.prepare("UPDATE share_transfers SET state = 'cancelled' WHERE service = 'community' AND kind = 'paste' AND resource_id = ? AND state = 'pending'").run(paste.slug);++ await db.prepare("UPDATE share_access_requests SET state = 'dismissed' WHERE service = 'community' AND kind = 'paste' AND resource_id = ? AND state = 'pending'").run(paste.slug);++ }++++ /** Write one category. Exported so a test or an operator route can apply the same three labels. */++ async function applyCategory(db, paste, category, reportId = null) {++ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) throw new Error(`unknown review category: ${category}`);++ if (category !== 'fine') await closeShares(db, paste);++ if (category === 'fine') {++ await db.prepare("UPDATE pastes SET review_category = 'fine', review_status = 'clear', review_report_id = NULL, reviewed_at = ov_now() WHERE id = ?").run(paste.id);++ } else if (category === 'sex_acts') {++ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_category = 'sex_acts', review_status = 'hidden', review_report_id = NULL, reviewed_at = ov_now() WHERE id = ?").run(paste.id);++ } else {++ if (reportId && !/^vor_[0-9A-HJKMNP-TV-Z]{26}$/.test(reportId)) throw new Error('invalid operator report id');++ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_category = 'minor_sexual', review_status = 'quarantined', review_report_id = ?, reviewed_at = ov_now() WHERE id = ?").run(reportId, paste.id);++ log.error(`[Pastes] ${paste.slug} quarantined: sexual content involving a minor; operator report ${reportId || '(missing)'}`);++ }++ return await db.prepare('SELECT * FROM pastes WHERE id = ?').get(paste.id);++ }++++ /** Review one image paste; a failure is closed (private + pending), never public. */++ async function reviewPaste(db, paste, imageUrl) {++ if (!enabled || !paste || paste.type !== 'screenshot' || !imageUrl) return null;++ try {++ const out = await classify({ paste, imageUrl });++ const row = await applyCategory(db, paste, out.category, out.report && out.report.id ? out.report.id : null);++ if (out.category === 'sex_acts') log.warn(`[Pastes] ${paste.slug} hidden: the review found sex acts or porn`);++ return row;++ } catch (err) {++ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_status = 'pending' WHERE id = ?").run(paste.id);++ await closeShares(db, paste).catch(() => {});++ log.warn(`[Pastes] ${paste.slug}: review failed, the paste stays private and pending (${String((err && err.message) || err).slice(0, 200)})`);++ return await db.prepare('SELECT * FROM pastes WHERE id = ?').get(paste.id);++ }++ }++++ return { enabled, classify, reviewPaste, applyCategory, closeShares };++}++++module.exports = { createPasteReview };@@@ -55,6 +55,8 @@ class PasteError extends Error {const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';++// A pending or hidden review keeps a paste out of public/unlisted visibility, whatever the owner asks later.++const reviewHidden = (p) => Boolean(p) && (p.review_status === 'pending' || ['sex_acts', 'minor_sexual'].includes(p.review_category));const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }@@@ -81,7 +83,7 @@ function sinceParam(value) {return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;}--function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null } = {}) {++function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null, review = null } = {}) {const L = { ...DEFAULT_LIMITS, ...limits };// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };@@@ -247,6 +249,10 @@ function createPasteService({ db, network = null, media = null, config = {}, limcopies: p.copies || 0,likes: p.likes || 0,is_nsfw: !!p.is_nsfw,++ review_category: p.review_category || null,++ review_status: p.review_status || null,++ review_report_id: p.review_report_id || null,++ reviewed_at: p.reviewed_at || null,ai_summary: p.ai_summary || null,ai_tags: p.ai_tags || null,ai_analyzed_at: p.ai_analyzed_at || null,@@@ -571,7 +577,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limmime_type: mime,media_visibility: 'private',};-- const row = await db.tx(async () => {++ let row = await db.tx(async () => {const r = await store.insertPaste(db, {slug,owner_subject: v.subject || null,@@@ -593,7 +599,18 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn r;});const sharing = await applyShareRequest(v, row.slug, audience, body);-- return await created(row, { paste: { ...(await shapeOne(row, v)), audience }, ...(sharing || {}) });++ // The review comes after the share row exists, so it can close the share and remove every++ // member in the same pass. The owner's requested visibility is kept for a retry that says fine.++ if (review && review.enabled) {++ await db.prepare('UPDATE pastes SET review_requested_visibility = ?, review_requested_audience = ?, review_requested_nsfw = ? WHERE id = ?')++ .run(visibility, audience, truthy(body.is_nsfw) ? 1 : 0, row.id);++ row = await store.getById(db, row.id) || row;++ row = (await review.reviewPaste(db, row, stored.url)) || row;++ }++ const reviewedAudience = (row.review_status === 'hidden' || row.review_status === 'quarantined' || row.review_status === 'pending') ? 'private' : audience;++ return await created(row, {++ paste: { ...(await shapeOne(row, v)), audience: reviewedAudience }, ...(sharing || {}),++ });},/** PUT /api/pastes/:slug — owner (or staff) only. */@@@ -609,6 +626,11 @@ function createPasteService({ db, network = null, media = null, config = {}, limpatch.content = content;patch.language = store.detectLanguage(content, body.language);}++ if (reviewHidden(p)) {++ const requested = body.audience !== undefined ? String(body.audience)++ : body.visibility !== undefined ? audienceOf(v, body.visibility) : null;++ if (requested && requested !== 'private') fail(409, 'This paste is hidden by an unsafe-image review and cannot be made public', { code: 'paste.review_hidden' });++ }let audience = null;if (body.audience !== undefined || body.visibility !== undefined) {audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);@@@ -810,6 +832,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limfor (const slug of slugs.slice(0, 500)) {const p = await store.getBySlug(db, String(slug));if (!p) { skipped++; continue; }++ if (reviewHidden(p) && (action === 'public' || action === 'unlisted')) { skipped++; continue; }if (action === 'delete') await store.softDelete(db, p.id);else {await store.setVisibility(db, p.id, action);@@@ -842,11 +865,12 @@ function createPasteService({ db, network = null, media = null, config = {}, limconsole.warn('[Pastes] censor upload failed:', err.message);fail(502, 'Media service unavailable');}-- const row = await db.tx(async () => {++ let row = await db.tx(async () => {const r = await store.setScreenshot(db, p.id, stored.url, stored.media_ref);await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });return r;});++ if (review && review.enabled) row = (await review.reviewPaste(db, row, stored.url)) || row;pingIndexNow(row);return { paste: await shapeOne(row, v) };},nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++// VIS-6 in Community: an image paste is sorted into exactly three categories by Vision, and each one acts.++// fine the paste keeps its visibility and serves++// sex_acts private + NSFW: gone from every public list and from an anonymous page, kept for its owner++// minor_sexual private + quarantined with the operator report id; the owner still sees it, nobody else does++// A Vision outage makes the paste private and pending, never public.++const assert = require('assert');++const http = require('http');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++const { createPasteReview } = require('../server/pastes/review');++const { createReviewRetry } = require('../server/pastes/review-retry');++++const PNG = (() => {++ const zlib = require('zlib');++ const chunk = (type, data) => {++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);++ const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);++ const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));++ return Buffer.concat([len, td, crc]);++ };++ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;++ return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);++})();++++(async () => {++ // ── Vision stand-in: a token, then one category for the next review ──++ const state = { category: 'fine', fail: false, requests: [] };++ const vision = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return send(200, { access_token: 'svc-vision', token_type: 'Bearer', expires_in: 300 });++ if (req.url === '/api/v1/review') {++ state.requests.push(JSON.parse(Buffer.concat(chunks).toString('utf8')));++ if (state.fail) return send(503, { code: 'vision.review_unavailable' });++ return send(201, {++ category: state.category,++ action: state.category === 'fine' ? 'allow' : state.category === 'sex_acts' ? 'hide' : 'quarantine_and_report',++ public: state.category === 'fine',++ report: state.category === 'minor_sexual' ? { id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'open' } : null,++ });++ }++ send(404, {});++ });++ });++ await new Promise((r) => vision.listen(0, '127.0.0.1', r));++ const visionBase = `http://127.0.0.1:${vision.address().port}`;++ const review = createPasteReview({++ config: { vision: { url: visionBase }, oauth: { clientId: 'community', clientSecret: 'shh' }, networkInternalUrl: visionBase },++ fetchImpl: fetch, log: { warn() {}, error() {} },++ });++ const media = { async upload({ mime }) { return { url: `https://openvibe.media/o/med_${crypto.randomBytes(10).toString('hex')}`, media_ref: 'med_x', size: PNG.length, mime }; } };++++ const t = await boot({ pasteLimits: { cooldownSeconds: 0 }, appOpts: { media, review } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++ const upload = async (cookie, fields = {}) => {++ const fd = new FormData();++ fd.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');++ for (const [k, v] of Object.entries(fields)) fd.append(k, String(v));++ return await t.get('/api/pastes/screenshot', { method: 'POST', body: fd, cookies: [`ov_token=${cookie}`] });++ };++ const as = (cookie, p) => t.get(p, { cookies: cookie ? [`ov_token=${cookie}`] : [] });++++ try {++ let fineSlug;++ await check('fine: the paste keeps its visibility and serves to everyone', async () => {++ state.category = 'fine';++ const r = await upload(alexJwt, { title: 'A receipt', visibility: 'public' });++ assert.strictEqual(r.status, 201, r.text);++ const p = r.json().paste;++ fineSlug = p.slug;++ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['fine', 'clear', 'public', false]);++ assert.strictEqual((await as(null, `/p/${fineSlug}`)).status, 200);++ assert.ok((await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === fineSlug));++ });++++ let sexSlug;++ await check('sex_acts: private and NSFW, gone from the public page and list, kept for its owner', async () => {++ state.category = 'sex_acts';++ const r = await upload(alexJwt, { title: 'Adult shot', visibility: 'public' });++ assert.strictEqual(r.status, 201, r.text);++ const p = r.json().paste;++ sexSlug = p.slug;++ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['sex_acts', 'hidden', 'private', true]);++ assert.strictEqual((await as(null, `/p/${sexSlug}`)).status, 404);++ assert.strictEqual((await as(samJwt, `/p/${sexSlug}`)).status, 404, 'another person cannot open it');++ assert.strictEqual((await as(alexJwt, `/p/${sexSlug}`)).status, 200, 'its owner keeps it');++ assert.ok(!(await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === sexSlug), 'never in the public list');++ const publish = await t.get(`/api/pastes/${sexSlug}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ visibility: 'public' }), cookies: [`ov_token=${alexJwt}`] });++ assert.deepStrictEqual([publish.status, publish.json().code], [409, 'paste.review_hidden'], 'the owner cannot re-publish it');++ const shareRow = await t.db.prepare("SELECT audience FROM shares WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").get(sexSlug);++ assert.strictEqual(shareRow.audience, 'private', 'the sharing gate is closed too');++ });++++ let minorSlug;++ await check('minor_sexual: private and quarantined with the operator report id', async () => {++ state.category = 'minor_sexual';++ const r = await upload(alexJwt, { title: 'Hard line', visibility: 'public' });++ assert.strictEqual(r.status, 201, r.text);++ const p = r.json().paste;++ minorSlug = p.slug;++ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['minor_sexual', 'quarantined', 'private', true]);++ assert.strictEqual(p.review_report_id, 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ');++ assert.strictEqual((await as(null, `/p/${minorSlug}`)).status, 404);++ assert.strictEqual((await as(alexJwt, `/p/${minorSlug}`)).status, 200, 'the owner sees their row');++ const row = await t.db.prepare('SELECT visibility, is_nsfw, review_report_id FROM pastes WHERE slug = ?').get(minorSlug);++ assert.deepStrictEqual([row.visibility, Number(row.is_nsfw)], ['private', 1]);++ assert.strictEqual(row.review_report_id, 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ');++ });++++ let pendingSlug;++ await check('Vision down: the paste is private and pending, never public, and keeps the owner\'s visibility', async () => {++ state.fail = true;++ const r = await upload(alexJwt, { title: 'Unreviewed', visibility: 'public' });++ assert.strictEqual(r.status, 201, r.text);++ const p = r.json().paste;++ pendingSlug = p.slug;++ assert.deepStrictEqual([p.visibility, p.review_status, p.is_nsfw], ['private', 'pending', true]);++ assert.strictEqual((await as(null, `/p/${p.slug}`)).status, 404);++ const row = await t.db.prepare('SELECT review_requested_visibility, review_requested_audience FROM pastes WHERE slug = ?').get(pendingSlug);++ assert.deepStrictEqual([row.review_requested_visibility, row.review_requested_audience], ['public', 'public']);++ });++++ await check('a retry that comes back fine restores the owner\'s visibility', async () => {++ state.fail = false;++ state.category = 'fine';++ const retry = createReviewRetry({ db: t.db, review, intervalMs: 0, log: { log() {}, warn() {}, error() {} } });++ const out = await retry.runOnce();++ assert.deepStrictEqual([out.checked, out.cleared], [1, 1]);++ const row = await t.db.prepare('SELECT visibility, review_status, is_nsfw FROM pastes WHERE slug = ?').get(pendingSlug);++ assert.deepStrictEqual([row.visibility, row.review_status, Number(row.is_nsfw)], ['public', 'clear', 0]);++ assert.strictEqual((await as(null, `/p/${pendingSlug}`)).status, 200, 'the public page is back');++ assert.ok((await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === pendingSlug));++ });++++ await check('the review request carries the paste object and the Media URL, never the bytes', async () => {++ const last = state.requests[state.requests.length - 1];++ assert.strictEqual(last.object_type, 'community.paste');++ assert.ok(last.object_id);++ assert.match(last.image_url, /^https:\/\/openvibe\.media\/o\/med_/);++ assert.ok(!JSON.stringify(last).includes('89504e47'), 'no image bytes in the review request');++ });++ } finally {++ await t.close();++ await new Promise((r) => vision.close(r));++ done();++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -32,6 +32,7 @@ const INVENTORY = {'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],++ 'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
@@ -6,7 +6,10 @@
@@ -6,7 +6,10 @@
`legacy:live:paste:<slug>` object it keeps `previous_media_ref` and retires the object through Media's internal legacy-paste
`legacy:live:paste:<slug>` object it keeps `previous_media_ref` and retires the object through Media's internal legacy-paste
route. `--apply` pages with an id cursor and reaches every paste in one run, `--retire-ids <file>` retires named objects
route. `--apply` pages with an id cursor and reaches every paste in one run, `--retire-ids <file>` retires named objects
whose paste record was lost (one Media id per line), and the dry run prints the moves, retires and refusals it would make.
whose paste record was lost (one Media id per line), and the dry run prints the moves, retires and refusals it would make.
-
+- Review follow-up: a failed review keeps the owner's requested visibility/audience/NSFW choice in
+ `review_requested_*`; `server/pastes/review-retry.js` retries pending reviews with backoff and restores the
+ visibility and share audience when Vision later says fine. Migration `0018_paste_review_retry.sql`, Contracts
+ v0.294.0.
- Sharing reaches pastes (design 2026-11-06, the pilot): `openvibe-sdk` v0.85.0 and `openvibe-shared` v4.114.0, with
- Sharing reaches pastes (design 2026-11-06, the pilot): `openvibe-sdk` v0.85.0 and `openvibe-shared` v4.114.0, with
`openvibe-contracts` v0.291.0. `migrations/0015_sharing.sql` adds openvibe-sdk/share's tables (expand), and
`openvibe-contracts` v0.291.0. `migrations/0015_sharing.sql` adds openvibe-sdk/share's tables (expand), and
`migrations/0016_sharing_backfill.sql` registers every existing paste once (public→public, unlisted→link,
`migrations/0016_sharing_backfill.sql` registers every existing paste once (public→public, unlisted→link,
@@ -19,6 +22,12 @@
@@ -19,6 +22,12 @@
`members` and `transfer_to` (one call makes a paste only two people can open and offers it). Screenshots upload to
`members` and `transfer_to` (one call makes a paste only two people can open and offers it). Screenshots upload to
Media as private objects; `npm run screenshots:private` re-hosts the existing unlisted ones. Burn-after-reading
Media as private objects; `npm run screenshots:private` re-hosts the existing unlisted ones. Burn-after-reading
pastes cannot use `people`. `docs/sharing.md` has the curls; `/docs/sharing`, `/llms.txt` and `/llms-full.txt` link it.
pastes cannot use `people`. `docs/sharing.md` has the curls; `/docs/sharing`, `/llms.txt` and `/llms-full.txt` link it.
+- Unsafe-image review (VIS-6): image pastes are reviewed by OpenVibe.Vision (`OV_VISION_URL`) before they are
+ returned. `fine` keeps its visibility, `sex_acts` becomes private and NSFW, `minor_sexual` becomes private,
+ NSFW and quarantined with the operator report id. A Vision outage makes the paste private and pending; the
+ owner cannot re-publish a reviewed paste. A hidden review also closes the paste's share row and removes its
+ members, so the sharing gate cannot reopen it. Migration `0017_paste_reviews.sql`, `server/pastes/review.js`,
+ tests `test/paste-review.test.js`.
- Threads carry content labels (L1, labels and choices, 2026-11-02): `migrations/0013_thread_content_labels.sql` adds
- Threads carry content labels (L1, labels and choices, 2026-11-02): `migrations/0013_thread_content_labels.sql` adds
`threads.content_labels` (`{ labels, suggested }`, common.content-labels@1). The author or a moderator sets them on the
`threads.content_labels` (`{ labels, suggested }`, common.content-labels@1). The author or a moderator sets them on the
@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
-- `openvibe-contracts` v0.291.0, `openvibe-sdk` v0.85.0 (events outbox, per-actor limits, no-looks, share),
+- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.85.0 (events outbox, per-actor limits, no-looks, share),
`openvibe-shared` v4.115.1 (the Share sheet), pinned by release tarball
`openvibe-shared` v4.115.1 (the Share sheet), pinned by release tarball
## How it fits the network
## How it fits the network
@@ -105,6 +105,16 @@ Moments feeds read it.
@@ -105,6 +105,16 @@ Moments feeds read it.
(`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
(`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
`media.object.upload`); the paste keeps the public URL and `media_ref`
`media.object.upload`); the paste keeps the public URL and `media_ref`
`legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
`legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
+- **Unsafe-image review (VIS-6)**: with `OV_VISION_URL` and Community's service principal, an image paste is
+ reviewed before it is returned. Vision sorts it into exactly three categories: `fine` keeps its visibility;
+ `sex_acts` becomes private and NSFW (gone from every public list, Pulse, search, sitemap and anonymous page,
+ kept for its owner); `minor_sexual` becomes private, NSFW and quarantined with the operator report id. If
+ Vision is unreachable the paste becomes private and `review_status='pending'`, never public; the visibility its
+ owner asked for is kept in `review_requested_visibility`/`_audience`/`_nsfw`, and the background retry
+ (`server/pastes/review-retry.js`, `COMMUNITY_REVIEW_RETRY_MS`, off at 0) restores it when a later review says
+ `fine`. Text pastes are not reviewed. `paste.review_category`, `review_status`, `review_report_id`,
+ `reviewed_at` and the requested fields are in the paste shape; the enforcement lives in `server/pastes/review.js`.
+ Tests: `test/paste-review.test.js`.
- **Slugs**: public pastes get a short `adj-noun-NN` slug; a new unlisted or private paste (and
- **Slugs**: public pastes get a short `adj-noun-NN` slug; a new unlisted or private paste (and
a fork of one) gets `adj-noun-` + 16 random base62 characters, because its slug is the only
a fork of one) gets `adj-noun-` + 16 random base62 characters, because its slug is the only
thing keeping it unlisted. Existing slugs never change and keep working.
thing keeping it unlisted. Existing slugs never change and keep working.
@@ -668,6 +678,8 @@ robots/llms/sitemap are public for 1 h, `/feed.xml` 15 min and the thread feeds
@@ -668,6 +678,8 @@ robots/llms/sitemap are public for 1 h, `/feed.xml` 15 min and the thread feeds
## Configuration
## Configuration
+- `OV_VISION_URL` — OpenVibe.Vision's base URL for the unsafe-image review; unset means no image review runs.
+
Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0600).
Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0600).
| Variable | Default | Meaning |
| Variable | Default | Meaning |
@@ -851,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
@@ -851,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.291.0
+- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.85.0
- openvibe-sdk: v0.85.0
- openvibe-shared: v4.115.1
- openvibe-shared: v4.115.1
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"port": 4200,
"domain": "openvibe.community",
"domain": "openvibe.community",
- "contracts": "openvibe-contracts v0.291.0",
+ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.85.0",
"sdk": "openvibe-sdk v0.85.0",
"shared": "openvibe-shared v4.115.1",
"shared": "openvibe-shared v4.115.1",
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- VIS-6's last item: an unsafe-image review sorts a paste into exactly three categories.
+-- fine allowed, including nudity
+-- sex_acts never public: private and NSFW (hidden from every public list, feed and search), kept for its owner
+-- minor_sexual always blocked: quarantined with the operator's report id from OpenVibe.Vision
+-- A visible image paste is 'pending' while Vision answers; a review failure leaves it private and pending,
+-- never public. Text pastes are not reviewed (no image).
+ALTER TABLE pastes ADD COLUMN review_category text COLLATE "C";
+ALTER TABLE pastes ADD COLUMN review_status text COLLATE "C";
+ALTER TABLE pastes ADD COLUMN review_report_id text COLLATE "C";
+ALTER TABLE pastes ADD COLUMN reviewed_at text COLLATE "C";
+ALTER TABLE pastes ADD CONSTRAINT pastes_review_category
+ CHECK (review_category IS NULL OR review_category IN ('fine', 'sex_acts', 'minor_sexual'));
+ALTER TABLE pastes ADD CONSTRAINT pastes_review_status
+ CHECK (review_status IS NULL OR review_status IN ('pending', 'clear', 'hidden', 'quarantined'));
+ALTER TABLE pastes ADD CONSTRAINT pastes_review_report
+ CHECK (review_report_id IS NULL OR review_report_id ~ '^vor_[0-9A-HJKMNP-TV-Z]{26}$');
+CREATE INDEX pastes_review_pending ON pastes (review_status) WHERE review_status = 'pending';
+CREATE INDEX pastes_review_category ON pastes (review_category) WHERE review_category IS NOT NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- VIS-6 review follow-up: a failed review keeps the visibility its owner asked for, so a retry that comes
+-- back fine can restore it. The private column remains what the public surfaces read while pending.
+ALTER TABLE pastes ADD COLUMN review_requested_visibility text COLLATE "C";
+ALTER TABLE pastes ADD COLUMN review_requested_audience text COLLATE "C";
+ALTER TABLE pastes ADD COLUMN review_requested_nsfw bigint;
+ALTER TABLE pastes ADD COLUMN review_attempts bigint NOT NULL DEFAULT 0;
+ALTER TABLE pastes ADD COLUMN review_next_at text COLLATE "C";
+ALTER TABLE pastes ADD CONSTRAINT pastes_review_requested_visibility
+ CHECK (review_requested_visibility IS NULL OR review_requested_visibility IN ('public', 'unlisted', 'private'));
+ALTER TABLE pastes ADD CONSTRAINT pastes_review_requested_audience
+ CHECK (review_requested_audience IS NULL OR review_requested_audience IN ('public', 'link', 'people', 'private'));
+CREATE INDEX pastes_review_retry ON pastes (review_next_at) WHERE review_status = 'pending';
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1192,9 +1192,9 @@
@@ -1192,9 +1192,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.291.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
- "integrity": "sha512-/jZNmCA6rLPFe2Jt1FJbC8rfELcMMd0fk7466HzoXrsKzGTXQaB/hr2sjnnm407+mcEYtKBcdB6Oisjyjehh1Q==",
+ "version": "0.294.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -247,10 +247,12 @@ async function createApp(opts = {}) {
@@ -247,10 +247,12 @@ async function createApp(opts = {}) {
// ── Pastes: Community's own store is the only authority ──
// ── Pastes: Community's own store is the only authority ──
{
{
- const service = createPasteService({ db, network, media: pasteMedia, config, limits: opts.pasteLimits, pulse, indexnow, shares: sharing });
+ const review = opts.review || require('./pastes/review').createPasteReview({ config });
+ const service = createPasteService({ db, network, media: pasteMedia, config, limits: opts.pasteLimits, pulse, indexnow, shares: sharing, review });
source.use(service);
source.use(service);
app.locals.pastes = service;
app.locals.pastes = service;
app.locals.pasteMedia = pasteMedia;
app.locals.pasteMedia = pasteMedia;
+ app.locals.pasteReview = review;
}
}
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
const anonWriteLimiter = rateLimit({
@@ -46,6 +46,12 @@ module.exports = {
@@ -46,6 +46,12 @@ module.exports = {
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
+ // OpenVibe.Vision — the unsafe-image review (VIS-6). Unset or without Community's service principal:
+ // no review runs and image pastes keep their old behaviour.
+ vision: {
+ url: (process.env.OV_VISION_URL || process.env.VISION_URL || '').replace(/\/$/, ''),
+ },
+
// OpenVibe.Media — public host that serves raw paste text and screenshots.
// OpenVibe.Media — public host that serves raw paste text and screenshots.
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
// Media's internal address: new screenshot uploads go to its file store (community app).
// Media's internal address: new screenshot uploads go to its file store (community app).
@@ -56,6 +56,12 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -56,6 +56,12 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }
try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }
// A paste whose audience becomes people/private leaves Search at once (sharing's onAudienceChange).
// A paste whose audience becomes people/private leaves Search at once (sharing's onAudienceChange).
if (app.locals.sharing) app.locals.sharing.useSearch(searchDocs);
if (app.locals.sharing) app.locals.sharing.useSearch(searchDocs);
+ // Pending unsafe-image reviews retry with backoff; a later fine restores the owner's visibility.
+ let reviewRetry = null;
+ try {
+ reviewRetry = require('./pastes/review-retry').createReviewRetry({ db: require('./db').getDb(), review: app.locals.pasteReview });
+ if (reviewRetry.start()) console.log('[Community] paste review retry: on');
+ } catch (err) { console.warn('[Pastes] review retry not started:', err.message); }
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
@@ -73,6 +79,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -73,6 +79,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
() => { if (subscriptions) subscriptions.stop(); },
() => { if (subscriptions) subscriptions.stop(); },
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
+ () => { if (reviewRetry) reviewRetry.stop(); },
],
],
close: [
close: [
() => within(1000, profilesDone),
() => within(1000, profilesDone),
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+
+/**
+ * Retry pending paste reviews (VIS-6 follow-up).
+ *
+ * A review that could not reach Vision leaves the paste private and `pending`, but keeps the visibility its
+ * owner asked for (`review_requested_visibility`, `review_requested_audience`, `review_requested_nsfw`). This
+ * job retries with backoff; when Vision answers `fine` it restores that visibility and the share row, so an
+ * owner's public paste is not left private by a reviewer outage. A retry that comes back hidden/quarantined
+ * stays closed. Off with `COMMUNITY_REVIEW_RETRY_MS=0`; tests call `runOnce()` directly.
+ */
+function createReviewRetry({ db, review, intervalMs = Number(process.env.COMMUNITY_REVIEW_RETRY_MS || 60_000), log = console } = {}) {
+ let timer = null;
+
+ function nextAt(attempts) {
+ const seconds = Math.min(6 * 3600, 60 * Math.pow(5, Math.min(5, Math.max(0, attempts))));
+ return new Date(Date.now() + seconds * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ }
+
+ async function restore(row) {
+ const visibility = row.review_requested_visibility || 'private';
+ const nsfw = Number(row.review_requested_nsfw) || 0;
+ const audience = row.review_requested_audience
+ || (visibility === 'public' ? 'public' : visibility === 'unlisted' ? 'link' : 'private');
+ await db.prepare('UPDATE pastes SET visibility = ?, is_nsfw = ? WHERE id = ?').run(visibility, nsfw, row.id);
+ await db.prepare("UPDATE shares SET audience = ?, revision = revision + 1 WHERE service = 'community' AND kind = 'paste' AND resource_id = ?")
+ .run(audience, row.slug).catch(() => {});
+ }
+
+ async function runOnce({ limit = 20 } = {}) {
+ if (!db || !review || !review.enabled) return { checked: 0, cleared: 0, closed: 0, failed: 0 };
+ const rows = await db.prepare(`SELECT * FROM pastes
+ WHERE type = 'screenshot' AND review_status = 'pending' AND deleted_at IS NULL
+ AND screenshot_url IS NOT NULL AND (review_next_at IS NULL OR review_next_at <= ov_now())
+ ORDER BY updated_at ASC LIMIT ?`).all(Math.min(100, Math.max(1, limit)));
+ let cleared = 0; let closed = 0; let failed = 0;
+ for (const row of rows) {
+ const updated = await review.reviewPaste(db, row, row.screenshot_url);
+ if (!updated) { failed++; continue; }
+ if (updated.review_category === 'fine') { await restore(updated); cleared++; continue; }
+ if (updated.review_status === 'hidden' || updated.review_status === 'quarantined') { closed++; continue; }
+ const attempts = Number(row.review_attempts || 0) + 1;
+ await db.prepare('UPDATE pastes SET review_attempts = ?, review_next_at = ? WHERE id = ?').run(attempts, nextAt(attempts), row.id);
+ failed++;
+ }
+ if (rows.length) log.log(`[Pastes] review retry: ${rows.length} checked, ${cleared} cleared, ${closed} closed, ${failed} still pending`);
+ return { checked: rows.length, cleared, closed, failed };
+ }
+
+ function start() {
+ if (timer || !intervalMs || !review || !review.enabled) return false;
+ timer = setInterval(() => { runOnce().catch((err) => log.warn('[Pastes] review retry:', err.message)); }, intervalMs);
+ if (timer.unref) timer.unref();
+ return true;
+ }
+ function stop() { if (timer) { clearInterval(timer); timer = null; } }
+ return { start, stop, runOnce, nextAt, restore };
+}
+
+module.exports = { createReviewRetry };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+
+/**
+ * Community's side of the unsafe-image review (VIS-6).
+ *
+ * An image paste goes to OpenVibe.Vision with its Media URL. Vision answers exactly one category:
+ *
+ * fine clear; the paste keeps the visibility its owner chose.
+ * sex_acts private and NSFW: every public list, the Pulse feed, search and the sitemap leave it out;
+ * the owner can still open it, and an adults-only section can use it later.
+ * minor_sexual private, NSFW and quarantined, with the operator report id Vision made. The hard line is
+ * always blocked, whoever posted it.
+ *
+ * If Vision cannot answer, the paste is made private and stays pending: an unreviewed image is never public.
+ * Text pastes are not sent. No image bytes are copied here; Community keeps only the Media link it already had.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createPasteReview({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String((config.vision && config.vision.url) || process.env.OV_VISION_URL || process.env.VISION_URL || '').replace(/\/+$/, '');
+ const enabled = Boolean(base && config.oauth && config.oauth.clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.vision',
+ scope: 'vision.image.review',
+ fetchImpl,
+ }) : null;
+
+ async function classify({ paste, imageUrl }, retried = false) {
+ const mediaId = /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(paste.media_ref || '')) ? paste.media_ref : null;
+ const res = await fetchImpl(`${base}/api/v1/review`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({
+ object_type: 'community.paste',
+ object_id: paste.slug,
+ object_app: 'community',
+ media_object_id: mediaId,
+ media_app: mediaId ? 'community' : null,
+ owner_subject: paste.owner_subject || null,
+ image_url: imageUrl,
+ preview_url: imageUrl,
+ }),
+ signal: AbortSignal.timeout(20000),
+ });
+ if (res.status === 401 && !retried) { try { tokens.invalidate(); } catch { /* older client */ } return await classify({ paste, imageUrl }, true); }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);
+ if (!body || !['fine', 'sex_acts', 'minor_sexual'].includes(body.category)) throw new Error('Vision review returned no known category');
+ return body;
+ }
+
+ /**
+ * A hidden paste is the owner's alone, so the share row must close too: after the sharing pilot the
+ * share audience is the authority (server/sharing.js), and the old visibility column alone would let
+ * canView reopen it. Members, pending transfers and access requests are removed for the same reason.
+ */
+ async function closeShares(db, paste) {
+ await db.prepare("UPDATE shares SET audience = 'private', revision = revision + 1 WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").run(paste.slug);
+ await db.prepare("DELETE FROM share_members WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").run(paste.slug);
+ await db.prepare("UPDATE share_transfers SET state = 'cancelled' WHERE service = 'community' AND kind = 'paste' AND resource_id = ? AND state = 'pending'").run(paste.slug);
+ await db.prepare("UPDATE share_access_requests SET state = 'dismissed' WHERE service = 'community' AND kind = 'paste' AND resource_id = ? AND state = 'pending'").run(paste.slug);
+ }
+
+ /** Write one category. Exported so a test or an operator route can apply the same three labels. */
+ async function applyCategory(db, paste, category, reportId = null) {
+ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) throw new Error(`unknown review category: ${category}`);
+ if (category !== 'fine') await closeShares(db, paste);
+ if (category === 'fine') {
+ await db.prepare("UPDATE pastes SET review_category = 'fine', review_status = 'clear', review_report_id = NULL, reviewed_at = ov_now() WHERE id = ?").run(paste.id);
+ } else if (category === 'sex_acts') {
+ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_category = 'sex_acts', review_status = 'hidden', review_report_id = NULL, reviewed_at = ov_now() WHERE id = ?").run(paste.id);
+ } else {
+ if (reportId && !/^vor_[0-9A-HJKMNP-TV-Z]{26}$/.test(reportId)) throw new Error('invalid operator report id');
+ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_category = 'minor_sexual', review_status = 'quarantined', review_report_id = ?, reviewed_at = ov_now() WHERE id = ?").run(reportId, paste.id);
+ log.error(`[Pastes] ${paste.slug} quarantined: sexual content involving a minor; operator report ${reportId || '(missing)'}`);
+ }
+ return await db.prepare('SELECT * FROM pastes WHERE id = ?').get(paste.id);
+ }
+
+ /** Review one image paste; a failure is closed (private + pending), never public. */
+ async function reviewPaste(db, paste, imageUrl) {
+ if (!enabled || !paste || paste.type !== 'screenshot' || !imageUrl) return null;
+ try {
+ const out = await classify({ paste, imageUrl });
+ const row = await applyCategory(db, paste, out.category, out.report && out.report.id ? out.report.id : null);
+ if (out.category === 'sex_acts') log.warn(`[Pastes] ${paste.slug} hidden: the review found sex acts or porn`);
+ return row;
+ } catch (err) {
+ await db.prepare("UPDATE pastes SET visibility = 'private', is_nsfw = 1, review_status = 'pending' WHERE id = ?").run(paste.id);
+ await closeShares(db, paste).catch(() => {});
+ log.warn(`[Pastes] ${paste.slug}: review failed, the paste stays private and pending (${String((err && err.message) || err).slice(0, 200)})`);
+ return await db.prepare('SELECT * FROM pastes WHERE id = ?').get(paste.id);
+ }
+ }
+
+ return { enabled, classify, reviewPaste, applyCategory, closeShares };
+}
+
+module.exports = { createPasteReview };
@@ -55,6 +55,8 @@ class PasteError extends Error {
@@ -55,6 +55,8 @@ class PasteError extends Error {
const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };
const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';
+// A pending or hidden review keeps a paste out of public/unlisted visibility, whatever the owner asks later.
+const reviewHidden = (p) => Boolean(p) && (p.review_status === 'pending' || ['sex_acts', 'minor_sexual'].includes(p.review_category));
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
@@ -81,7 +83,7 @@ function sinceParam(value) {
@@ -81,7 +83,7 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
}
}
-function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null } = {}) {
+function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null, review = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
@@ -247,6 +249,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -247,6 +249,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
copies: p.copies || 0,
copies: p.copies || 0,
likes: p.likes || 0,
likes: p.likes || 0,
is_nsfw: !!p.is_nsfw,
is_nsfw: !!p.is_nsfw,
+ review_category: p.review_category || null,
+ review_status: p.review_status || null,
+ review_report_id: p.review_report_id || null,
+ reviewed_at: p.reviewed_at || null,
ai_summary: p.ai_summary || null,
ai_summary: p.ai_summary || null,
ai_tags: p.ai_tags || null,
ai_tags: p.ai_tags || null,
ai_analyzed_at: p.ai_analyzed_at || null,
ai_analyzed_at: p.ai_analyzed_at || null,
@@ -571,7 +577,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -571,7 +577,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
mime_type: mime,
mime_type: mime,
media_visibility: 'private',
media_visibility: 'private',
};
};
- const row = await db.tx(async () => {
+ let row = await db.tx(async () => {
const r = await store.insertPaste(db, {
const r = await store.insertPaste(db, {
slug,
slug,
owner_subject: v.subject || null,
owner_subject: v.subject || null,
@@ -593,7 +599,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -593,7 +599,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return r;
return r;
});
});
const sharing = await applyShareRequest(v, row.slug, audience, body);
const sharing = await applyShareRequest(v, row.slug, audience, body);
- return await created(row, { paste: { ...(await shapeOne(row, v)), audience }, ...(sharing || {}) });
+ // The review comes after the share row exists, so it can close the share and remove every
+ // member in the same pass. The owner's requested visibility is kept for a retry that says fine.
+ if (review && review.enabled) {
+ await db.prepare('UPDATE pastes SET review_requested_visibility = ?, review_requested_audience = ?, review_requested_nsfw = ? WHERE id = ?')
+ .run(visibility, audience, truthy(body.is_nsfw) ? 1 : 0, row.id);
+ row = await store.getById(db, row.id) || row;
+ row = (await review.reviewPaste(db, row, stored.url)) || row;
+ }
+ const reviewedAudience = (row.review_status === 'hidden' || row.review_status === 'quarantined' || row.review_status === 'pending') ? 'private' : audience;
+ return await created(row, {
+ paste: { ...(await shapeOne(row, v)), audience: reviewedAudience }, ...(sharing || {}),
+ });
},
},
/** PUT /api/pastes/:slug — owner (or staff) only. */
/** PUT /api/pastes/:slug — owner (or staff) only. */
@@ -609,6 +626,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -609,6 +626,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
patch.content = content;
patch.content = content;
patch.language = store.detectLanguage(content, body.language);
patch.language = store.detectLanguage(content, body.language);
}
}
+ if (reviewHidden(p)) {
+ const requested = body.audience !== undefined ? String(body.audience)
+ : body.visibility !== undefined ? audienceOf(v, body.visibility) : null;
+ if (requested && requested !== 'private') fail(409, 'This paste is hidden by an unsafe-image review and cannot be made public', { code: 'paste.review_hidden' });
+ }
let audience = null;
let audience = null;
if (body.audience !== undefined || body.visibility !== undefined) {
if (body.audience !== undefined || body.visibility !== undefined) {
audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
@@ -810,6 +832,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -810,6 +832,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
for (const slug of slugs.slice(0, 500)) {
for (const slug of slugs.slice(0, 500)) {
const p = await store.getBySlug(db, String(slug));
const p = await store.getBySlug(db, String(slug));
if (!p) { skipped++; continue; }
if (!p) { skipped++; continue; }
+ if (reviewHidden(p) && (action === 'public' || action === 'unlisted')) { skipped++; continue; }
if (action === 'delete') await store.softDelete(db, p.id);
if (action === 'delete') await store.softDelete(db, p.id);
else {
else {
await store.setVisibility(db, p.id, action);
await store.setVisibility(db, p.id, action);
@@ -842,11 +865,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -842,11 +865,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
}
}
- const row = await db.tx(async () => {
+ let row = await db.tx(async () => {
const r = await store.setScreenshot(db, p.id, stored.url, stored.media_ref);
const r = await store.setScreenshot(db, p.id, stored.url, stored.media_ref);
await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
return r;
return r;
});
});
+ if (review && review.enabled) row = (await review.reviewPaste(db, row, stored.url)) || row;
pingIndexNow(row);
pingIndexNow(row);
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+// VIS-6 in Community: an image paste is sorted into exactly three categories by Vision, and each one acts.
+// fine the paste keeps its visibility and serves
+// sex_acts private + NSFW: gone from every public list and from an anonymous page, kept for its owner
+// minor_sexual private + quarantined with the operator report id; the owner still sees it, nobody else does
+// A Vision outage makes the paste private and pending, never public.
+const assert = require('assert');
+const http = require('http');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+const { createPasteReview } = require('../server/pastes/review');
+const { createReviewRetry } = require('../server/pastes/review-retry');
+
+const PNG = (() => {
+ const zlib = require('zlib');
+ const chunk = (type, data) => {
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
+ const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
+ return Buffer.concat([len, td, crc]);
+ };
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;
+ return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);
+})();
+
+(async () => {
+ // ── Vision stand-in: a token, then one category for the next review ──
+ const state = { category: 'fine', fail: false, requests: [] };
+ const vision = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return send(200, { access_token: 'svc-vision', token_type: 'Bearer', expires_in: 300 });
+ if (req.url === '/api/v1/review') {
+ state.requests.push(JSON.parse(Buffer.concat(chunks).toString('utf8')));
+ if (state.fail) return send(503, { code: 'vision.review_unavailable' });
+ return send(201, {
+ category: state.category,
+ action: state.category === 'fine' ? 'allow' : state.category === 'sex_acts' ? 'hide' : 'quarantine_and_report',
+ public: state.category === 'fine',
+ report: state.category === 'minor_sexual' ? { id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'open' } : null,
+ });
+ }
+ send(404, {});
+ });
+ });
+ await new Promise((r) => vision.listen(0, '127.0.0.1', r));
+ const visionBase = `http://127.0.0.1:${vision.address().port}`;
+ const review = createPasteReview({
+ config: { vision: { url: visionBase }, oauth: { clientId: 'community', clientSecret: 'shh' }, networkInternalUrl: visionBase },
+ fetchImpl: fetch, log: { warn() {}, error() {} },
+ });
+ const media = { async upload({ mime }) { return { url: `https://openvibe.media/o/med_${crypto.randomBytes(10).toString('hex')}`, media_ref: 'med_x', size: PNG.length, mime }; } };
+
+ const t = await boot({ pasteLimits: { cooldownSeconds: 0 }, appOpts: { media, review } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+ const upload = async (cookie, fields = {}) => {
+ const fd = new FormData();
+ fd.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
+ for (const [k, v] of Object.entries(fields)) fd.append(k, String(v));
+ return await t.get('/api/pastes/screenshot', { method: 'POST', body: fd, cookies: [`ov_token=${cookie}`] });
+ };
+ const as = (cookie, p) => t.get(p, { cookies: cookie ? [`ov_token=${cookie}`] : [] });
+
+ try {
+ let fineSlug;
+ await check('fine: the paste keeps its visibility and serves to everyone', async () => {
+ state.category = 'fine';
+ const r = await upload(alexJwt, { title: 'A receipt', visibility: 'public' });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = r.json().paste;
+ fineSlug = p.slug;
+ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['fine', 'clear', 'public', false]);
+ assert.strictEqual((await as(null, `/p/${fineSlug}`)).status, 200);
+ assert.ok((await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === fineSlug));
+ });
+
+ let sexSlug;
+ await check('sex_acts: private and NSFW, gone from the public page and list, kept for its owner', async () => {
+ state.category = 'sex_acts';
+ const r = await upload(alexJwt, { title: 'Adult shot', visibility: 'public' });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = r.json().paste;
+ sexSlug = p.slug;
+ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['sex_acts', 'hidden', 'private', true]);
+ assert.strictEqual((await as(null, `/p/${sexSlug}`)).status, 404);
+ assert.strictEqual((await as(samJwt, `/p/${sexSlug}`)).status, 404, 'another person cannot open it');
+ assert.strictEqual((await as(alexJwt, `/p/${sexSlug}`)).status, 200, 'its owner keeps it');
+ assert.ok(!(await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === sexSlug), 'never in the public list');
+ const publish = await t.get(`/api/pastes/${sexSlug}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ visibility: 'public' }), cookies: [`ov_token=${alexJwt}`] });
+ assert.deepStrictEqual([publish.status, publish.json().code], [409, 'paste.review_hidden'], 'the owner cannot re-publish it');
+ const shareRow = await t.db.prepare("SELECT audience FROM shares WHERE service = 'community' AND kind = 'paste' AND resource_id = ?").get(sexSlug);
+ assert.strictEqual(shareRow.audience, 'private', 'the sharing gate is closed too');
+ });
+
+ let minorSlug;
+ await check('minor_sexual: private and quarantined with the operator report id', async () => {
+ state.category = 'minor_sexual';
+ const r = await upload(alexJwt, { title: 'Hard line', visibility: 'public' });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = r.json().paste;
+ minorSlug = p.slug;
+ assert.deepStrictEqual([p.review_category, p.review_status, p.visibility, p.is_nsfw], ['minor_sexual', 'quarantined', 'private', true]);
+ assert.strictEqual(p.review_report_id, 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ');
+ assert.strictEqual((await as(null, `/p/${minorSlug}`)).status, 404);
+ assert.strictEqual((await as(alexJwt, `/p/${minorSlug}`)).status, 200, 'the owner sees their row');
+ const row = await t.db.prepare('SELECT visibility, is_nsfw, review_report_id FROM pastes WHERE slug = ?').get(minorSlug);
+ assert.deepStrictEqual([row.visibility, Number(row.is_nsfw)], ['private', 1]);
+ assert.strictEqual(row.review_report_id, 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ');
+ });
+
+ let pendingSlug;
+ await check('Vision down: the paste is private and pending, never public, and keeps the owner\'s visibility', async () => {
+ state.fail = true;
+ const r = await upload(alexJwt, { title: 'Unreviewed', visibility: 'public' });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = r.json().paste;
+ pendingSlug = p.slug;
+ assert.deepStrictEqual([p.visibility, p.review_status, p.is_nsfw], ['private', 'pending', true]);
+ assert.strictEqual((await as(null, `/p/${p.slug}`)).status, 404);
+ const row = await t.db.prepare('SELECT review_requested_visibility, review_requested_audience FROM pastes WHERE slug = ?').get(pendingSlug);
+ assert.deepStrictEqual([row.review_requested_visibility, row.review_requested_audience], ['public', 'public']);
+ });
+
+ await check('a retry that comes back fine restores the owner\'s visibility', async () => {
+ state.fail = false;
+ state.category = 'fine';
+ const retry = createReviewRetry({ db: t.db, review, intervalMs: 0, log: { log() {}, warn() {}, error() {} } });
+ const out = await retry.runOnce();
+ assert.deepStrictEqual([out.checked, out.cleared], [1, 1]);
+ const row = await t.db.prepare('SELECT visibility, review_status, is_nsfw FROM pastes WHERE slug = ?').get(pendingSlug);
+ assert.deepStrictEqual([row.visibility, row.review_status, Number(row.is_nsfw)], ['public', 'clear', 0]);
+ assert.strictEqual((await as(null, `/p/${pendingSlug}`)).status, 200, 'the public page is back');
+ assert.ok((await as(null, '/api/pastes')).json().pastes.some((x) => x.slug === pendingSlug));
+ });
+
+ await check('the review request carries the paste object and the Media URL, never the bytes', async () => {
+ const last = state.requests[state.requests.length - 1];
+ assert.strictEqual(last.object_type, 'community.paste');
+ assert.ok(last.object_id);
+ assert.match(last.image_url, /^https:\/\/openvibe\.media\/o\/med_/);
+ assert.ok(!JSON.stringify(last).includes('89504e47'), 'no image bytes in the review request');
+ });
+ } finally {
+ await t.close();
+ await new Promise((r) => vision.close(r));
+ done();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -32,6 +32,7 @@ const INVENTORY = {
@@ -32,6 +32,7 @@ const INVENTORY = {
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
+ 'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],