openvibers/openvibe.community Commits
Staff actions on someone else's content publish community.moderation.action for the network's moderation audit log (ADR-022): paste edit, visibility change, delete, censor, bulk and fork cleanup, comment and post removal, thread delete, lock and unlock — in the same transaction as the change, never the content; an owner acting on their own content is not audited; openvibe-contracts 0.36.0
Changes
@@@ -17,7 +17,7 @@"helmet": "^8.1.0","highlight.js": "^11.11.1","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -1057,9 +1057,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"helmet": "^8.1.0","highlight.js": "^11.11.1","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -106,10 +106,22 @@ function commentCreated(comment, cthread) {}, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });}++/**++ * A staff action on someone else's content: community.moderation.action, for the network's++ * moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.++ */++function moderationAction(v, action, target, { reason = null, details = {} } = {}) {++ const actorSubject = v && v.subject ? v.subject : null;++ const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };++ return record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },++ { action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },++ { actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });++}++function status() {if (!outbox) return { enabled: false };return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };}function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }--module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };++module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };@@@ -28,6 +28,7 @@* gates it to their own members.*/const store = require('./store');++const events = require('../events');const { applyVote, myVotes, parseVote } = require('../votes');const { fail, isoTime } = require('../http/v1');const { createAuthors } = require('../identity/authors');@@@ -200,8 +201,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}function removeThread(v, thread) {-- if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');-- store.softDeleteThread(db, thread.id);++ const mine = person(v) && thread.author_subject === v.subject;++ if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');++ db.transaction(() => {++ store.softDeleteThread(db, thread.id);++ if (!mine) events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ })();if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });return { ok: true, id: thread.id, deleted: 'thread' };}@@@ -337,7 +342,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst { post, thread } = postFor(v, postId);if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');if (post.is_opening) return removeThread(v, thread);-- store.softDeletePost(db, post.id);++ db.transaction(() => {++ store.softDeletePost(db, post.id);++ if (!(person(v) && post.author_subject === v.subject)) events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });++ })();if (pulse) hook(() => pulse.postGone(post.id));return { ok: true, id: post.id };},@@@ -370,7 +378,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (body.pinned !== undefined) flags.pinned = !!body.pinned;if (body.locked !== undefined) flags.locked = !!body.locked;if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');-- const next = store.setThreadFlags(db, thread.id, flags);++ const next = db.transaction(() => {++ const r = store.setThreadFlags(db, thread.id, flags);++ if (flags.locked !== undefined && !!thread.locked !== flags.locked) events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ return r;++ })();const projections = await authors.projectionsFor([next.author_subject]);return { thread: shapeThread(next, space, v, projections, null) };},@@@ -113,8 +113,8 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = nrouter.get('/admin/stats', staffOnly, run(() => service.stats()));router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));-- router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));-- router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));++ router.delete('/admin/forks', staffOnly, run((req) => service.deleteForks(req.viewer)));++ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));@@@ -17,6 +17,7 @@* the JSON response, or throws PasteError(status, body).*/const crypto = require('crypto');++const events = require('../events');const store = require('./store');const { stripImageMetadata } = require('../media/strip-metadata');const { capabilities } = require('openvibe-contracts');@@@ -454,7 +455,12 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;if (!Object.keys(patch).length) fail(400, 'Nothing to update');-- const row = store.updatePaste(db, p.id, patch, v.subject || null);++ const row = db.transaction(() => {++ const r = store.updatePaste(db, p.id, patch, v.subject || null);++ // Staff editing someone else's paste goes to the moderation audit log (ADR-022).++ if (!isOwner(v, p) && isStaff(v)) events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });++ return r;++ })();tell((pl) => pl.pasteChanged(row));return { paste: await shapeOne(row, v) };},@@@ -464,7 +470,10 @@ function createPasteService({ db, network = null, media = null, config = {}, limneedIdentity(v);const p = visible(v, slug);if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');-- store.softDelete(db, p.id);++ db.transaction(() => {++ store.softDelete(db, p.id);++ if (!isOwner(v, p)) events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });++ })();tell((pl) => pl.pasteGone(p.slug));return { success: true };},@@@ -560,7 +569,10 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (!c || c.paste_id !== p.id) fail(404, 'Comment not found');const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');-- store.softDeleteComment(db, c.id);++ db.transaction(() => {++ store.softDeleteComment(db, c.id);++ if (!isAuthor && !isOwner(v, p)) events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });++ })();return { message: 'Comment deleted' };},@@@ -585,9 +597,15 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };},-- deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },++ deleteForks(v) {++ return db.transaction(() => {++ const deleted = store.deleteAllForks(db);++ events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });++ return { success: true, deleted };++ })();++ },-- bulk(body = {}) {++ bulk(body = {}, v = null) {const { slugs, action } = body;if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');@@@ -601,6 +619,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (action !== 'public') tell((pl) => pl.pasteGone(p.slug));done++;}++ if (done) events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });})();return { done, skipped };},@@@ -621,7 +640,11 @@ function createPasteService({ db, network = null, media = null, config = {}, limconsole.warn('[Pastes] censor upload failed:', err.message);fail(502, 'Media service unavailable');}-- const row = store.setScreenshot(db, p.id, stored.url, stored.media_ref);++ const row = db.transaction(() => {++ const r = store.setScreenshot(db, p.id, stored.url, stored.media_ref);++ events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });++ return r;++ })();return { paste: await shapeOne(row, v) };},@@@ -75,6 +75,32 @@ check('a comment on a Live VOD queues community.comment.created with its ref', (assert.ok(!JSON.stringify(evs).includes('nice stream'));});++check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', () => {++ const { createPasteService } = require('../server/pastes/service');++ const svc = createPasteService({ db });++ const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });++ pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });++ const before = queued().length;++ svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');++ svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');++ const evs = queued().slice(before);++ const mod = evs.filter((e) => e.event_type === 'community.moderation.action');++ assert.strictEqual(mod.length, 1, 'only the staff delete is audited');++ ok(mod[0]);++ assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });++ assert.strictEqual(mod[0].payload.actor_subject, STAFF);++ assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });++ assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');++ pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });++ const bulk = svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });++ assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);++ const last = queued().slice(-1)[0];++ assert.strictEqual(last.event_type, 'community.moderation.action');++ assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });++ ok(last);++});++events._reset();console.log(`community events: ${n} checks passed`);process.exit(0);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -106,10 +106,22 @@ function commentCreated(comment, cthread) {
@@ -106,10 +106,22 @@ function commentCreated(comment, cthread) {
}, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
}, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
}
}
+/**
+ * A staff action on someone else's content: community.moderation.action, for the network's
+ * moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.
+ */
+function moderationAction(v, action, target, { reason = null, details = {} } = {}) {
+ const actorSubject = v && v.subject ? v.subject : null;
+ const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };
+ return record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
+ { action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
+ { actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });
+}
+
function status() {
function status() {
if (!outbox) return { enabled: false };
if (!outbox) return { enabled: false };
return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
}
}
function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
-module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
+module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
* gates it to their own members.
* gates it to their own members.
*/
*/
const store = require('./store');
const store = require('./store');
+const events = require('../events');
const { applyVote, myVotes, parseVote } = require('../votes');
const { applyVote, myVotes, parseVote } = require('../votes');
const { fail, isoTime } = require('../http/v1');
const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
const { createAuthors } = require('../identity/authors');
@@ -200,8 +201,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -200,8 +201,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
function removeThread(v, thread) {
function removeThread(v, thread) {
- if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
- store.softDeleteThread(db, thread.id);
+ const mine = person(v) && thread.author_subject === v.subject;
+ if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
+ db.transaction(() => {
+ store.softDeleteThread(db, thread.id);
+ if (!mine) events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ })();
if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });
if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });
return { ok: true, id: thread.id, deleted: 'thread' };
return { ok: true, id: thread.id, deleted: 'thread' };
}
}
@@ -337,7 +342,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -337,7 +342,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { post, thread } = postFor(v, postId);
const { post, thread } = postFor(v, postId);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (post.is_opening) return removeThread(v, thread);
if (post.is_opening) return removeThread(v, thread);
- store.softDeletePost(db, post.id);
+ db.transaction(() => {
+ store.softDeletePost(db, post.id);
+ if (!(person(v) && post.author_subject === v.subject)) events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
+ })();
if (pulse) hook(() => pulse.postGone(post.id));
if (pulse) hook(() => pulse.postGone(post.id));
return { ok: true, id: post.id };
return { ok: true, id: post.id };
},
},
@@ -370,7 +378,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -370,7 +378,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
- const next = store.setThreadFlags(db, thread.id, flags);
+ const next = db.transaction(() => {
+ const r = store.setThreadFlags(db, thread.id, flags);
+ if (flags.locked !== undefined && !!thread.locked !== flags.locked) events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ return r;
+ })();
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
return { thread: shapeThread(next, space, v, projections, null) };
return { thread: shapeThread(next, space, v, projections, null) };
},
},
@@ -113,8 +113,8 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -113,8 +113,8 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
- router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));
- router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));
+ router.delete('/admin/forks', staffOnly, run((req) => service.deleteForks(req.viewer)));
+ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));
router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
* the JSON response, or throws PasteError(status, body).
* the JSON response, or throws PasteError(status, body).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
+const events = require('../events');
const store = require('./store');
const store = require('./store');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
@@ -454,7 +455,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -454,7 +455,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
- const row = store.updatePaste(db, p.id, patch, v.subject || null);
+ const row = db.transaction(() => {
+ const r = store.updatePaste(db, p.id, patch, v.subject || null);
+ // Staff editing someone else's paste goes to the moderation audit log (ADR-022).
+ if (!isOwner(v, p) && isStaff(v)) events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
+ return r;
+ })();
tell((pl) => pl.pasteChanged(row));
tell((pl) => pl.pasteChanged(row));
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
@@ -464,7 +470,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -464,7 +470,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
needIdentity(v);
needIdentity(v);
const p = visible(v, slug);
const p = visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
- store.softDelete(db, p.id);
+ db.transaction(() => {
+ store.softDelete(db, p.id);
+ if (!isOwner(v, p)) events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
+ })();
tell((pl) => pl.pasteGone(p.slug));
tell((pl) => pl.pasteGone(p.slug));
return { success: true };
return { success: true };
},
},
@@ -560,7 +569,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -560,7 +569,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
- store.softDeleteComment(db, c.id);
+ db.transaction(() => {
+ store.softDeleteComment(db, c.id);
+ if (!isAuthor && !isOwner(v, p)) events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
+ })();
return { message: 'Comment deleted' };
return { message: 'Comment deleted' };
},
},
@@ -585,9 +597,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -585,9 +597,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
},
},
- deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },
+ deleteForks(v) {
+ return db.transaction(() => {
+ const deleted = store.deleteAllForks(db);
+ events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });
+ return { success: true, deleted };
+ })();
+ },
- bulk(body = {}) {
+ bulk(body = {}, v = null) {
const { slugs, action } = body;
const { slugs, action } = body;
if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
@@ -601,6 +619,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -601,6 +619,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
done++;
done++;
}
}
+ if (done) events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
})();
})();
return { done, skipped };
return { done, skipped };
},
},
@@ -621,7 +640,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -621,7 +640,11 @@ 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 = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
+ const row = db.transaction(() => {
+ const r = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
+ events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
+ return r;
+ })();
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
@@ -75,6 +75,32 @@ check('a comment on a Live VOD queues community.comment.created with its ref', (
@@ -75,6 +75,32 @@ check('a comment on a Live VOD queues community.comment.created with its ref', (
assert.ok(!JSON.stringify(evs).includes('nice stream'));
assert.ok(!JSON.stringify(evs).includes('nice stream'));
});
});
+check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', () => {
+ const { createPasteService } = require('../server/pastes/service');
+ const svc = createPasteService({ db });
+ const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });
+ pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
+ const before = queued().length;
+ svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');
+ svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');
+ const evs = queued().slice(before);
+ const mod = evs.filter((e) => e.event_type === 'community.moderation.action');
+ assert.strictEqual(mod.length, 1, 'only the staff delete is audited');
+ ok(mod[0]);
+ assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });
+ assert.strictEqual(mod[0].payload.actor_subject, STAFF);
+ assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });
+ assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');
+ pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
+ const bulk = svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });
+ assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);
+ const last = queued().slice(-1)[0];
+ assert.strictEqual(last.event_type, 'community.moderation.action');
+ assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });
+ ok(last);
+});
+
events._reset();
events._reset();
console.log(`community events: ${n} checks passed`);
console.log(`community events: ${n} checks passed`);
process.exit(0);
process.exit(0);