openvibers/openvibe.community Commits

Community: a public moderation log per space, and RSS for every thread (#211)

OP OpenVibers committed 12 days ago · 0962b43798 · 16 files, +587 −47

* Community: publish a per-space moderation log and thread RSS feeds

Every moderator lock, unlock, pin, unpin, post/thread delete and status change
writes a forum_moderation_log row in the same transaction, with the optional
rule and reason the no-JS forms (and JSON API) now take. Rows name the actor's
role only, never their Network subject. GET /s/:space/log and
GET /api/v1/spaces/:space/moderation-log show the newest 100, under the same
visibility as the space page; a removed thread reads 'a removed thread'.

Each thread also has RSS of its newest 50 posts, advertised in the head and
linked from the space and thread pages.

* Community: a members thread's feed is never kept by a shared cache; the log is cards on a phone

The thread feed sent public, max-age=300 for every thread, so a member's copy of a private or members-only
thread could be kept by Cloudflare and served to anyone. Only a public, open thread's feed is cacheable now;
any other is private, no-store (tested).

On a phone each log row was six labelled lines; it is now a card: the action and what it touched, the
reason, then rule, role and time in one small line. A missing rule or reason is left out there.

* Community: the moderation log's styles load on the log page only (modlog.css)

The log's table widths and phone cards were in community.css, which every page loads, and pushed the home
page 0.1 KB over its CSS budget. They are in modlog.css now, linked by that one page (renderPage's new
pageCss, after community.css); the moderator forms' styles stay shared because thread pages use them.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e25dcd1..80b6165 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 0e16ad5..a484cc9 100644
---- a/README.md
++++ b/README.md
@@@ -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 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
@@@ -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 |
ddiff --git a/migrations/0012_forum_moderation_log.sql b/migrations/0012_forum_moderation_log.sql
nnew file mode 100644
iindex 0000000..312d7e6
---- /dev/null
++++ b/migrations/0012_forum_moderation_log.sql
@@@ -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);
ddiff --git a/public/css/community.css b/public/css/community.css
iindex f710d65..b9b641a 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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) ── */
ddiff --git a/public/css/modlog.css b/public/css/modlog.css
nnew file mode 100644
iindex 0000000..75eaafa
---- /dev/null
++++ b/public/css/modlog.css
@@@ -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; }
++}
ddiff --git a/server/discovery.js b/server/discovery.js
iindex c1ec78b..5349fe7 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -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 };
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex af3572e..61bd61d 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -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 }));
ddiff --git a/server/forum/moderation.js b/server/forum/moderation.js
nnew file mode 100644
iindex 0000000..cf15716
---- /dev/null
++++ b/server/forum/moderation.js
@@@ -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 };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 0404a25..632fc5d 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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); }
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 55da387..219e1eb 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, vi
const 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, vi
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;
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, vi
if (!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, vi
if (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]);
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 2b7c3c9..409f32c 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -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,
};
ddiff --git a/server/openapi.js b/server/openapi.js
iindex d7b35d6..9155c5c 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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', {
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 5c38ffc..4867d16 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -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 }) {
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, viewe
historyType: '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 }]),
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 555c79e..bff7adf 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -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 = nul
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']) {
if (pages <= 1) return '';
return `<nav class="pager" aria-label="Pages">
@@@ -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 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, categories
const 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, u
historyType: '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,
};
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 28ed9ce..e09f8c4 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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>`,
ddiff --git a/test/moderation-log.test.js b/test/moderation-log.test.js
nnew file mode 100644
iindex 0000000..2da621f
---- /dev/null
++++ b/test/moderation-log.test.js
@@@ -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); });