openvibers/openvibe.community Commits

Image pastes are reviewed into three categories: hidden or quarantined (#263)

OP OpenVibers committed 7 days ago · a612e2f016 · 15 files, +423 −14

* Image pastes are reviewed into three categories; sex acts hidden, minor content quarantined and reported

* Merge origin/main (paste sharing) and close the share row when a review hides a paste

* SSRF inventory: classify the Vision review client

* Pending image reviews keep the owner's visibility and retry until Vision clears them

* Docs: name openvibe-contracts v0.294.0

* Docs: the dependency list names openvibe-contracts v0.294.0

* Merge origin/main (screenshot move fix, shared 4.115.1) and keep the review retry

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex afa4125..7ddc6c5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,7 +6,10 @@
`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
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
`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 to
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.
++- 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
ddiff --git a/README.md b/README.md
iindex 79f54fe..1e88ebf 100644
---- a/README.md
++++ b/README.md
@@@ -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 (and
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.
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 2bdc223..4b2b032 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0017_paste_reviews.sql b/migrations/0017_paste_reviews.sql
nnew file mode 100644
iindex 0000000..6a7d0dc
---- /dev/null
++++ b/migrations/0017_paste_reviews.sql
@@@ -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;
ddiff --git a/migrations/0018_paste_review_retry.sql b/migrations/0018_paste_review_retry.sql
nnew file mode 100644
iindex 0000000..8d634ae
---- /dev/null
++++ b/migrations/0018_paste_review_retry.sql
@@@ -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';
ddiff --git a/package-lock.json b/package-lock.json
iindex b2b5b58..d10ec6d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c1dcf30..b8156a0 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex ae329f1..3099c7d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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({
ddiff --git a/server/config.js b/server/config.js
iindex 7284b26..8817790 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/server/index.js b/server/index.js
iindex 506c9c4..17b8fb1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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); }
// 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),
ddiff --git a/server/pastes/review-retry.js b/server/pastes/review-retry.js
nnew file mode 100644
iindex 0000000..bc14b9a
---- /dev/null
++++ b/server/pastes/review-retry.js
@@@ -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 };
ddiff --git a/server/pastes/review.js b/server/pastes/review.js
nnew file mode 100644
iindex 0000000..19ff639
---- /dev/null
++++ b/server/pastes/review.js
@@@ -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 };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 5419db3..181e11d 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
copies: 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 = {}, lim
mime_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 = {}, lim
return 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 = {}, lim
patch.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 = {}, lim
for (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 = {}, lim
console.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) };
},
ddiff --git a/test/paste-review.test.js b/test/paste-review.test.js
nnew file mode 100644
iindex 0000000..a1f1932
---- /dev/null
++++ b/test/paste-review.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 0d551a5..5dc0ca9 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],