openvibers/openvibe.community Commits
Community: a public moderation log per space, and RSS for every thread (#211)
Changes
@@@ -2,6 +2,9 @@## Unreleased++- The forum publishes its moderation (market outcome 5): `migrations/0012_forum_moderation_log.sql` adds `forum_moderation_log`, and every moderator lock, unlock, pin, unpin, post/thread delete and status change writes one row in the same transaction, with the optional rule and reason the forms now take. The row names the actor's role only (staff, moderator, owner, automatic), never their Network subject. `GET /s/:space/log` (and `GET /api/v1/spaces/:space/moderation-log`) shows the newest 100, with a removed thread shown as "a removed thread"; public spaces publish it, private spaces keep the same sign-in rule as their page.++- Each thread has an RSS feed of its newest 50 posts (`GET /s/:space/t/:slug/feed.xml`), the space and thread pages advertise their feeds in the head, and each shows a small RSS link.++- The forum is Community's again (owner decision, 2026-10-08). Reverts #19: `server/forum/*`, `server/relay/*`, `server/{chat-rooms,votes}.js`, `server/vip/*`, `server/render/{board,forum}.js` and their pages, feeds, search documents, events, migrations and suites are back, and `/s`, `/s/*` serve the forum here instead of a 301 to Space. Later changes are kept: the pin bumps, the Events move to openvibe.events (`connect-src`, the Pulse stream URL), and the `community.*` capability ids. `migrations/0006_contract_forum.sql` is gone: it drops the forum tables and never ran in production, so it must not. OpenVibe.Space becomes code hosting and "spaces" and redirects every forum URL here.- The forum capability ids Community serves (`community.post.create`, `community.space.read`, `community.space.manage`, `community.thread.read`) are retired in the currently pinned openvibe-contracts; until the release that makes them active again, `server/identity/capabilities.js` decides those grants locally with the library's own matching rule.- `test/account-data.test.js` closes its database; the post-#19 SDK left PGlite's idle timer keeping the process alive, so the file never exited.@@@ -134,10 +134,14 @@ Pages (server-rendered HTML):| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` || `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in || `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` |--| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` |--| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms |++| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |++| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |++| `GET /s/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |++| `GET /s/:space/t/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility || `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |--| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |++| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |++| `POST /s/:space/t/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |++| `POST /s/:space/t/:slug/post/:id/delete` | A moderator removes one post, with the same optional `rule` and `reason` || `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` || `GET /submissions`, `POST /submissions` | Accepted submissions — `?kind=clip\|art\|idea\|report`, `?mine=1` (your own, any status), `?after=`; signed-in people submit with the no-JS form || `GET /submissions/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |@@@ -167,6 +171,7 @@ API and machine endpoints:| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes || `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full || `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |++| `GET /s/:space/t/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 onlywhen the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the@@@ -395,6 +400,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| Route | What || --- | --- || `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |++| `GET /spaces/:space/moderation-log` | The space's newest 100 moderation rows `{ space, entries: [{ action, rule, reason, actor_role, created_at, thread, post_id }] }` — in role only, never the moderator's subject; public for public spaces || `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status || `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members || `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |@@@ -403,17 +409,17 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space || `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post || `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |--| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |++| `PUT /spaces/:space/threads/:slug/status` `{ status, rule?, reason? }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` || `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |--| `DELETE /spaces/:space/threads/:slug` | Author or moderator |++| `DELETE /spaces/:space/threads/:slug` `{ rule?, reason? }` | Author or moderator || `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply || `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |--| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |++| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked?, rule?, reason? }` | Moderators || `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) || `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) || `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) || `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |--| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |++| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` `{ rule?, reason? }` · `GET /posts/:id/versions` | Author or moderator || `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) || `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- A public moderation log per space (market outcome 5: "we publish what our moderation did and why").++-- Every moderator action (lock, unlock, pin, unpin, hide, delete, status) writes one row in the same++-- transaction as the action. The public row names the role only (staff, moderator, owner, automatic),++-- never the moderator's Network subject: a removed thread keeps its id and title here for rendering,++-- and a hard-deleted thread or post sets its reference to NULL.++CREATE TABLE forum_moderation_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ thread_id bigint REFERENCES threads(id) ON DELETE SET NULL,++ post_id bigint REFERENCES posts(id) ON DELETE SET NULL,++ action text COLLATE "C" NOT NULL CHECK(action IN ('lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status')),++ rule text COLLATE "C",++ reason text COLLATE "C" CHECK(reason IS NULL OR char_length(reason) <= 280),++ actor_role text COLLATE "C" NOT NULL CHECK(actor_role IN ('moderator', 'owner', 'staff', 'automatic')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE INDEX idx_forum_moderation_log_space ON forum_moderation_log (space_id, created_at DESC, id DESC);@@@ -189,6 +189,18 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }.vote-btn.active { background: var(--accent-strong, var(--accent, #3472d8)); border-color: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent)); }.mod-actions form { margin: 0; }++.mod-actions .mod-form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }++.mod-actions .mod-rule, .mod-actions .mod-reason, .post-mod .mod-rule, .post-mod .mod-reason {++ font: inherit; font-size: .82rem; padding: 3px 6px; border-radius: 6px;++ border: 1px solid var(--border-light); background: var(--bg-input); color: var(--text-primary); max-width: 100%;++}++.mod-actions .mod-reason, .post-mod .mod-reason { width: 13em; }++.post-mod { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0 0; }++.rss-link { display: inline-block; font-size: .78rem; font-weight: 600; color: var(--text-secondary); padding: 1px 7px; border: 1px solid var(--border-light); border-radius: 6px; vertical-align: middle; white-space: nowrap; }++.rss-link:hover { color: var(--accent-light); border-color: var(--accent); text-decoration: none; }++h1 .rss-link { font-size: .68rem; margin-left: 6px; }++.thread-follow { margin: 4px 0 0; }++.space-foot { margin: 20px 0 0; color: var(--text-secondary); }.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }.post-opening { border-color: var(--border-light); }@@@ -319,6 +331,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }.board-table td.topic-icon { display: none; }.board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }++ .mod-actions form, .post-mod { width: 100%; }++ .mod-actions .mod-reason, .post-mod .mod-reason { flex: 1 1 9em; width: auto; }}/* ── Ratings (Facepunch style) ── */nnew file mode 100644@@@ -0,0 +1,25 @@++/* OpenVibe.Community: the moderation log page (/s/:space/log), linked by that page alone (renderPage pageCss) so the++ other pages carry none of it. A table on a computer; on a phone each row is a card. */++.mod-log th:first-child { width: 7.5em; }++.mod-log th:nth-child(3) { width: 7em; }++.mod-log th:nth-child(4) { width: 9em; }++.mod-log th:last-child { width: 7em; }++.mod-log .log-reason { overflow-wrap: anywhere; }++@media (max-width: 720px) {++ /* The moderation log as cards: [Action] what · the reason · "Rule duplicate · Staff · 2 h ago" */++ .mod-log tr { display: flex; flex-wrap: wrap; align-items: baseline; padding: 12px 14px; }++ .mod-log tr::before { content: ''; order: 3; flex-basis: 100%; }++ .mod-log td { padding: 0; width: auto; min-width: 0; }++ .mod-log .log-act { order: 1; margin-right: 8px; }++ .mod-log .log-what { order: 2; flex: 1 1 12em; }++ .mod-log .log-reason { order: 4; flex-basis: 100%; margin-top: 6px; }++ .mod-log .log-rule { order: 5; }++ .mod-log .log-by { order: 6; }++ .mod-log .log-when { order: 7; }++ .mod-log .log-rule, .mod-log .log-by, .mod-log .log-when { margin-top: 6px; font-size: .8rem; color: var(--text-secondary); white-space: nowrap; }++ .mod-log .log-rule::before { content: 'Rule '; }++ .mod-log .log-by::before, .mod-log .log-when::before { content: '\00b7'; padding: 0 6px; }++ .mod-log .log-rule.is-none, .mod-log .log-reason.is-none { display: none; }++ .mod-log .log-rule.is-none + .log-reason + .log-by::before { content: none; padding: 0; }++ .mod-log th { width: auto; }++}@@@ -107,6 +107,17 @@ function threadFeedItems(threads) {});}++/** A thread's newest posts for /s/:space/t/:slug/feed.xml (opening post and replies). */++function postFeedItems(space, thread, posts) {++ const { markdownToText } = require('./render/markdown');++ return posts.map((p) => {++ const url = abs(`/s/${space.slug}/t/${thread.slug}#post-${p.id}`);++ const description = markdownToText(p.body_markdown || '', 300);++ return { title: p.is_opening ? thread.title : `Re: ${thread.title}`, link: url, guid: url, description, content: description,++ author: undefined, published: isoDate(p.created_at), updated: isoDate(p.updated_at || p.created_at) };++ });++}++function resetCaches() { _sitemap = null; _sitemapAt = 0; }--module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, pasteFeedItems, threadFeedItems, useForum, resetCaches };++module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, pasteFeedItems, threadFeedItems, postFeedItems, useForum, resetCaches };@@@ -6,13 +6,14 @@** GET /spaces spaces the caller can open* GET /spaces/:space one space++ * GET /spaces/:space/moderation-log its newest 100 moderation rows (public for public spaces)* GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)* POST /spaces/:space/threads { title, body } new thread (body is Markdown)* GET /spaces/:space/threads/:slug?page= thread + posts-- * DELETE /spaces/:space/threads/:slug author or moderator++ * DELETE /spaces/:space/threads/:slug { rule?, reason? } author or moderator* POST /spaces/:space/threads/:slug/posts { body } reply* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0-- * PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators++ * PUT /spaces/:space/threads/:slug/state { pinned?, locked?, rule?, reason? } moderators* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread@@@ -22,7 +23,7 @@* PUT /spaces/:space/categories/:category { name, description?, position? } moderators* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators-- * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,++ * PUT /spaces/:space/threads/:slug/status { status, rule?, reason? } moderators: requests open|planned|in_progress|done|declined,* roadmap items planned|in_progress|done|paused (?status= filters)* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators@@@ -78,6 +79,7 @@ function createSpacesApi({ forum, viewers, limits }) {router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));++ router.get('/:space/moderation-log', run(async (req) => await forum.moderationLog(req.viewer, p(req).space)));router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.@@@ -94,7 +96,7 @@ function createSpacesApi({ forum, viewers, limits }) {router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));-- router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));++ router.delete('/:space/threads/:slug', writeOrMod, editThread, jsonBody, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));@@@ -122,7 +124,7 @@ function createPostsApi({ forum, viewers, limits }) {router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));-- router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));++ router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), jsonBody, run(async (req) => await forum.deletePost(req.viewer, req.params.id, req.body || {})));router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * The public moderation log's vocabulary (migrations/0012_forum_moderation_log.sql): the actions a++ * moderator can take, the rules a space may cite, and the actor roles a row may name. The row never++ * carries the moderator's Network subject; the role is all the public log says about who acted.++ */++const { fail } = require('../http/v1');++++const ACTIONS = Object.freeze(['lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status']);++const ROLES = Object.freeze(['moderator', 'owner', 'staff', 'automatic']);++// Spaces have no rules field of their own yet, so every space cites this network-wide list.++const RULES = Object.freeze(['spam', 'off-topic', 'harassment', 'illegal content', 'personal information', 'duplicate', 'other']);++++/** The posted rule, from the allowed list only (case-insensitive), or null. */++function rule(value) {++ if (value === undefined || value === null || value === '') return null;++ const r = String(value).trim().toLowerCase();++ if (!RULES.includes(r)) fail(400, 'moderation.invalid_rule', `The rule is one of ${RULES.join(', ')}`);++ return r;++}++++/** The posted reason as plain one-line text, trimmed and capped at 280 characters, or null. */++function reason(value) {++ if (value === undefined || value === null) return null;++ const r = String(value).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();++ return r ? r.slice(0, 280) : null;++}++++/** { rule, reason } from a form or API body, with the rule and reason rules above. */++function fields(body = {}) {++ return { rule: rule(body.rule), reason: reason(body.reason) };++}++++/**++ * Who acted, in role only: discussion staff, the space's owner, or one of its moderators.++ * `isStaff` is the caller's discussions-moderator answer (identity/capabilities.js).++ */++function actorRole(viewer, space, isStaff) {++ if (isStaff) return 'staff';++ if (space && viewer && viewer.subject && (space.created_by === viewer.subject || space.members_only_owner === viewer.subject)) return 'owner';++ return 'moderator';++}++++module.exports = { ACTIONS, ROLES, RULES, rule, reason, fields, actorRole };@@@ -8,9 +8,12 @@* GET /s/feed.xml RSS: latest threads in public spaces* GET /s/:space threads (?sort=hot|new|top&page=)* GET /s/:space/feed.xml RSS: latest threads of one public space++ * GET /s/:space/log the space's public moderation log (newest 100)++ * GET /s/:space/t/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread* GET /s/:space/new POST /s/:space/new start a thread* GET /s/:space/t/:slug a thread (?page=)* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms++ * POST /s/:space/t/:slug/post/:id/delete a moderator removes a post (rule and reason)* POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)** A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the@@@ -135,6 +138,34 @@ function createForumRoutes({ forum, viewers, config }) {await threadFeed(res, space);}));++ // A thread's replies as RSS: newest 50, under the same visibility rule as the thread itself.++ router.get('/s/:space/t/:slug/feed.xml', withViewer, wrap(async (req, res, next) => {++ try {++ const out = await forum.threadFeed(req.viewer, req.params.space, req.params.slug);++ const base = `${config.baseUrl}/s/${out.space.slug}/t/${out.thread.slug}`;++ // A private or members-only thread's feed is the member's own: never kept by Cloudflare or a proxy.++ res.type('application/rss+xml').set('Cache-Control', out.shared ? cache.htmlHeaders({ maxAge: 300 }) : 'private, no-store').send(seo.feedXml({++ title: `${SITE_NAME} — ${out.thread.title}`,++ link: base,++ description: `New posts in ${out.thread.title} on ${out.space.name}.`,++ language: 'en',++ selfUrl: `${base}/feed.xml`,++ items: discovery.postFeedItems(out.space, out.thread, out.posts),++ }, { format: 'rss' }));++ } catch (err) {++ if (err instanceof ApiError) return res.status(err.status).type('text/plain').send(err.message);++ next(err);++ }++ }));++++ // The space's public moderation log: what moderation did and why, in role only.++ router.get('/s/:space/log', withViewer, wrap(async (req, res, next) => {++ try {++ const out = await forum.moderationLog(req.viewer, req.params.space);++ html(res, forumPages.moderationLogPage(out));++ } catch (err) { failPage(req, res, err, next); }++ }));++router.get('/s/:space', withViewer, wrap(async (req, res, next) => {try {const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });@@@ -233,7 +264,7 @@ function createForumRoutes({ forum, viewers, config }) {router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};-- const flags = {};++ const flags = { rule: b.rule, reason: b.reason };if (b.pinned !== undefined) flags.pinned = b.pinned === '1';if (b.locked !== undefined) flags.locked = b.locked === '1';try {@@@ -315,8 +346,9 @@ function createForumRoutes({ forum, viewers, config }) {// Status (moderators) and category (the author or moderators), no JS.router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const b = req.body || {};try {-- await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });++ await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String(b.status || ''), rule: b.rule, reason: b.reason });res.redirect(303, back(req));} catch (err) { failPage(req, res, err, next); }}));@@@ -328,9 +360,17 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));++ // A moderator removes one post (its opening post removes the thread): rule and reason are optional.++ router.post('/s/:space/t/:slug/post/:id/delete', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.deletePost(req.viewer, req.params.id, req.body || {});++ res.redirect(303, back(req));++ } catch (err) { failPage(req, res, err, next); }++ }));++router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {try {-- await forum.deleteThread(req.viewer, req.params.space, req.params.slug);++ await forum.deleteThread(req.viewer, req.params.space, req.params.slug, req.body || {});discovery.resetCaches();res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);} catch (err) { failPage(req, res, err, next); }@@@ -51,6 +51,7 @@ const { isUserSubject } = require('../vip');const { stripImageMetadata } = require('../media/strip-metadata');const { sniffImage } = require('../media/sniff-image');const reactions = require('./reactions');++const moderation = require('./moderation');const blocks = require('../identity/blocks');const STYLES = ['feed', 'forum'];const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;@@@ -125,6 +126,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst moderates = async (v, space) => moderator(v)|| (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));++ /**++ * One public moderation-log row, in the caller's transaction. The row names the actor's role only++ * (staff, owner, moderator), never their subject; `fields` is moderation.fields(body).++ */++ const logModeration = async (v, space, { thread = null, post = null, action, rule = null, reason = null }) => {++ await store.addModerationLog(db, {++ space_id: space.id, thread_id: thread ? thread.id : null, post_id: post ? post.id : null,++ action, rule, reason, actor_role: moderation.actorRole(v, space, moderator(v)),++ });++ };++// ── a space's chat room (OpenVibe.Chat) ──────────────────const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);const shapeChatRoom = (r) => (r ? {@@@ -355,12 +367,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));}-- async function removeThread(v, thread, space) {++ async function removeThread(v, thread, space, body = {}) {const mine = person(v) && thread.author_subject === v.subject;if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');++ const fields = moderation.fields(body);await db.tx(async () => {await store.softDeleteThread(db, thread.id);-- if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ if (!mine) {++ await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ await logModeration(v, space, { thread, action: 'delete', ...fields });++ }});if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));@@@ -662,6 +678,42 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi};},++ /**++ * A thread's feed: its newest live posts (opening and replies, 50 at most), under the same++ * visibility as the thread itself. → { space, thread: { slug, title }, posts }++ */++ async threadFeed(v, spaceSlug, threadSlug) {++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);++ await requireMembership(v, space, thread);++ return {++ space: await shapeSpace(space, await membersOnly(space.members_only_owner)),++ thread: { slug: thread.slug, title: thread.title },++ // Only a public, open thread's feed may sit in a shared cache; any other is the reader's own copy.++ shared: space.visibility === 'public' && !space.members_only_owner && !thread.members_only_owner,++ posts: await store.recentPosts(db, thread.id, 50),++ };++ },++++ /**++ * A space's public moderation log: its newest 100 actions with the rule and reason, in role only++ * (never the moderator's identity), public for public spaces and members-only for the rest.++ * → { space, entries: [{ id, action, rule, reason, actor_role, created_at, thread, post_id }] }++ */++ async moderationLog(v, spaceSlug) {++ const space = await spaceFor(v, spaceSlug);++ await requireMembership(v, space);++ const rows = await store.listModerationLog(db, space.id, { limit: 100 });++ return {++ space: await shapeSpace(space, await membersOnly(space.members_only_owner)),++ entries: rows.map((r) => ({++ id: r.id, action: r.action, rule: r.rule || null, reason: r.reason || null,++ actor_role: r.actor_role, created_at: isoTime(r.created_at),++ thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: `/s/${space.slug}/t/${r.thread_slug}` } : null,++ post_id: r.post_id || null,++ })),++ };++ },++/*** Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.* People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in@@@ -810,7 +862,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');const statuses = STATUSES[thread.kind] || [];if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');-- const next = await store.setThreadStatus(db, thread.id, body.status);++ const fields = moderation.fields(body);++ const next = await db.tx(async () => {++ const r = await store.setThreadStatus(db, thread.id, body.status);++ if (r.status !== thread.status) await logModeration(v, space, { thread, action: 'status', ...fields });++ return r;++ });const projections = await authors.projectionsFor([next.author_subject]);pingThread(space, next);return { thread: await shapeThread(next, space, v, projections, null) };@@@ -948,24 +1005,29 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi},/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */-- async deletePost(v, postId) {++ async deletePost(v, postId, body = {}) {const { post, thread, space } = await postFor(v, postId);-- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');-- if (post.is_opening) return await removeThread(v, thread, space);++ const mine = person(v) && post.author_subject === v.subject;++ if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');++ if (post.is_opening) return await removeThread(v, thread, space, body);++ const fields = moderation.fields(body);await db.tx(async () => {await store.softDeletePost(db, post.id);-- if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });++ if (!mine) {++ await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });++ await logModeration(v, space, { thread, post, action: 'delete', ...fields });++ }});if (pulse) await hook(async () => await pulse.postGone(post.id));-- if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));++ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, mine ? 'deleted by its author' : 'deleted by a moderator'));pingThread(space, thread);return { ok: true, id: post.id };},/** Delete a thread — its author or a moderator. */-- async deleteThread(v, spaceSlug, threadSlug) {++ async deleteThread(v, spaceSlug, threadSlug, body = {}) {const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- return await removeThread(v, thread, space);++ return await removeThread(v, thread, space, body);},/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */@@@ -991,9 +1053,16 @@ 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 fields = moderation.fields(body);const next = await db.tx(async () => {const r = await store.setThreadFlags(db, thread.id, flags);-- if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ if (flags.locked !== undefined && !!thread.locked !== flags.locked) {++ await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ await logModeration(v, space, { thread, action: flags.locked ? 'lock' : 'unlock', ...fields });++ }++ if (flags.pinned !== undefined && !!thread.pinned !== flags.pinned) {++ await logModeration(v, space, { thread, action: flags.pinned ? 'pin' : 'unpin', ...fields });++ }return r;});const projections = await authors.projectionsFor([next.author_subject]);@@@ -319,6 +319,25 @@ async function countPostsSince(db, subject, sinceSql) {return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;}++// ── public moderation log ────────────────────────────────────++/** One public log row. The actor is named by role only; never pass a subject here. */++async function addModerationLog(db, { space_id, thread_id = null, post_id = null, action, rule = null, reason = null, actor_role }) {++ await db.prepare(`INSERT INTO forum_moderation_log (space_id, thread_id, post_id, action, rule, reason, actor_role)++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(space_id, thread_id, post_id, action, rule, reason, actor_role);++}++++/** A space's newest log rows (100 by default), with enough of a thread to name it, even after deletion. */++async function listModerationLog(db, spaceId, { limit = 100 } = {}) {++ return await db.prepare(`SELECT l.*, t.title AS thread_title, t.slug AS thread_slug, t.deleted_at AS thread_deleted_at++ FROM forum_moderation_log l LEFT JOIN threads t ON t.id = l.thread_id++ WHERE l.space_id = ? ORDER BY l.created_at DESC, l.id DESC LIMIT ?`).all(spaceId, limit);++}++++/** The newest live posts of a thread (its feed): opening post included, tombstones left out. */++async function recentPosts(db, threadId, limit = 50) {++ return await db.prepare('SELECT * FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?').all(threadId, limit);++}++module.exports = {SORTS, slugify,listSpaces, getSpace, getSpaceById,@@@ -327,4 +346,5 @@ module.exports = {lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,listModerators, moderatesSpace, addModerator, removeModerator,getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,++ addModerationLog, listModerationLog, recentPosts,};@@@ -16,6 +16,7 @@* the services validate; response properties are the DTOs' output where the shape is read from code.*/const pkg = require('../package.json');++const { RULES: MODERATION_RULES } = require('./forum/moderation');const SITE = 'https://openvibe.community';@@@ -360,6 +361,22 @@ route('get', '/api/v1/spaces/{space}', {parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],responses: { 200: json('The space, its categories and moderators', { type: 'object' }), ...errors([404, 429]) },});++route('get', '/api/v1/spaces/{space}/moderation-log', {++ operationId: 'getModerationLog', summary: 'A space\'s public moderation log (newest 100)', tags: ['spaces'], security: ANONYMOUS,++ parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],++ description: 'Every moderator action with its rule and reason, in role only (staff, moderator, owner, automatic); never the moderator\'s identity. Public for public spaces; a members-only space keeps it for signed-in members, a staff space for staff.',++ responses: { 200: json('The space and its entries', { type: 'object', properties: {++ space: ref('Space'),++ entries: arrayOf({ type: 'object', properties: {++ id: { type: 'integer' }, action: { type: 'string', enum: ['lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status'] },++ rule: nullable('string', { enum: MODERATION_RULES }), reason: nullable('string', { maxLength: 280 }),++ actor_role: { type: 'string', enum: ['moderator', 'owner', 'staff', 'automatic'] },++ created_at: str({ format: 'date-time' }),++ thread: nullable('object', { properties: { slug: str(), title: str(), url: str() }, description: 'Null when the thread was removed' }),++ post_id: nullable('integer'),++ } }),++ } }), ...errors([401, 404, 429]) },++});route('get', '/api/v1/spaces/{space}/threads', {operationId: 'listThreads', summary: 'A space\'s threads', tags: ['spaces'], security: ANONYMOUS, parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })),@@@ -402,6 +419,7 @@ route('put', '/api/v1/spaces/{space}/threads/{slug}/status', {parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],requestBody: body({ type: 'object', required: ['status'], properties: {status: { type: 'string', enum: ['open', 'planned', 'in_progress', 'done', 'declined', 'paused'] },++ rule: str({ enum: MODERATION_RULES, description: 'The rule applied, if any' }), reason: str({ maxLength: 280 }),} }),responses: { 200: json('The thread'), ...errors([400, 404, 429]) },});@@@ -431,6 +449,7 @@ route('get', '/api/v1/spaces/{space}/threads/{slug}', {route('delete', '/api/v1/spaces/{space}/threads/{slug}', {operationId: 'deleteThread', summary: 'Delete a thread (its author or a moderator)', tags: ['spaces'], security: PERSON,parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],++ requestBody: body({ type: 'object', properties: { rule: str({ enum: MODERATION_RULES }), reason: str({ maxLength: 280 }) } }, false),responses: { 200: json('What was deleted'), ...errors([404, 429]) },});route('post', '/api/v1/spaces/{space}/threads/{slug}/posts', {@@@ -452,7 +471,10 @@ route('post', '/api/v1/spaces/{space}/threads/{slug}/votes', {route('put', '/api/v1/spaces/{space}/threads/{slug}/state', {operationId: 'moderateThread', summary: 'Pin or lock a thread (moderators)', tags: ['spaces'], security: PERSON,parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],-- requestBody: body({ type: 'object', properties: { pinned: { type: 'boolean' }, locked: { type: 'boolean' } } }),++ requestBody: body({ type: 'object', properties: {++ pinned: { type: 'boolean' }, locked: { type: 'boolean' },++ rule: str({ enum: MODERATION_RULES, description: 'The rule applied, if any' }), reason: str({ maxLength: 280 }),++ } }),responses: { 200: json('The thread'), ...errors([400, 404, 429]) },});route('put', '/api/v1/spaces/{space}/threads/{slug}/members-only', {@@@ -517,6 +539,7 @@ route('delete', '/api/v1/posts/{id}', {operationId: 'deletePost', summary: 'Delete a post (its author or a moderator)', tags: ['posts'], security: PERSON,parameters: [path('id', { type: 'string' })],description: 'community.post.delete.',++ requestBody: body({ type: 'object', properties: { rule: str({ enum: MODERATION_RULES }), reason: str({ maxLength: 280 }) } }, false),responses: { 200: json('What was deleted'), ...errors([404, 429]) },});route('get', '/api/v1/posts/{id}/versions', {@@@ -133,6 +133,7 @@ ${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one mofunction forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {const filtered = !!(category || status);const indexable = space.visibility === 'public' && !space.members_only && !filtered;++ const feedable = space.visibility === 'public' && !space.members_only;const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';@@@ -144,7 +145,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =const body = `<header class="page-head">${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}-- <h1>${esc(space.name)}${f.visBadge(space)}</h1>++ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}</header>${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}@@@ -157,14 +158,15 @@ ${threads.length ? `<table class="board-table topic-table"><caption class="sr-on<tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}${pagerHtml}</section>--${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;++${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}++<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;return renderPage({title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,canonicalPath: href({ page }),robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',active: 'spaces',-- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],body,});@@@ -187,7 +189,7 @@ function authorPanel(p) {</aside>`;}--function postbit(p, n, { base, page, catalogue, canReply }) {++function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">@@@ -198,6 +200,7 @@ function postbit(p, n, { base, page, catalogue, canReply }) {${f.postImages(p)}${f.pasteCards(p)}${f.reactionBar(p, base, page, catalogue)}++ ${canModerate && !p.is_opening ? f.postModerationForm(base, p) : ''}</div></article>`;}@@@ -229,13 +232,13 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe<div><h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}-- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>++ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p></div></header>${f.crosspostBlock(crosspost, base)}${f.modActions({ base, space, thread, viewer, categories })}${pagerHtml}-- <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>++ <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply, canModerate: viewer.can_moderate })).join('\n')}</div>${pagerHtml}${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}</article>`;@@@ -252,7 +255,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewehistoryType: 'page',historyTitle: thread.title,footerVariant: 'compact',-- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],++ feeds: [++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),++ { title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },++ ],jsonLd: [...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),@@@ -11,9 +11,12 @@ const { renderPage } = require('./layout');const { escapeHtml: esc } = require('./highlight');const { markdownToText } = require('./markdown');const { timeTag, fmtDate, num, authorHtml } = require('./pages');++const { RULES: MODERATION_RULES } = require('../forum/moderation');const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };++const ACTION_LABELS = { lock: 'Locked', unlock: 'Unlocked', pin: 'Pinned', unpin: 'Unpinned', hide: 'Hidden', delete: 'Deleted', status: 'Status changed' };++const ROLE_LABELS = { moderator: 'Moderator', owner: 'Owner', staff: 'Staff', automatic: 'Automatic' };/** A request's or roadmap item's status, and a thread's category. */const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');@@@ -37,6 +40,18 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nulreturn `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;}++/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */++function moderationFields(id) {++ const suffix = String(id).replace(/[^a-z0-9-]+/gi, '-');++ const label = (r) => r.charAt(0).toUpperCase() + r.slice(1);++ return `<label class="sr-only" for="rule-${suffix}">Rule applied</label><select class="mod-rule" id="rule-${suffix}" name="rule"><option value="">Rule applied…</option>${MODERATION_RULES.map((r) => `<option value="${esc(r)}">${esc(label(r))}</option>`).join('')}</select><label class="sr-only" for="reason-${suffix}">Reason (optional, 280 characters)</label><input class="mod-reason" id="reason-${suffix}" name="reason" maxlength="280" placeholder="Reason (optional)" autocomplete="off">`;++}++++/** A moderator's form to remove one post (its opening post removes the thread instead). */++function postModerationForm(base, post) {++ return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete post</button></form>`;++}++function pager(href, page, pages, labels = ['Newer', 'Older']) {if (pages <= 1) return '';return `<nav class="pager" aria-label="Pages">@@@ -114,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categoriesconst tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');const filtered = !!(category || status);const indexable = space.visibility === 'public' && !space.members_only && !filtered;++ const feedable = space.visibility === 'public' && !space.members_only;const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';@@@ -125,7 +141,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categoriesconst body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>-- <h1>${esc(space.name)}${visBadge(space)}</h1>++ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}${start}</header>@@@ -136,33 +152,34 @@ ${categoryNav}${statusNav}${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}</section>--${footer}`;++${footer}++<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;return renderPage({title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,canonicalPath: spaceHref(space.slug, { sort, page, category, status }),robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',active: 'spaces',-- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],body,});}--/** The pin / lock / delete / members-only / status / category buttons (no JS). */++/** The pin / lock / delete / members-only / status / category buttons (no JS), each with rule and reason. */function modActions({ base, space, thread, viewer, categories = [] }) {return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">-- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>-- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}-- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}++ ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>++ <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}++ ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}-- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}++ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="mod-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select>${moderationFields('status')}<button class="btn btn-sm" type="submit">Set status</button></form>` : ''}${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}</div>` : '';}// ── /s/:space/t/:slug ────────────────────────────────────────--function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {++function postHtml(p, n, { base = '', page = 1, catalogue = [], canModerate = false } = {}) {if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}"><header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>@@@ -170,6 +187,7 @@ function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {${postImages(p)}${pasteCards(p)}${reactionBar(p, base, page, catalogue)}++ ${canModerate && !p.is_opening ? postModerationForm(base, p) : ''}</article>`;}/** Pastes attached to a post: a card with the first lines (highlighted), or the screenshot. */@@@ -260,11 +278,12 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>++ <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p></div></header>${crosspostBlock(crosspost, base)}${modBlock}-- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>++ <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions, canModerate: viewer.can_moderate })).join('\n')}</div>${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}</article>`;@@@ -281,7 +300,10 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uhistoryType: 'page',historyTitle: thread.title,footerVariant: 'compact',-- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],++ feeds: [++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),++ { title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },++ ],jsonLd: [...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),@@@ -359,7 +381,54 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason});}++// ── /s/:space/log ────────────────────────────────────────────++/** What a log row removed, as the public log may name it: a live title, a post in it, or a removed thread. */++function logTarget(e) {++ if (!e.thread) return '<span class="muted">a removed thread</span>';++ const title = `<a href="${esc(e.thread.url)}">${esc(e.thread.title)}</a>`;++ return e.post_id ? `a post in ${title}` : title;++}++++/**++ * A space's public moderation log: a plain table on a computer and stacked rows on a phone (the++ * board table's own small-screen rules). No moderator is named — only the role that acted.++ */++function moderationLogPage({ space, entries }) {++ // On a phone each row is a card (public/css/community.css, .mod-log): the action and what it touched, the reason,++ // then the rule, who and when in one small line. A missing rule or reason is "—" on the table and left out there.++ const rows = entries.map((e) => `<tr>++ <td data-label="When" class="log-when">${timeTag(e.created_at)}</td>++ <td data-label="What" class="log-what">${logTarget(e)}</td>++ <td data-label="Action" class="log-act"><span class="badge">${esc(ACTION_LABELS[e.action] || e.action)}</span></td>++ <td data-label="Rule" class="log-rule${e.rule ? '' : ' is-none'}">${e.rule ? esc(e.rule) : '<span class="muted">—</span>'}</td>++ <td data-label="Reason" class="log-reason${e.reason ? '' : ' is-none'}">${e.reason ? esc(e.reason) : '<span class="muted">—</span>'}</td>++ <td data-label="By" class="log-by">${esc(ROLE_LABELS[e.actor_role] || e.actor_role)}</td>++ </tr>`).join('');++ const body = `++<header class="page-head">++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>++ <h1>Moderation log</h1>++ <p class="muted">What moderation did in ${esc(space.name)} and why. Newest first; who acted is shown by role only.</p>++</header>++${entries.length ? `<table class="board-table mod-log"><caption class="sr-only">Moderation actions in ${esc(space.name)}</caption>++ <thead><tr><th scope="col">When</th><th scope="col">What</th><th scope="col">Action</th><th scope="col">Rule</th><th scope="col">Reason</th><th scope="col">By</th></tr></thead>++ <tbody>${rows}</tbody></table>` : '<p class="empty">No moderation actions in this forum yet.</p>'}++<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/s/${esc(space.slug)}/feed.xml">RSS</a>` : ''}</footer>`;++ return renderPage({++ title: `Moderation log · ${space.name}`,++ description: `What moderation did in ${space.name} on OpenVibe.Community, and why: the action, the rule and the reason.`,++ canonicalPath: `/s/${space.slug}/log`,++ robots: space.visibility === 'public' && !space.members_only ? 'index,follow' : 'noindex,nofollow',++ active: 'spaces',++ footerVariant: 'compact',++ pageCss: ['modlog.css'],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: 'Moderation log', url: `/s/${space.slug}/log` }])],++ body,++ });++}++module.exports = {-- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,++ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, moderationLogPage, spaceHref, who, chatRoomBox,pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,++ postModerationForm,};@@@ -111,6 +111,7 @@ function footerInit(opts) {* feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),++ * pageCss (this site's own stylesheets for one page, linked after community.css: ['modlog.css']),* published/modified (ISO, for article:*), noFrame (error pages during outages)*/function renderPage(o) {@@@ -143,6 +144,7 @@ function renderPage(o) {...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),`<link rel="stylesheet" href="${asset('css/community.css')}">`,++ ...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),`<script src="${asset('js/community.js')}" defer></script>`,// The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01).`<script id="ov-glyphs-loader" src="${ovServe.url('ov-glyphs.js')}" defer></script>`,nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++/**++ * The public moderation log (migrations/0012_forum_moderation_log.sql) and a thread's RSS feed:++ * every moderator action writes one row in the same transaction, in role only (never the actor's++ * subject); the rule is from the allowed list and the reason is trimmed and capped; private spaces++ * keep their log behind the same sign-in rule as the space; a removed thread is shown as one; and++ * the space and thread pages advertise their feeds.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to find: ${s}`);++++(async () => {++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const boss = net.addUser({ network_user_id: 1, username: 'boss', display_name: 'Boss' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const bossJwt = net.sign({ id: 1, subject_id: boss.subject_id, username: 'boss', display_name: 'Boss', role: 'global_mod' });++ const admin = { id: 2, subject_id: ids.newId('user'), username: 'admin', display_name: 'Admin', role: 'admin' };++ const adminJwt = net.sign(admin);++++ const call = (path, { method = 'GET', cookie, json } = {}) => t.get(path, {++ method, headers: json !== undefined ? { 'content-type': 'application/json' } : {},++ body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [],++ });++ const form = (path, cookie, fields) => t.get(path, {++ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${cookie}`],++ });++ const rows = async (space = 'general') => t.db.prepare(`SELECT l.* FROM forum_moderation_log l JOIN spaces s ON s.id = l.space_id++ WHERE s.slug = ? ORDER BY l.id ASC`).all(space);++ const thread = async (space, title) => {++ const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', cookie: alexJwt, json: { title, body: `A thread for ${title}` } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().thread;++ };++++ let locked;++ let feedback;++++ await check('lock, pin, post delete and status each write one log row in role only, with rule and reason', async () => {++ locked = await thread('general', 'A thread that will be moderated');++ const reply = await call(`/api/v1/spaces/general/threads/${locked.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A reply that will be removed' } });++ assert.strictEqual(reply.status, 201, reply.text);++ let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'spam', reason: ' Repeated links ' } });++ assert.strictEqual(r.status, 200, r.text);++ r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { pinned: '1', rule: 'off-topic', reason: ` Pinned for ${'x'.repeat(300)} ` });++ assert.strictEqual(r.status, 303, r.text);++ r = await form(`/s/general/t/${locked.slug}/post/${reply.json().post.id}/delete`, bossJwt, { rule: 'personal information', reason: 'Names someone' });++ assert.strictEqual(r.status, 303, r.text);++ feedback = await thread('feedback', 'A request whose status changes');++ r = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/status`, { method: 'PUT', cookie: bossJwt, json: { status: 'planned', rule: 'duplicate', reason: 'Already planned elsewhere' } });++ assert.strictEqual(r.status, 200, r.text);++++ const generalLog = await rows();++ const feedbackLog = await rows('feedback');++ assert.deepStrictEqual(generalLog.map((x) => x.action), ['lock', 'pin', 'delete']);++ assert.deepStrictEqual(feedbackLog.map((x) => x.action), ['status']);++ assert.deepStrictEqual(generalLog.map((x) => x.rule), ['spam', 'off-topic', 'personal information']);++ assert.strictEqual(feedbackLog[0].rule, 'duplicate');++ assert.strictEqual(generalLog[0].reason, 'Repeated links');++ assert.strictEqual(generalLog[1].reason.length, 280, 'the reason is capped at 280 characters');++ assert.strictEqual(generalLog[1].reason, `Pinned for ${'x'.repeat(280 - 'Pinned for '.length)}`);++ assert.strictEqual(generalLog[2].reason, 'Names someone');++ assert.strictEqual(feedbackLog[0].reason, 'Already planned elsewhere');++ assert.deepStrictEqual([...generalLog, ...feedbackLog].map((x) => x.actor_role), ['staff', 'staff', 'staff', 'staff']);++ assert.strictEqual(generalLog[2].thread_id, locked.id);++ assert.strictEqual(generalLog[2].post_id, reply.json().post.id);++ assert.strictEqual(feedbackLog[0].thread_id, feedback.id);++ const dump = JSON.stringify([...generalLog, ...feedbackLog]);++ assert.ok(!dump.includes(alex.subject_id), "the author's subject is not in the public row");++ assert.ok(!dump.includes(boss.subject_id), "the moderator's subject is not in the public row");++ });++++ await check('the rule list is enforced; a no-op change writes nothing', async () => {++ const before = (await rows()).length;++ let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: false, rule: 'not-a-rule' } });++ assert.strictEqual(r.status, 400, r.text);++ assert.strictEqual(r.json().code, 'moderation.invalid_rule');++ r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });++ assert.strictEqual(r.status, 400, r.text);++ has(r.text, 'The rule is one of spam');++ assert.strictEqual((await rows()).length, before, 'a refused rule writes no row');++ r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await rows()).length, before, 'a lock that changes nothing writes no row');++ });++++ await check('the log page and JSON API show what happened, and the space page links it', async () => {++ const page = await t.get('/s/general/log');++ assert.strictEqual(page.status, 200, page.text);++ has(page.text, '<h1>Moderation log</h1>');++ has(page.text, 'A thread that will be moderated');++ has(page.text, 'a post in');++ has(page.text, 'Repeated links');++ has(page.text, 'Staff');++ assert.ok(!page.text.includes(alex.subject_id) && !page.text.includes(boss.subject_id), 'no subject on the page');++ const json = await call('/api/v1/spaces/general/moderation-log');++ assert.strictEqual(json.status, 200, json.text);++ assert.deepStrictEqual(json.json().entries.map((e) => e.action), ['delete', 'pin', 'lock']);++ assert.strictEqual(json.json().space.slug, 'general');++ assert.ok(!json.text.includes(boss.subject_id));++ const feedbackLog = await t.get('/s/feedback/log');++ assert.strictEqual(feedbackLog.status, 200);++ has(feedbackLog.text, 'A request whose status changes');++ has(feedbackLog.text, 'Status changed');++ const space = await t.get('/s/general');++ has(space.text, 'href="/s/general/log"');++ has(space.text, 'Moderation log');++ has(space.text, '<link rel="alternate" type="application/rss+xml"');++ });++++ await check('a thread feed is its newest posts, advertised in the thread head and linked on the page', async () => {++ const reply = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A feed reply with **markup**' } });++ assert.strictEqual(reply.status, 201, reply.text);++ const page = await t.get(`/s/feedback/t/${feedback.slug}`);++ has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/s/feedback/t/${feedback.slug}/feed.xml">`);++ has(page.text, 'Follow this thread by RSS');++ const feed = await t.get(`/s/feedback/t/${feedback.slug}/feed.xml`);++ assert.strictEqual(feed.status, 200, feed.text);++ assert.match(feed.headers.get('content-type'), /application\/rss\+xml/);++ assert.match(feed.headers.get('cache-control'), /^public/, 'a public thread\'s feed may be cached');++ has(feed.text, 'A request whose status changes');++ has(feed.text, `Re: A request whose status changes`);++ has(feed.text, 'A feed reply with markup');++ const items = feed.text.split('<item>').slice(1);++ has(items[0], 'A feed reply with markup', 'the newest post is the first item');++ has(items[1], 'A request whose status changes');++ const space = await t.get('/s/feedback');++ has(space.text, '<link rel="alternate" type="application/rss+xml"');++ has(space.text, '/s/feedback/feed.xml');++ });++++ await check('a private space\'s log is not public; its members and staff read it', async () => {++ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();++ const insider = await call('/api/v1/spaces/insiders/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Inside the members space', body: 'Members only' } });++ assert.strictEqual(insider.status, 201, insider.text);++ assert.strictEqual((await call(`/api/v1/spaces/insiders/threads/${insider.json().thread.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'harassment' } })).status, 200);++ assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log')).status, 401, 'signed out: no JSON log');++ const anon = await t.get('/s/insiders/log');++ assert.notStrictEqual(anon.status, 200, 'signed out: no page');++ assert.strictEqual((await t.get('/s/insiders/log', { cookies: [`ov_token=${alexJwt}`] })).status, 200, 'a signed-in member reads it');++ assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log', { cookie: alexJwt })).status, 200);++ const feedUrl = `/s/insiders/t/${insider.json().thread.slug}/feed.xml`;++ assert.notStrictEqual((await t.get(feedUrl)).status, 200, 'signed out: no feed of a members thread');++ const own = await t.get(feedUrl, { cookies: [`ov_token=${alexJwt}`] });++ assert.strictEqual(own.status, 200, own.text);++ assert.strictEqual(own.headers.get('cache-control'), 'private, no-store', 'a members thread\'s feed is never kept by a shared cache');++ });++++ await check('a removed thread is shown as "a removed thread"', async () => {++ const r = await call(`/api/v1/spaces/general/threads/${locked.slug}`, { method: 'DELETE', cookie: bossJwt, json: { rule: 'illegal content', reason: 'Removed by a moderator' } });++ assert.strictEqual(r.status, 200, r.text);++ const log = await rows();++ assert.strictEqual(log.at(-1).action, 'delete');++ assert.strictEqual(log.at(-1).rule, 'illegal content');++ const page = await t.get('/s/general/log');++ assert.strictEqual(page.status, 200);++ has(page.text, 'a removed thread');++ has(page.text, 'Removed by a moderator');++ assert.ok(!page.text.includes('A thread that will be moderated'), 'the deleted title is not shown');++ const json = await call('/api/v1/spaces/general/moderation-log');++ assert.strictEqual(json.json().entries[0].action, 'delete');++ assert.strictEqual(json.json().entries[0].thread, null);++ });++++ await check('the forms carry the rule select and reason box', async () => {++ const thread2 = await thread('general', 'A thread with moderation controls');++ const reply = await call(`/api/v1/spaces/general/threads/${thread2.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A reply with its own delete form' } });++ assert.strictEqual(reply.status, 201, reply.text);++ const page = await t.get(`/s/general/t/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });++ assert.strictEqual(page.status, 200);++ has(page.text, 'Rule applied…');++ has(page.text, 'name="reason"');++ has(page.text, 'maxlength="280"');++ has(page.text, `<form class="mod-form" method="post" action="/s/general/t/${thread2.slug}/state">`);++ has(page.text, `action="/s/general/t/${thread2.slug}/post/${reply.json().post.id}/delete"`);++ has(page.text, 'Delete post');++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,6 +2,9 @@
@@ -2,6 +2,9 @@
## Unreleased
## Unreleased
+- The forum publishes its moderation (market outcome 5): `migrations/0012_forum_moderation_log.sql` adds `forum_moderation_log`, and every moderator lock, unlock, pin, unpin, post/thread delete and status change writes one row in the same transaction, with the optional rule and reason the forms now take. The row names the actor's role only (staff, moderator, owner, automatic), never their Network subject. `GET /s/:space/log` (and `GET /api/v1/spaces/:space/moderation-log`) shows the newest 100, with a removed thread shown as "a removed thread"; public spaces publish it, private spaces keep the same sign-in rule as their page.
+- Each thread has an RSS feed of its newest 50 posts (`GET /s/:space/t/:slug/feed.xml`), the space and thread pages advertise their feeds in the head, and each shows a small RSS link.
+
- The forum is Community's again (owner decision, 2026-10-08). Reverts #19: `server/forum/*`, `server/relay/*`, `server/{chat-rooms,votes}.js`, `server/vip/*`, `server/render/{board,forum}.js` and their pages, feeds, search documents, events, migrations and suites are back, and `/s`, `/s/*` serve the forum here instead of a 301 to Space. Later changes are kept: the pin bumps, the Events move to openvibe.events (`connect-src`, the Pulse stream URL), and the `community.*` capability ids. `migrations/0006_contract_forum.sql` is gone: it drops the forum tables and never ran in production, so it must not. OpenVibe.Space becomes code hosting and "spaces" and redirects every forum URL here.
- The forum is Community's again (owner decision, 2026-10-08). Reverts #19: `server/forum/*`, `server/relay/*`, `server/{chat-rooms,votes}.js`, `server/vip/*`, `server/render/{board,forum}.js` and their pages, feeds, search documents, events, migrations and suites are back, and `/s`, `/s/*` serve the forum here instead of a 301 to Space. Later changes are kept: the pin bumps, the Events move to openvibe.events (`connect-src`, the Pulse stream URL), and the `community.*` capability ids. `migrations/0006_contract_forum.sql` is gone: it drops the forum tables and never ran in production, so it must not. OpenVibe.Space becomes code hosting and "spaces" and redirects every forum URL here.
- The forum capability ids Community serves (`community.post.create`, `community.space.read`, `community.space.manage`, `community.thread.read`) are retired in the currently pinned openvibe-contracts; until the release that makes them active again, `server/identity/capabilities.js` decides those grants locally with the library's own matching rule.
- The forum capability ids Community serves (`community.post.create`, `community.space.read`, `community.space.manage`, `community.thread.read`) are retired in the currently pinned openvibe-contracts; until the release that makes them active again, `server/identity/capabilities.js` decides those grants locally with the library's own matching rule.
- `test/account-data.test.js` closes its database; the post-#19 SDK left PGlite's idle timer keeping the process alive, so the file never exited.
- `test/account-data.test.js` closes its database; the post-#19 SDK left PGlite's idle timer keeping the process alive, so the file never exited.
@@ -134,10 +134,14 @@ Pages (server-rendered HTML):
@@ -134,10 +134,14 @@ Pages (server-rendered HTML):
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
| `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` |
| `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` |
-| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` |
-| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms |
+| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |
+| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |
+| `GET /s/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |
+| `GET /s/:space/t/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
-| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
+| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |
+| `POST /s/:space/t/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |
+| `POST /s/:space/t/:slug/post/:id/delete` | A moderator removes one post, with the same optional `rule` and `reason` |
| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
| `GET /submissions`, `POST /submissions` | Accepted submissions — `?kind=clip\|art\|idea\|report`, `?mine=1` (your own, any status), `?after=`; signed-in people submit with the no-JS form |
| `GET /submissions`, `POST /submissions` | Accepted submissions — `?kind=clip\|art\|idea\|report`, `?mine=1` (your own, any status), `?after=`; signed-in people submit with the no-JS form |
| `GET /submissions/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |
| `GET /submissions/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |
@@ -167,6 +171,7 @@ API and machine endpoints:
@@ -167,6 +171,7 @@ API and machine endpoints:
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full |
| `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
+| `GET /s/:space/t/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
@@ -395,6 +400,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -395,6 +400,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| Route | What |
| Route | What |
| --- | --- |
| --- | --- |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
+| `GET /spaces/:space/moderation-log` | The space's newest 100 moderation rows `{ space, entries: [{ action, rule, reason, actor_role, created_at, thread, post_id }] }` — in role only, never the moderator's subject; public for public spaces |
| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |
| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |
| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
@@ -403,17 +409,17 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -403,17 +409,17 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space |
| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space |
| `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post |
| `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post |
| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
-| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
+| `PUT /spaces/:space/threads/:slug/status` `{ status, rule?, reason? }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
-| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
+| `DELETE /spaces/:space/threads/:slug` `{ rule?, reason? }` | Author or moderator |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
-| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
+| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked?, rule?, reason? }` | Moderators |
| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
| `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) |
| `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) |
| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
-| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
+| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` `{ rule?, reason? }` · `GET /posts/:id/versions` | Author or moderator |
| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- A public moderation log per space (market outcome 5: "we publish what our moderation did and why").
+-- Every moderator action (lock, unlock, pin, unpin, hide, delete, status) writes one row in the same
+-- transaction as the action. The public row names the role only (staff, moderator, owner, automatic),
+-- never the moderator's Network subject: a removed thread keeps its id and title here for rendering,
+-- and a hard-deleted thread or post sets its reference to NULL.
+CREATE TABLE forum_moderation_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ thread_id bigint REFERENCES threads(id) ON DELETE SET NULL,
+ post_id bigint REFERENCES posts(id) ON DELETE SET NULL,
+ action text COLLATE "C" NOT NULL CHECK(action IN ('lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status')),
+ rule text COLLATE "C",
+ reason text COLLATE "C" CHECK(reason IS NULL OR char_length(reason) <= 280),
+ actor_role text COLLATE "C" NOT NULL CHECK(actor_role IN ('moderator', 'owner', 'staff', 'automatic')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE INDEX idx_forum_moderation_log_space ON forum_moderation_log (space_id, created_at DESC, id DESC);
@@ -189,6 +189,18 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -189,6 +189,18 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.vote-btn.active { background: var(--accent-strong, var(--accent, #3472d8)); border-color: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent)); }
.vote-btn.active { background: var(--accent-strong, var(--accent, #3472d8)); border-color: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent)); }
.mod-actions form { margin: 0; }
.mod-actions form { margin: 0; }
+.mod-actions .mod-form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
+.mod-actions .mod-rule, .mod-actions .mod-reason, .post-mod .mod-rule, .post-mod .mod-reason {
+ font: inherit; font-size: .82rem; padding: 3px 6px; border-radius: 6px;
+ border: 1px solid var(--border-light); background: var(--bg-input); color: var(--text-primary); max-width: 100%;
+}
+.mod-actions .mod-reason, .post-mod .mod-reason { width: 13em; }
+.post-mod { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0 0; }
+.rss-link { display: inline-block; font-size: .78rem; font-weight: 600; color: var(--text-secondary); padding: 1px 7px; border: 1px solid var(--border-light); border-radius: 6px; vertical-align: middle; white-space: nowrap; }
+.rss-link:hover { color: var(--accent-light); border-color: var(--accent); text-decoration: none; }
+h1 .rss-link { font-size: .68rem; margin-left: 6px; }
+.thread-follow { margin: 4px 0 0; }
+.space-foot { margin: 20px 0 0; color: var(--text-secondary); }
.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }
.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }
.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
.post-opening { border-color: var(--border-light); }
.post-opening { border-color: var(--border-light); }
@@ -319,6 +331,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -319,6 +331,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }
.board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }
.board-table td.topic-icon { display: none; }
.board-table td.topic-icon { display: none; }
.board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }
.board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }
+ .mod-actions form, .post-mod { width: 100%; }
+ .mod-actions .mod-reason, .post-mod .mod-reason { flex: 1 1 9em; width: auto; }
}
}
/* ── Ratings (Facepunch style) ── */
/* ── Ratings (Facepunch style) ── */
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+/* OpenVibe.Community: the moderation log page (/s/:space/log), linked by that page alone (renderPage pageCss) so the
+ other pages carry none of it. A table on a computer; on a phone each row is a card. */
+.mod-log th:first-child { width: 7.5em; }
+.mod-log th:nth-child(3) { width: 7em; }
+.mod-log th:nth-child(4) { width: 9em; }
+.mod-log th:last-child { width: 7em; }
+.mod-log .log-reason { overflow-wrap: anywhere; }
+@media (max-width: 720px) {
+ /* The moderation log as cards: [Action] what · the reason · "Rule duplicate · Staff · 2 h ago" */
+ .mod-log tr { display: flex; flex-wrap: wrap; align-items: baseline; padding: 12px 14px; }
+ .mod-log tr::before { content: ''; order: 3; flex-basis: 100%; }
+ .mod-log td { padding: 0; width: auto; min-width: 0; }
+ .mod-log .log-act { order: 1; margin-right: 8px; }
+ .mod-log .log-what { order: 2; flex: 1 1 12em; }
+ .mod-log .log-reason { order: 4; flex-basis: 100%; margin-top: 6px; }
+ .mod-log .log-rule { order: 5; }
+ .mod-log .log-by { order: 6; }
+ .mod-log .log-when { order: 7; }
+ .mod-log .log-rule, .mod-log .log-by, .mod-log .log-when { margin-top: 6px; font-size: .8rem; color: var(--text-secondary); white-space: nowrap; }
+ .mod-log .log-rule::before { content: 'Rule '; }
+ .mod-log .log-by::before, .mod-log .log-when::before { content: '\00b7'; padding: 0 6px; }
+ .mod-log .log-rule.is-none, .mod-log .log-reason.is-none { display: none; }
+ .mod-log .log-rule.is-none + .log-reason + .log-by::before { content: none; padding: 0; }
+ .mod-log th { width: auto; }
+}
@@ -107,6 +107,17 @@ function threadFeedItems(threads) {
@@ -107,6 +107,17 @@ function threadFeedItems(threads) {
});
});
}
}
+/** A thread's newest posts for /s/:space/t/:slug/feed.xml (opening post and replies). */
+function postFeedItems(space, thread, posts) {
+ const { markdownToText } = require('./render/markdown');
+ return posts.map((p) => {
+ const url = abs(`/s/${space.slug}/t/${thread.slug}#post-${p.id}`);
+ const description = markdownToText(p.body_markdown || '', 300);
+ return { title: p.is_opening ? thread.title : `Re: ${thread.title}`, link: url, guid: url, description, content: description,
+ author: undefined, published: isoDate(p.created_at), updated: isoDate(p.updated_at || p.created_at) };
+ });
+}
+
function resetCaches() { _sitemap = null; _sitemapAt = 0; }
function resetCaches() { _sitemap = null; _sitemapAt = 0; }
-module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, pasteFeedItems, threadFeedItems, useForum, resetCaches };
+module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, pasteFeedItems, threadFeedItems, postFeedItems, useForum, resetCaches };
@@ -6,13 +6,14 @@
@@ -6,13 +6,14 @@
*
*
* GET /spaces spaces the caller can open
* GET /spaces spaces the caller can open
* GET /spaces/:space one space
* GET /spaces/:space one space
+ * GET /spaces/:space/moderation-log its newest 100 moderation rows (public for public spaces)
* GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)
* GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)
* POST /spaces/:space/threads { title, body } new thread (body is Markdown)
* POST /spaces/:space/threads { title, body } new thread (body is Markdown)
* GET /spaces/:space/threads/:slug?page= thread + posts
* GET /spaces/:space/threads/:slug?page= thread + posts
- * DELETE /spaces/:space/threads/:slug author or moderator
+ * DELETE /spaces/:space/threads/:slug { rule?, reason? } author or moderator
* POST /spaces/:space/threads/:slug/posts { body } reply
* POST /spaces/:space/threads/:slug/posts { body } reply
* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
- * PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators
+ * PUT /spaces/:space/threads/:slug/state { pinned?, locked?, rule?, reason? } moderators
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
@@ -22,7 +23,7 @@
@@ -22,7 +23,7 @@
* PUT /spaces/:space/categories/:category { name, description?, position? } moderators
* PUT /spaces/:space/categories/:category { name, description?, position? } moderators
* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
- * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,
+ * PUT /spaces/:space/threads/:slug/status { status, rule?, reason? } moderators: requests open|planned|in_progress|done|declined,
* roadmap items planned|in_progress|done|paused (?status= filters)
* roadmap items planned|in_progress|done|paused (?status= filters)
* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
@@ -78,6 +79,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -78,6 +79,7 @@ function createSpacesApi({ forum, viewers, limits }) {
router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
+ router.get('/:space/moderation-log', run(async (req) => await forum.moderationLog(req.viewer, p(req).space)));
router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
@@ -94,7 +96,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -94,7 +96,7 @@ function createSpacesApi({ forum, viewers, limits }) {
router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
- router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
+ router.delete('/:space/threads/:slug', writeOrMod, editThread, jsonBody, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
@@ -122,7 +124,7 @@ function createPostsApi({ forum, viewers, limits }) {
@@ -122,7 +124,7 @@ function createPostsApi({ forum, viewers, limits }) {
router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));
router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));
router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));
router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));
- router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));
+ router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), jsonBody, run(async (req) => await forum.deletePost(req.viewer, req.params.id, req.body || {})));
router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));
router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * The public moderation log's vocabulary (migrations/0012_forum_moderation_log.sql): the actions a
+ * moderator can take, the rules a space may cite, and the actor roles a row may name. The row never
+ * carries the moderator's Network subject; the role is all the public log says about who acted.
+ */
+const { fail } = require('../http/v1');
+
+const ACTIONS = Object.freeze(['lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status']);
+const ROLES = Object.freeze(['moderator', 'owner', 'staff', 'automatic']);
+// Spaces have no rules field of their own yet, so every space cites this network-wide list.
+const RULES = Object.freeze(['spam', 'off-topic', 'harassment', 'illegal content', 'personal information', 'duplicate', 'other']);
+
+/** The posted rule, from the allowed list only (case-insensitive), or null. */
+function rule(value) {
+ if (value === undefined || value === null || value === '') return null;
+ const r = String(value).trim().toLowerCase();
+ if (!RULES.includes(r)) fail(400, 'moderation.invalid_rule', `The rule is one of ${RULES.join(', ')}`);
+ return r;
+}
+
+/** The posted reason as plain one-line text, trimmed and capped at 280 characters, or null. */
+function reason(value) {
+ if (value === undefined || value === null) return null;
+ const r = String(value).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
+ return r ? r.slice(0, 280) : null;
+}
+
+/** { rule, reason } from a form or API body, with the rule and reason rules above. */
+function fields(body = {}) {
+ return { rule: rule(body.rule), reason: reason(body.reason) };
+}
+
+/**
+ * Who acted, in role only: discussion staff, the space's owner, or one of its moderators.
+ * `isStaff` is the caller's discussions-moderator answer (identity/capabilities.js).
+ */
+function actorRole(viewer, space, isStaff) {
+ if (isStaff) return 'staff';
+ if (space && viewer && viewer.subject && (space.created_by === viewer.subject || space.members_only_owner === viewer.subject)) return 'owner';
+ return 'moderator';
+}
+
+module.exports = { ACTIONS, ROLES, RULES, rule, reason, fields, actorRole };
@@ -8,9 +8,12 @@
@@ -8,9 +8,12 @@
* GET /s/feed.xml RSS: latest threads in public spaces
* GET /s/feed.xml RSS: latest threads in public spaces
* GET /s/:space threads (?sort=hot|new|top&page=)
* GET /s/:space threads (?sort=hot|new|top&page=)
* GET /s/:space/feed.xml RSS: latest threads of one public space
* GET /s/:space/feed.xml RSS: latest threads of one public space
+ * GET /s/:space/log the space's public moderation log (newest 100)
+ * GET /s/:space/t/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/t/:slug a thread (?page=)
* GET /s/:space/t/:slug a thread (?page=)
* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
+ * POST /s/:space/t/:slug/post/:id/delete a moderator removes a post (rule and reason)
* POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)
* POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)
*
*
* A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
* A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
@@ -135,6 +138,34 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -135,6 +138,34 @@ function createForumRoutes({ forum, viewers, config }) {
await threadFeed(res, space);
await threadFeed(res, space);
}));
}));
+ // A thread's replies as RSS: newest 50, under the same visibility rule as the thread itself.
+ router.get('/s/:space/t/:slug/feed.xml', withViewer, wrap(async (req, res, next) => {
+ try {
+ const out = await forum.threadFeed(req.viewer, req.params.space, req.params.slug);
+ const base = `${config.baseUrl}/s/${out.space.slug}/t/${out.thread.slug}`;
+ // A private or members-only thread's feed is the member's own: never kept by Cloudflare or a proxy.
+ res.type('application/rss+xml').set('Cache-Control', out.shared ? cache.htmlHeaders({ maxAge: 300 }) : 'private, no-store').send(seo.feedXml({
+ title: `${SITE_NAME} — ${out.thread.title}`,
+ link: base,
+ description: `New posts in ${out.thread.title} on ${out.space.name}.`,
+ language: 'en',
+ selfUrl: `${base}/feed.xml`,
+ items: discovery.postFeedItems(out.space, out.thread, out.posts),
+ }, { format: 'rss' }));
+ } catch (err) {
+ if (err instanceof ApiError) return res.status(err.status).type('text/plain').send(err.message);
+ next(err);
+ }
+ }));
+
+ // The space's public moderation log: what moderation did and why, in role only.
+ router.get('/s/:space/log', withViewer, wrap(async (req, res, next) => {
+ try {
+ const out = await forum.moderationLog(req.viewer, req.params.space);
+ html(res, forumPages.moderationLogPage(out));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
try {
try {
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
@@ -233,7 +264,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -233,7 +264,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
- const flags = {};
+ const flags = { rule: b.rule, reason: b.reason };
if (b.pinned !== undefined) flags.pinned = b.pinned === '1';
if (b.pinned !== undefined) flags.pinned = b.pinned === '1';
if (b.locked !== undefined) flags.locked = b.locked === '1';
if (b.locked !== undefined) flags.locked = b.locked === '1';
try {
try {
@@ -315,8 +346,9 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -315,8 +346,9 @@ function createForumRoutes({ forum, viewers, config }) {
// Status (moderators) and category (the author or moderators), no JS.
// Status (moderators) and category (the author or moderators), no JS.
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const b = req.body || {};
try {
try {
- await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });
+ await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String(b.status || ''), rule: b.rule, reason: b.reason });
res.redirect(303, back(req));
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -328,9 +360,17 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -328,9 +360,17 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
+ // A moderator removes one post (its opening post removes the thread): rule and reason are optional.
+ router.post('/s/:space/t/:slug/post/:id/delete', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.deletePost(req.viewer, req.params.id, req.body || {});
+ res.redirect(303, back(req));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
try {
- await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
+ await forum.deleteThread(req.viewer, req.params.space, req.params.slug, req.body || {});
discovery.resetCaches();
discovery.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
@@ -51,6 +51,7 @@ const { isUserSubject } = require('../vip');
@@ -51,6 +51,7 @@ const { isUserSubject } = require('../vip');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const reactions = require('./reactions');
+const moderation = require('./moderation');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const STYLES = ['feed', 'forum'];
const STYLES = ['feed', 'forum'];
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
@@ -125,6 +126,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -125,6 +126,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const moderates = async (v, space) => moderator(v)
const moderates = async (v, space) => moderator(v)
|| (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));
|| (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));
+ /**
+ * One public moderation-log row, in the caller's transaction. The row names the actor's role only
+ * (staff, owner, moderator), never their subject; `fields` is moderation.fields(body).
+ */
+ const logModeration = async (v, space, { thread = null, post = null, action, rule = null, reason = null }) => {
+ await store.addModerationLog(db, {
+ space_id: space.id, thread_id: thread ? thread.id : null, post_id: post ? post.id : null,
+ action, rule, reason, actor_role: moderation.actorRole(v, space, moderator(v)),
+ });
+ };
+
// ── a space's chat room (OpenVibe.Chat) ──────────────────
// ── a space's chat room (OpenVibe.Chat) ──────────────────
const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
const shapeChatRoom = (r) => (r ? {
const shapeChatRoom = (r) => (r ? {
@@ -355,12 +367,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -355,12 +367,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
}
}
- async function removeThread(v, thread, space) {
+ async function removeThread(v, thread, space, body = {}) {
const mine = person(v) && thread.author_subject === v.subject;
const mine = person(v) && thread.author_subject === v.subject;
if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
+ const fields = moderation.fields(body);
await db.tx(async () => {
await db.tx(async () => {
await store.softDeleteThread(db, thread.id);
await store.softDeleteThread(db, thread.id);
- if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ if (!mine) {
+ await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ await logModeration(v, space, { thread, action: 'delete', ...fields });
+ }
});
});
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
@@ -662,6 +678,42 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -662,6 +678,42 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
};
},
},
+ /**
+ * A thread's feed: its newest live posts (opening and replies, 50 at most), under the same
+ * visibility as the thread itself. → { space, thread: { slug, title }, posts }
+ */
+ async threadFeed(v, spaceSlug, threadSlug) {
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
+ await requireMembership(v, space, thread);
+ return {
+ space: await shapeSpace(space, await membersOnly(space.members_only_owner)),
+ thread: { slug: thread.slug, title: thread.title },
+ // Only a public, open thread's feed may sit in a shared cache; any other is the reader's own copy.
+ shared: space.visibility === 'public' && !space.members_only_owner && !thread.members_only_owner,
+ posts: await store.recentPosts(db, thread.id, 50),
+ };
+ },
+
+ /**
+ * A space's public moderation log: its newest 100 actions with the rule and reason, in role only
+ * (never the moderator's identity), public for public spaces and members-only for the rest.
+ * → { space, entries: [{ id, action, rule, reason, actor_role, created_at, thread, post_id }] }
+ */
+ async moderationLog(v, spaceSlug) {
+ const space = await spaceFor(v, spaceSlug);
+ await requireMembership(v, space);
+ const rows = await store.listModerationLog(db, space.id, { limit: 100 });
+ return {
+ space: await shapeSpace(space, await membersOnly(space.members_only_owner)),
+ entries: rows.map((r) => ({
+ id: r.id, action: r.action, rule: r.rule || null, reason: r.reason || null,
+ actor_role: r.actor_role, created_at: isoTime(r.created_at),
+ thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: `/s/${space.slug}/t/${r.thread_slug}` } : null,
+ post_id: r.post_id || null,
+ })),
+ };
+ },
+
/**
/**
* Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.
* Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.
* People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in
* People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in
@@ -810,7 +862,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -810,7 +862,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');
if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');
const statuses = STATUSES[thread.kind] || [];
const statuses = STATUSES[thread.kind] || [];
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
- const next = await store.setThreadStatus(db, thread.id, body.status);
+ const fields = moderation.fields(body);
+ const next = await db.tx(async () => {
+ const r = await store.setThreadStatus(db, thread.id, body.status);
+ if (r.status !== thread.status) await logModeration(v, space, { thread, action: 'status', ...fields });
+ return r;
+ });
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
pingThread(space, next);
pingThread(space, next);
return { thread: await shapeThread(next, space, v, projections, null) };
return { thread: await shapeThread(next, space, v, projections, null) };
@@ -948,24 +1005,29 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -948,24 +1005,29 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
},
},
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
- async deletePost(v, postId) {
+ async deletePost(v, postId, body = {}) {
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
- if (post.is_opening) return await removeThread(v, thread, space);
+ const mine = person(v) && post.author_subject === v.subject;
+ if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
+ if (post.is_opening) return await removeThread(v, thread, space, body);
+ const fields = moderation.fields(body);
await db.tx(async () => {
await db.tx(async () => {
await store.softDeletePost(db, post.id);
await store.softDeletePost(db, post.id);
- if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
+ if (!mine) {
+ await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
+ await logModeration(v, space, { thread, post, action: 'delete', ...fields });
+ }
});
});
if (pulse) await hook(async () => await pulse.postGone(post.id));
if (pulse) await hook(async () => await pulse.postGone(post.id));
- if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));
+ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, mine ? 'deleted by its author' : 'deleted by a moderator'));
pingThread(space, thread);
pingThread(space, thread);
return { ok: true, id: post.id };
return { ok: true, id: post.id };
},
},
/** Delete a thread — its author or a moderator. */
/** Delete a thread — its author or a moderator. */
- async deleteThread(v, spaceSlug, threadSlug) {
+ async deleteThread(v, spaceSlug, threadSlug, body = {}) {
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- return await removeThread(v, thread, space);
+ return await removeThread(v, thread, space, body);
},
},
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
@@ -991,9 +1053,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -991,9 +1053,16 @@ 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 fields = moderation.fields(body);
const next = await db.tx(async () => {
const next = await db.tx(async () => {
const r = await store.setThreadFlags(db, thread.id, flags);
const r = await store.setThreadFlags(db, thread.id, flags);
- if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ if (flags.locked !== undefined && !!thread.locked !== flags.locked) {
+ await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ await logModeration(v, space, { thread, action: flags.locked ? 'lock' : 'unlock', ...fields });
+ }
+ if (flags.pinned !== undefined && !!thread.pinned !== flags.pinned) {
+ await logModeration(v, space, { thread, action: flags.pinned ? 'pin' : 'unpin', ...fields });
+ }
return r;
return r;
});
});
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
@@ -319,6 +319,25 @@ async function countPostsSince(db, subject, sinceSql) {
@@ -319,6 +319,25 @@ async function countPostsSince(db, subject, sinceSql) {
return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
}
}
+// ── public moderation log ────────────────────────────────────
+/** One public log row. The actor is named by role only; never pass a subject here. */
+async function addModerationLog(db, { space_id, thread_id = null, post_id = null, action, rule = null, reason = null, actor_role }) {
+ await db.prepare(`INSERT INTO forum_moderation_log (space_id, thread_id, post_id, action, rule, reason, actor_role)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(space_id, thread_id, post_id, action, rule, reason, actor_role);
+}
+
+/** A space's newest log rows (100 by default), with enough of a thread to name it, even after deletion. */
+async function listModerationLog(db, spaceId, { limit = 100 } = {}) {
+ return await db.prepare(`SELECT l.*, t.title AS thread_title, t.slug AS thread_slug, t.deleted_at AS thread_deleted_at
+ FROM forum_moderation_log l LEFT JOIN threads t ON t.id = l.thread_id
+ WHERE l.space_id = ? ORDER BY l.created_at DESC, l.id DESC LIMIT ?`).all(spaceId, limit);
+}
+
+/** The newest live posts of a thread (its feed): opening post included, tombstones left out. */
+async function recentPosts(db, threadId, limit = 50) {
+ return await db.prepare('SELECT * FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?').all(threadId, limit);
+}
+
module.exports = {
module.exports = {
SORTS, slugify,
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
listSpaces, getSpace, getSpaceById,
@@ -327,4 +346,5 @@ module.exports = {
@@ -327,4 +346,5 @@ module.exports = {
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
listModerators, moderatesSpace, addModerator, removeModerator,
listModerators, moderatesSpace, addModerator, removeModerator,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
+ addModerationLog, listModerationLog, recentPosts,
};
};
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
* the services validate; response properties are the DTOs' output where the shape is read from code.
* the services validate; response properties are the DTOs' output where the shape is read from code.
*/
*/
const pkg = require('../package.json');
const pkg = require('../package.json');
+const { RULES: MODERATION_RULES } = require('./forum/moderation');
const SITE = 'https://openvibe.community';
const SITE = 'https://openvibe.community';
@@ -360,6 +361,22 @@ route('get', '/api/v1/spaces/{space}', {
@@ -360,6 +361,22 @@ route('get', '/api/v1/spaces/{space}', {
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],
responses: { 200: json('The space, its categories and moderators', { type: 'object' }), ...errors([404, 429]) },
responses: { 200: json('The space, its categories and moderators', { type: 'object' }), ...errors([404, 429]) },
});
});
+route('get', '/api/v1/spaces/{space}/moderation-log', {
+ operationId: 'getModerationLog', summary: 'A space\'s public moderation log (newest 100)', tags: ['spaces'], security: ANONYMOUS,
+ parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],
+ description: 'Every moderator action with its rule and reason, in role only (staff, moderator, owner, automatic); never the moderator\'s identity. Public for public spaces; a members-only space keeps it for signed-in members, a staff space for staff.',
+ responses: { 200: json('The space and its entries', { type: 'object', properties: {
+ space: ref('Space'),
+ entries: arrayOf({ type: 'object', properties: {
+ id: { type: 'integer' }, action: { type: 'string', enum: ['lock', 'unlock', 'pin', 'unpin', 'hide', 'delete', 'status'] },
+ rule: nullable('string', { enum: MODERATION_RULES }), reason: nullable('string', { maxLength: 280 }),
+ actor_role: { type: 'string', enum: ['moderator', 'owner', 'staff', 'automatic'] },
+ created_at: str({ format: 'date-time' }),
+ thread: nullable('object', { properties: { slug: str(), title: str(), url: str() }, description: 'Null when the thread was removed' }),
+ post_id: nullable('integer'),
+ } }),
+ } }), ...errors([401, 404, 429]) },
+});
route('get', '/api/v1/spaces/{space}/threads', {
route('get', '/api/v1/spaces/{space}/threads', {
operationId: 'listThreads', summary: 'A space\'s threads', tags: ['spaces'], security: ANONYMOUS, parameters: [
operationId: 'listThreads', summary: 'A space\'s threads', tags: ['spaces'], security: ANONYMOUS, parameters: [
path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })),
path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })),
@@ -402,6 +419,7 @@ route('put', '/api/v1/spaces/{space}/threads/{slug}/status', {
@@ -402,6 +419,7 @@ route('put', '/api/v1/spaces/{space}/threads/{slug}/status', {
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
requestBody: body({ type: 'object', required: ['status'], properties: {
requestBody: body({ type: 'object', required: ['status'], properties: {
status: { type: 'string', enum: ['open', 'planned', 'in_progress', 'done', 'declined', 'paused'] },
status: { type: 'string', enum: ['open', 'planned', 'in_progress', 'done', 'declined', 'paused'] },
+ rule: str({ enum: MODERATION_RULES, description: 'The rule applied, if any' }), reason: str({ maxLength: 280 }),
} }),
} }),
responses: { 200: json('The thread'), ...errors([400, 404, 429]) },
responses: { 200: json('The thread'), ...errors([400, 404, 429]) },
});
});
@@ -431,6 +449,7 @@ route('get', '/api/v1/spaces/{space}/threads/{slug}', {
@@ -431,6 +449,7 @@ route('get', '/api/v1/spaces/{space}/threads/{slug}', {
route('delete', '/api/v1/spaces/{space}/threads/{slug}', {
route('delete', '/api/v1/spaces/{space}/threads/{slug}', {
operationId: 'deleteThread', summary: 'Delete a thread (its author or a moderator)', tags: ['spaces'], security: PERSON,
operationId: 'deleteThread', summary: 'Delete a thread (its author or a moderator)', tags: ['spaces'], security: PERSON,
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
+ requestBody: body({ type: 'object', properties: { rule: str({ enum: MODERATION_RULES }), reason: str({ maxLength: 280 }) } }, false),
responses: { 200: json('What was deleted'), ...errors([404, 429]) },
responses: { 200: json('What was deleted'), ...errors([404, 429]) },
});
});
route('post', '/api/v1/spaces/{space}/threads/{slug}/posts', {
route('post', '/api/v1/spaces/{space}/threads/{slug}/posts', {
@@ -452,7 +471,10 @@ route('post', '/api/v1/spaces/{space}/threads/{slug}/votes', {
@@ -452,7 +471,10 @@ route('post', '/api/v1/spaces/{space}/threads/{slug}/votes', {
route('put', '/api/v1/spaces/{space}/threads/{slug}/state', {
route('put', '/api/v1/spaces/{space}/threads/{slug}/state', {
operationId: 'moderateThread', summary: 'Pin or lock a thread (moderators)', tags: ['spaces'], security: PERSON,
operationId: 'moderateThread', summary: 'Pin or lock a thread (moderators)', tags: ['spaces'], security: PERSON,
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
- requestBody: body({ type: 'object', properties: { pinned: { type: 'boolean' }, locked: { type: 'boolean' } } }),
+ requestBody: body({ type: 'object', properties: {
+ pinned: { type: 'boolean' }, locked: { type: 'boolean' },
+ rule: str({ enum: MODERATION_RULES, description: 'The rule applied, if any' }), reason: str({ maxLength: 280 }),
+ } }),
responses: { 200: json('The thread'), ...errors([400, 404, 429]) },
responses: { 200: json('The thread'), ...errors([400, 404, 429]) },
});
});
route('put', '/api/v1/spaces/{space}/threads/{slug}/members-only', {
route('put', '/api/v1/spaces/{space}/threads/{slug}/members-only', {
@@ -517,6 +539,7 @@ route('delete', '/api/v1/posts/{id}', {
@@ -517,6 +539,7 @@ route('delete', '/api/v1/posts/{id}', {
operationId: 'deletePost', summary: 'Delete a post (its author or a moderator)', tags: ['posts'], security: PERSON,
operationId: 'deletePost', summary: 'Delete a post (its author or a moderator)', tags: ['posts'], security: PERSON,
parameters: [path('id', { type: 'string' })],
parameters: [path('id', { type: 'string' })],
description: 'community.post.delete.',
description: 'community.post.delete.',
+ requestBody: body({ type: 'object', properties: { rule: str({ enum: MODERATION_RULES }), reason: str({ maxLength: 280 }) } }, false),
responses: { 200: json('What was deleted'), ...errors([404, 429]) },
responses: { 200: json('What was deleted'), ...errors([404, 429]) },
});
});
route('get', '/api/v1/posts/{id}/versions', {
route('get', '/api/v1/posts/{id}/versions', {
@@ -133,6 +133,7 @@ ${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one mo
@@ -133,6 +133,7 @@ ${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one mo
function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {
function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {
const filtered = !!(category || status);
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
+ const feedable = space.visibility === 'public' && !space.members_only;
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
@@ -144,7 +145,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
@@ -144,7 +145,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
- <h1>${esc(space.name)}${f.visBadge(space)}</h1>
+ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
</header>
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
@@ -157,14 +158,15 @@ ${threads.length ? `<table class="board-table topic-table"><caption class="sr-on
@@ -157,14 +158,15 @@ ${threads.length ? `<table class="board-table topic-table"><caption class="sr-on
<tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
<tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
${pagerHtml}
${pagerHtml}
</section>
</section>
-${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;
+${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}
+<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;
return renderPage({
return renderPage({
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
canonicalPath: href({ page }),
canonicalPath: href({ page }),
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
+ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
body,
});
});
@@ -187,7 +189,7 @@ function authorPanel(p) {
@@ -187,7 +189,7 @@ function authorPanel(p) {
</aside>`;
</aside>`;
}
}
-function postbit(p, n, { base, page, catalogue, canReply }) {
+function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';
const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';
return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
@@ -198,6 +200,7 @@ function postbit(p, n, { base, page, catalogue, canReply }) {
@@ -198,6 +200,7 @@ function postbit(p, n, { base, page, catalogue, canReply }) {
${f.postImages(p)}
${f.postImages(p)}
${f.pasteCards(p)}
${f.pasteCards(p)}
${f.reactionBar(p, base, page, catalogue)}
${f.reactionBar(p, base, page, catalogue)}
+ ${canModerate && !p.is_opening ? f.postModerationForm(base, p) : ''}
</div>
</div>
</article>`;
</article>`;
}
}
@@ -229,13 +232,13 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -229,13 +232,13 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<div>
<div>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>
+ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
</div>
</header>
</header>
${f.crosspostBlock(crosspost, base)}
${f.crosspostBlock(crosspost, base)}
${f.modActions({ base, space, thread, viewer, categories })}
${f.modActions({ base, space, thread, viewer, categories })}
${pagerHtml}
${pagerHtml}
- <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>
+ <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply, canModerate: viewer.can_moderate })).join('\n')}</div>
${pagerHtml}
${pagerHtml}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</article>`;
@@ -252,7 +255,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -252,7 +255,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
historyType: 'page',
historyType: 'page',
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
+ feeds: [
+ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
+ { title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
+ ],
jsonLd: [
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
@@ -11,9 +11,12 @@ const { renderPage } = require('./layout');
@@ -11,9 +11,12 @@ const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
const { timeTag, fmtDate, num, authorHtml } = require('./pages');
const { timeTag, fmtDate, num, authorHtml } = require('./pages');
+const { RULES: MODERATION_RULES } = require('../forum/moderation');
const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };
const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };
+const ACTION_LABELS = { lock: 'Locked', unlock: 'Unlocked', pin: 'Pinned', unpin: 'Unpinned', hide: 'Hidden', delete: 'Deleted', status: 'Status changed' };
+const ROLE_LABELS = { moderator: 'Moderator', owner: 'Owner', staff: 'Staff', automatic: 'Automatic' };
/** A request's or roadmap item's status, and a thread's category. */
/** A request's or roadmap item's status, and a thread's category. */
const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');
const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');
const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');
const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');
@@ -37,6 +40,18 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nul
@@ -37,6 +40,18 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nul
return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
}
}
+/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */
+function moderationFields(id) {
+ const suffix = String(id).replace(/[^a-z0-9-]+/gi, '-');
+ const label = (r) => r.charAt(0).toUpperCase() + r.slice(1);
+ return `<label class="sr-only" for="rule-${suffix}">Rule applied</label><select class="mod-rule" id="rule-${suffix}" name="rule"><option value="">Rule applied…</option>${MODERATION_RULES.map((r) => `<option value="${esc(r)}">${esc(label(r))}</option>`).join('')}</select><label class="sr-only" for="reason-${suffix}">Reason (optional, 280 characters)</label><input class="mod-reason" id="reason-${suffix}" name="reason" maxlength="280" placeholder="Reason (optional)" autocomplete="off">`;
+}
+
+/** A moderator's form to remove one post (its opening post removes the thread instead). */
+function postModerationForm(base, post) {
+ return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete post</button></form>`;
+}
+
function pager(href, page, pages, labels = ['Newer', 'Older']) {
function pager(href, page, pages, labels = ['Newer', 'Older']) {
if (pages <= 1) return '';
if (pages <= 1) return '';
return `<nav class="pager" aria-label="Pages">
return `<nav class="pager" aria-label="Pages">
@@ -114,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -114,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const filtered = !!(category || status);
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
+ const feedable = space.visibility === 'public' && !space.members_only;
const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
@@ -125,7 +141,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -125,7 +141,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
const body = `
const body = `
<header class="page-head">
<header class="page-head">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
- <h1>${esc(space.name)}${visBadge(space)}</h1>
+ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${start}
${start}
</header>
</header>
@@ -136,33 +152,34 @@ ${categoryNav}${statusNav}
@@ -136,33 +152,34 @@ ${categoryNav}${statusNav}
${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
</section>
</section>
-${footer}`;
+${footer}
+<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;
return renderPage({
return renderPage({
title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
canonicalPath: spaceHref(space.slug, { sort, page, category, status }),
canonicalPath: spaceHref(space.slug, { sort, page, category, status }),
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
+ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
body,
});
});
}
}
-/** The pin / lock / delete / members-only / status / category buttons (no JS). */
+/** The pin / lock / delete / members-only / status / category buttons (no JS), each with rule and reason. */
function modActions({ base, space, thread, viewer, categories = [] }) {
function modActions({ base, space, thread, viewer, categories = [] }) {
return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
+ ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
+ <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
+ ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
+ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="mod-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select>${moderationFields('status')}<button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
</div>` : '';
</div>` : '';
}
}
// ── /s/:space/t/:slug ────────────────────────────────────────
// ── /s/:space/t/:slug ────────────────────────────────────────
-function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {
+function postHtml(p, n, { base = '', page = 1, catalogue = [], canModerate = false } = {}) {
if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
<header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
<header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
@@ -170,6 +187,7 @@ function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {
@@ -170,6 +187,7 @@ function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {
${postImages(p)}
${postImages(p)}
${pasteCards(p)}
${pasteCards(p)}
${reactionBar(p, base, page, catalogue)}
${reactionBar(p, base, page, catalogue)}
+ ${canModerate && !p.is_opening ? postModerationForm(base, p) : ''}
</article>`;
</article>`;
}
}
/** Pastes attached to a post: a card with the first lines (highlighted), or the screenshot. */
/** Pastes attached to a post: a card with the first lines (highlighted), or the screenshot. */
@@ -260,11 +278,12 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -260,11 +278,12 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
+ <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
</div>
</header>
</header>
${crosspostBlock(crosspost, base)}
${crosspostBlock(crosspost, base)}
${modBlock}
${modBlock}
- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>
+ <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions, canModerate: viewer.can_moderate })).join('\n')}</div>
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</article>`;
@@ -281,7 +300,10 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -281,7 +300,10 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
historyType: 'page',
historyType: 'page',
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
- feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
+ feeds: [
+ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
+ { title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
+ ],
jsonLd: [
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
@@ -359,7 +381,54 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -359,7 +381,54 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
});
});
}
}
+// ── /s/:space/log ────────────────────────────────────────────
+/** What a log row removed, as the public log may name it: a live title, a post in it, or a removed thread. */
+function logTarget(e) {
+ if (!e.thread) return '<span class="muted">a removed thread</span>';
+ const title = `<a href="${esc(e.thread.url)}">${esc(e.thread.title)}</a>`;
+ return e.post_id ? `a post in ${title}` : title;
+}
+
+/**
+ * A space's public moderation log: a plain table on a computer and stacked rows on a phone (the
+ * board table's own small-screen rules). No moderator is named — only the role that acted.
+ */
+function moderationLogPage({ space, entries }) {
+ // On a phone each row is a card (public/css/community.css, .mod-log): the action and what it touched, the reason,
+ // then the rule, who and when in one small line. A missing rule or reason is "—" on the table and left out there.
+ const rows = entries.map((e) => `<tr>
+ <td data-label="When" class="log-when">${timeTag(e.created_at)}</td>
+ <td data-label="What" class="log-what">${logTarget(e)}</td>
+ <td data-label="Action" class="log-act"><span class="badge">${esc(ACTION_LABELS[e.action] || e.action)}</span></td>
+ <td data-label="Rule" class="log-rule${e.rule ? '' : ' is-none'}">${e.rule ? esc(e.rule) : '<span class="muted">—</span>'}</td>
+ <td data-label="Reason" class="log-reason${e.reason ? '' : ' is-none'}">${e.reason ? esc(e.reason) : '<span class="muted">—</span>'}</td>
+ <td data-label="By" class="log-by">${esc(ROLE_LABELS[e.actor_role] || e.actor_role)}</td>
+ </tr>`).join('');
+ const body = `
+<header class="page-head">
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>
+ <h1>Moderation log</h1>
+ <p class="muted">What moderation did in ${esc(space.name)} and why. Newest first; who acted is shown by role only.</p>
+</header>
+${entries.length ? `<table class="board-table mod-log"><caption class="sr-only">Moderation actions in ${esc(space.name)}</caption>
+ <thead><tr><th scope="col">When</th><th scope="col">What</th><th scope="col">Action</th><th scope="col">Rule</th><th scope="col">Reason</th><th scope="col">By</th></tr></thead>
+ <tbody>${rows}</tbody></table>` : '<p class="empty">No moderation actions in this forum yet.</p>'}
+<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/s/${esc(space.slug)}/feed.xml">RSS</a>` : ''}</footer>`;
+ return renderPage({
+ title: `Moderation log · ${space.name}`,
+ description: `What moderation did in ${space.name} on OpenVibe.Community, and why: the action, the rule and the reason.`,
+ canonicalPath: `/s/${space.slug}/log`,
+ robots: space.visibility === 'public' && !space.members_only ? 'index,follow' : 'noindex,nofollow',
+ active: 'spaces',
+ footerVariant: 'compact',
+ pageCss: ['modlog.css'],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: 'Moderation log', url: `/s/${space.slug}/log` }])],
+ body,
+ });
+}
+
module.exports = {
module.exports = {
- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,
+ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, moderationLogPage, spaceHref, who, chatRoomBox,
pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
+ postModerationForm,
};
};
@@ -111,6 +111,7 @@ function footerInit(opts) {
@@ -111,6 +111,7 @@ function footerInit(opts) {
* feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),
* feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),
* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),
+ * pageCss (this site's own stylesheets for one page, linked after community.css: ['modlog.css']),
* published/modified (ISO, for article:*), noFrame (error pages during outages)
* published/modified (ISO, for article:*), noFrame (error pages during outages)
*/
*/
function renderPage(o) {
function renderPage(o) {
@@ -143,6 +144,7 @@ function renderPage(o) {
@@ -143,6 +144,7 @@ function renderPage(o) {
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
+ ...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),
`<script src="${asset('js/community.js')}" defer></script>`,
`<script src="${asset('js/community.js')}" defer></script>`,
// The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01).
// The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01).
`<script id="ov-glyphs-loader" src="${ovServe.url('ov-glyphs.js')}" defer></script>`,
`<script id="ov-glyphs-loader" src="${ovServe.url('ov-glyphs.js')}" defer></script>`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * The public moderation log (migrations/0012_forum_moderation_log.sql) and a thread's RSS feed:
+ * every moderator action writes one row in the same transaction, in role only (never the actor's
+ * subject); the rule is from the allowed list and the reason is trimmed and capped; private spaces
+ * keep their log behind the same sign-in rule as the space; a removed thread is shown as one; and
+ * the space and thread pages advertise their feeds.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to find: ${s}`);
+
+(async () => {
+ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const boss = net.addUser({ network_user_id: 1, username: 'boss', display_name: 'Boss' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const bossJwt = net.sign({ id: 1, subject_id: boss.subject_id, username: 'boss', display_name: 'Boss', role: 'global_mod' });
+ const admin = { id: 2, subject_id: ids.newId('user'), username: 'admin', display_name: 'Admin', role: 'admin' };
+ const adminJwt = net.sign(admin);
+
+ const call = (path, { method = 'GET', cookie, json } = {}) => t.get(path, {
+ method, headers: json !== undefined ? { 'content-type': 'application/json' } : {},
+ body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [],
+ });
+ const form = (path, cookie, fields) => t.get(path, {
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${cookie}`],
+ });
+ const rows = async (space = 'general') => t.db.prepare(`SELECT l.* FROM forum_moderation_log l JOIN spaces s ON s.id = l.space_id
+ WHERE s.slug = ? ORDER BY l.id ASC`).all(space);
+ const thread = async (space, title) => {
+ const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', cookie: alexJwt, json: { title, body: `A thread for ${title}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().thread;
+ };
+
+ let locked;
+ let feedback;
+
+ await check('lock, pin, post delete and status each write one log row in role only, with rule and reason', async () => {
+ locked = await thread('general', 'A thread that will be moderated');
+ const reply = await call(`/api/v1/spaces/general/threads/${locked.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A reply that will be removed' } });
+ assert.strictEqual(reply.status, 201, reply.text);
+ let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'spam', reason: ' Repeated links ' } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { pinned: '1', rule: 'off-topic', reason: ` Pinned for ${'x'.repeat(300)} ` });
+ assert.strictEqual(r.status, 303, r.text);
+ r = await form(`/s/general/t/${locked.slug}/post/${reply.json().post.id}/delete`, bossJwt, { rule: 'personal information', reason: 'Names someone' });
+ assert.strictEqual(r.status, 303, r.text);
+ feedback = await thread('feedback', 'A request whose status changes');
+ r = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/status`, { method: 'PUT', cookie: bossJwt, json: { status: 'planned', rule: 'duplicate', reason: 'Already planned elsewhere' } });
+ assert.strictEqual(r.status, 200, r.text);
+
+ const generalLog = await rows();
+ const feedbackLog = await rows('feedback');
+ assert.deepStrictEqual(generalLog.map((x) => x.action), ['lock', 'pin', 'delete']);
+ assert.deepStrictEqual(feedbackLog.map((x) => x.action), ['status']);
+ assert.deepStrictEqual(generalLog.map((x) => x.rule), ['spam', 'off-topic', 'personal information']);
+ assert.strictEqual(feedbackLog[0].rule, 'duplicate');
+ assert.strictEqual(generalLog[0].reason, 'Repeated links');
+ assert.strictEqual(generalLog[1].reason.length, 280, 'the reason is capped at 280 characters');
+ assert.strictEqual(generalLog[1].reason, `Pinned for ${'x'.repeat(280 - 'Pinned for '.length)}`);
+ assert.strictEqual(generalLog[2].reason, 'Names someone');
+ assert.strictEqual(feedbackLog[0].reason, 'Already planned elsewhere');
+ assert.deepStrictEqual([...generalLog, ...feedbackLog].map((x) => x.actor_role), ['staff', 'staff', 'staff', 'staff']);
+ assert.strictEqual(generalLog[2].thread_id, locked.id);
+ assert.strictEqual(generalLog[2].post_id, reply.json().post.id);
+ assert.strictEqual(feedbackLog[0].thread_id, feedback.id);
+ const dump = JSON.stringify([...generalLog, ...feedbackLog]);
+ assert.ok(!dump.includes(alex.subject_id), "the author's subject is not in the public row");
+ assert.ok(!dump.includes(boss.subject_id), "the moderator's subject is not in the public row");
+ });
+
+ await check('the rule list is enforced; a no-op change writes nothing', async () => {
+ const before = (await rows()).length;
+ let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: false, rule: 'not-a-rule' } });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.strictEqual(r.json().code, 'moderation.invalid_rule');
+ r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });
+ assert.strictEqual(r.status, 400, r.text);
+ has(r.text, 'The rule is one of spam');
+ assert.strictEqual((await rows()).length, before, 'a refused rule writes no row');
+ r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await rows()).length, before, 'a lock that changes nothing writes no row');
+ });
+
+ await check('the log page and JSON API show what happened, and the space page links it', async () => {
+ const page = await t.get('/s/general/log');
+ assert.strictEqual(page.status, 200, page.text);
+ has(page.text, '<h1>Moderation log</h1>');
+ has(page.text, 'A thread that will be moderated');
+ has(page.text, 'a post in');
+ has(page.text, 'Repeated links');
+ has(page.text, 'Staff');
+ assert.ok(!page.text.includes(alex.subject_id) && !page.text.includes(boss.subject_id), 'no subject on the page');
+ const json = await call('/api/v1/spaces/general/moderation-log');
+ assert.strictEqual(json.status, 200, json.text);
+ assert.deepStrictEqual(json.json().entries.map((e) => e.action), ['delete', 'pin', 'lock']);
+ assert.strictEqual(json.json().space.slug, 'general');
+ assert.ok(!json.text.includes(boss.subject_id));
+ const feedbackLog = await t.get('/s/feedback/log');
+ assert.strictEqual(feedbackLog.status, 200);
+ has(feedbackLog.text, 'A request whose status changes');
+ has(feedbackLog.text, 'Status changed');
+ const space = await t.get('/s/general');
+ has(space.text, 'href="/s/general/log"');
+ has(space.text, 'Moderation log');
+ has(space.text, '<link rel="alternate" type="application/rss+xml"');
+ });
+
+ await check('a thread feed is its newest posts, advertised in the thread head and linked on the page', async () => {
+ const reply = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A feed reply with **markup**' } });
+ assert.strictEqual(reply.status, 201, reply.text);
+ const page = await t.get(`/s/feedback/t/${feedback.slug}`);
+ has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/s/feedback/t/${feedback.slug}/feed.xml">`);
+ has(page.text, 'Follow this thread by RSS');
+ const feed = await t.get(`/s/feedback/t/${feedback.slug}/feed.xml`);
+ assert.strictEqual(feed.status, 200, feed.text);
+ assert.match(feed.headers.get('content-type'), /application\/rss\+xml/);
+ assert.match(feed.headers.get('cache-control'), /^public/, 'a public thread\'s feed may be cached');
+ has(feed.text, 'A request whose status changes');
+ has(feed.text, `Re: A request whose status changes`);
+ has(feed.text, 'A feed reply with markup');
+ const items = feed.text.split('<item>').slice(1);
+ has(items[0], 'A feed reply with markup', 'the newest post is the first item');
+ has(items[1], 'A request whose status changes');
+ const space = await t.get('/s/feedback');
+ has(space.text, '<link rel="alternate" type="application/rss+xml"');
+ has(space.text, '/s/feedback/feed.xml');
+ });
+
+ await check('a private space\'s log is not public; its members and staff read it', async () => {
+ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
+ const insider = await call('/api/v1/spaces/insiders/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Inside the members space', body: 'Members only' } });
+ assert.strictEqual(insider.status, 201, insider.text);
+ assert.strictEqual((await call(`/api/v1/spaces/insiders/threads/${insider.json().thread.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'harassment' } })).status, 200);
+ assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log')).status, 401, 'signed out: no JSON log');
+ const anon = await t.get('/s/insiders/log');
+ assert.notStrictEqual(anon.status, 200, 'signed out: no page');
+ assert.strictEqual((await t.get('/s/insiders/log', { cookies: [`ov_token=${alexJwt}`] })).status, 200, 'a signed-in member reads it');
+ assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log', { cookie: alexJwt })).status, 200);
+ const feedUrl = `/s/insiders/t/${insider.json().thread.slug}/feed.xml`;
+ assert.notStrictEqual((await t.get(feedUrl)).status, 200, 'signed out: no feed of a members thread');
+ const own = await t.get(feedUrl, { cookies: [`ov_token=${alexJwt}`] });
+ assert.strictEqual(own.status, 200, own.text);
+ assert.strictEqual(own.headers.get('cache-control'), 'private, no-store', 'a members thread\'s feed is never kept by a shared cache');
+ });
+
+ await check('a removed thread is shown as "a removed thread"', async () => {
+ const r = await call(`/api/v1/spaces/general/threads/${locked.slug}`, { method: 'DELETE', cookie: bossJwt, json: { rule: 'illegal content', reason: 'Removed by a moderator' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const log = await rows();
+ assert.strictEqual(log.at(-1).action, 'delete');
+ assert.strictEqual(log.at(-1).rule, 'illegal content');
+ const page = await t.get('/s/general/log');
+ assert.strictEqual(page.status, 200);
+ has(page.text, 'a removed thread');
+ has(page.text, 'Removed by a moderator');
+ assert.ok(!page.text.includes('A thread that will be moderated'), 'the deleted title is not shown');
+ const json = await call('/api/v1/spaces/general/moderation-log');
+ assert.strictEqual(json.json().entries[0].action, 'delete');
+ assert.strictEqual(json.json().entries[0].thread, null);
+ });
+
+ await check('the forms carry the rule select and reason box', async () => {
+ const thread2 = await thread('general', 'A thread with moderation controls');
+ const reply = await call(`/api/v1/spaces/general/threads/${thread2.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A reply with its own delete form' } });
+ assert.strictEqual(reply.status, 201, reply.text);
+ const page = await t.get(`/s/general/t/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });
+ assert.strictEqual(page.status, 200);
+ has(page.text, 'Rule applied…');
+ has(page.text, 'name="reason"');
+ has(page.text, 'maxlength="280"');
+ has(page.text, `<form class="mod-form" method="post" action="/s/general/t/${thread2.slug}/state">`);
+ has(page.text, `action="/s/general/t/${thread2.slug}/post/${reply.json().post.id}/delete"`);
+ has(page.text, 'Delete post');
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });