openvibers/openvibe.community Commits
Forum: move /s to /forum, threads to /forum/<space>/<slug> (#277)
Changes
@@@ -2,6 +2,18 @@## Unreleased++- **The forum moved from `/s` to `/forum`** (design 2026-11-07, section 13 "URLs people already know"). The board++ is `/forum`, a space `/forum/<space>`, and a thread `/forum/<space>/<thread>` (the `/t/` segment is gone);++ `/forum/new` and `/forum/discuss` replace `/s/new-space` and `/s/discuss`. Every old `/s` address answers a++ permanent redirect with the query kept — 301 for reads, 308 for form posts, so `/s/<space>/t/<slug>` becomes++ `/forum/<space>/<slug>` without losing a method or body.++ - A thread slug may never be one of a space's own sub-route words (`new`, `log`, `settings`, `feed.xml`,++ `moderators`, `chat-room`, `members`, `about`, `search`, `rules`, `wiki`, `edit`, `discuss`): a title that would++ make one takes `-2`. A space may not take `new`, `discuss`, `feed.xml`, `search` or `about`.++ - Sitemap, robots (`/forum/*/new`), IndexNow pings, feeds, `llms.txt`, canonical links and BreadcrumbList++ (`Forum › Space › Thread`), Search documents, Pulse items, `community.thread/post.created` URLs and the Discord++ relay all use `/forum`.++- **Locked pastes** (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). A new text paste can belocked: the browser seals it, Community stores the envelope and never sees the content key.- `POST /api/pastes` takes `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`; a@@@ -143,15 +143,15 @@ Pages (server-rendered HTML):| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image || `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=`, 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); 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 /forum` | The Forum (board index); `GET /forum[/…]` answers 301 → `/forum[/…]` |++| `GET /forum/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |++| `GET /forum/:space/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |++| `GET /forum/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |++| `GET /forum/:space/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |++| `GET /forum/:space/new`, `POST /forum/:space/new` | Start a thread (signed in) |++| `POST /forum/:space/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |++| `POST /forum/:space/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |++| `POST /forum/:space/: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 |@@@ -180,8 +180,8 @@ API and machine endpoints:| `POST /auth/refresh` | Rotate via refresh token || `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) |++| `GET /forum/feed.xml`, `GET /forum/:space/feed.xml` | RSS of the latest threads (public spaces) |++| `GET /forum/:space/: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@@@ -346,7 +346,7 @@ member for now), `staff` (moderators only; looks missing to everyone else).refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap-- lists `/s`, `/pulse`, public spaces and the latest 1000 threads; `/s/*/new` is disallowed in++ lists `/forum`, `/pulse`, public spaces and the latest 1000 threads; `/forum/*/new` is disallowed inrobots.txt.- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is thedeterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`@@@ -375,12 +375,12 @@ member for now), `staff` (moderators only; looks missing to everyone else).`docs/cutover-community-0005-space-moderators.md`.- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:-- - the board index at `/s` lists every space under its group, with topics, posts and the last post;++ - the board index at `/forum` lists every space under its group, with topics, posts and the last post;- a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;- a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),-- the name and description, and create spaces (`/s/new-space`).++ the name and description, and create spaces (`/forum/new`).Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to anotherspace, and both link to each other.- **Ratings** (after Facepunch), one per person per post, never your own:@@@ -431,7 +431,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) || `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 |++| `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 /forum/:space/moderators` and `POST /forum/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |### A space's chat room (OpenVibe.Chat)@@@ -665,9 +665,9 @@ Discovery is written by `openvibe-shared/seo`; Community only supplies the data`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on pastepages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The--sitemap lists home, `/pastes`, the latest public pastes, `/s`, `/pulse`, public spaces and++sitemap lists home, `/pastes`, the latest public pastes, `/forum`, `/pulse`, public spaces andtheir latest threads (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers--and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/s/*/new` and `?sso=` URLs;++and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/forum/*/new` and `?sso=` URLs;`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastesin full (no NSFW, no burn-after-read, nothing members-only).@@@ -5,7 +5,7 @@"key": "welcome","space": "general","title": "Welcome to OpenVibe: start here",-- "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/s/feedback), where people vote on them. What we are building next is on the [Roadmap](/s/roadmap). Everything here follows the [terms](/terms)."++ "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/forum/feedback), where people vote on them. What we are building next is on the [Roadmap](/forum/roadmap). Everything here follows the [terms](/terms)."},{"key": "how-to-get-help",@@@ -17,7 +17,7 @@"key": "how-feedback-works","space": "feedback","title": "How feedback works",-- "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/s/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."++ "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/forum/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."},{"key": "show-what-you-made",@@@ -1,4 +1,4 @@--/* OpenVibe.Community: the moderation log page (/s/:space/log), linked by that page alone (renderPage pageCss) so the++/* OpenVibe.Community: the moderation log page (/forum/:space/log), linked by that page alone (renderPage pageCss) so theother 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; }@@@ -13,9 +13,10 @@* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)-- * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /s/feed.xml++ * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /forum/feed.xml* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh-- * GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)++ * GET /forum … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)++ * GET /s … 301/308 to /forum (old forum links)* GET /pulse the network's public activity* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)* GET|POST /submissions … submissions, their pages and the review queue (submissions/routes.js)@@@ -9,10 +9,11 @@ const config = require('./config');const catalog = require('./pastes/catalog');const source = require('./pastes/source');const { abs } = require('./render/layout');++const { spacePath, threadPath } = require('./forum/paths');const { isoDate, clean } = require('./render/jsonld');const SITEMAP_TTL_MS = 60 * 60 * 1000;--const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso='];const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';@@@ -29,7 +30,7 @@ function llmsSections() {{ title: 'Browse', links: [{ title: 'OpenVibe.Community', url: `${u}/`, note: 'pastes, forum and network activity' },{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },-- { title: 'Forum', url: `${u}/s`, note: 'open discussion forums and their threads', html: true },++ { title: 'Forum', url: `${u}/forum`, note: 'open discussion forums and their threads', html: true },{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity', html: true },] },// The API is a primary entry for an agent, not something to skip.@@@ -53,7 +54,7 @@ function llmsSections() {optional: [{ title: 'Sitemap', url: `${u}/sitemap.xml` },{ title: 'Latest pastes (RSS)', url: `${u}/feed.xml` },-- { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },++ { title: 'Latest threads (RSS)', url: `${u}/forum/feed.xml` },{ title: 'Full text for language models', url: `${u}/llms-full.txt` },],};@@@ -68,7 +69,7 @@ async function llmsFullSections() {if (_forum) {const threads = await _forum.recentPublic({ limit: 50 });sections.push({ title: 'Latest threads', pages: threads.map((t) => ({-- title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),++ title: `${t.title} (${t.space_name})`, url: abs(threadPath(t.space_slug, t.slug)), text: markdownToText(t.opening, Infinity),})) });}const pastes = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw) && !Number(p.burn_after_read) && p.type !== 'screenshot');@@@ -93,10 +94,10 @@ async function buildSitemapRows() {// No /p/:slug rows: a paste is link-shared content and is noindex (SEO-29), so it must not be// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.if (_forum) {-- add('/s', null, 'hourly', '0.8');++ add('/forum', null, 'hourly', '0.8');add('/pulse', null, 'hourly', '0.5');-- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');-- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');++ for (const s of await _forum.publicSpaces()) add(spacePath(s.slug), s.last_activity_at || null, 'hourly', '0.7');++ for (const t of await _forum.recentPublic({ limit: 1000 })) add(threadPath(t.space_slug, t.slug), isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');}return rows;}@@@ -120,22 +121,22 @@ async function pasteFeedItems() {});}--/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */++/** Threads (forum.recentPublic rows) for /forum/feed.xml and /forum/:space/feed.xml. */function threadFeedItems(threads) {const { markdownToText } = require('./render/markdown');return threads.map((t) => {-- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);++ const url = abs(threadPath(t.space_slug, t.slug));const description = markdownToText(t.opening, 300);return { title: t.title, link: url, guid: url, description, content: description,author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };});}--/** A thread's newest posts for /s/:space/t/:slug/feed.xml (opening post and replies). */++/** A thread's newest posts for /forum/:space/: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 url = abs(`${threadPath(space.slug, 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) };@@@ -102,14 +102,14 @@ async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {const visibility = forumVisibility(thread, spaceVisibility);return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,++ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}` : null,}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });}async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {const visibility = forumVisibility(thread, spaceVisibility);return await record('community.post.created', { type: 'post', id: String(post.id) }, {post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,++ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}#p${post.id}` : null,}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });}/**nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++++/**++ * The forum's addresses. The forum moved from /s (spaces were its old name) to /forum, and a thread's++ * /t/ segment went with it (design 2026-11-07): /forum, /forum/<space>, /forum/<space>/<thread>.++ * A thread's slug therefore shares a namespace with the space's own sub-routes, so those words are++ * reserved and never become a slug (store.js appends -2 when a title would make one).++ */++++const RESERVED_SPACE_SLUGS = new Set(['new', 'discuss', 'feed.xml', 'search', 'about']);++const RESERVED_THREAD_SLUGS = new Set(['new', 'log', 'settings', 'feed.xml', 'moderators', 'chat-room', 'members', 'about', 'search', 'rules', 'wiki', 'edit', 'discuss']);++++const spacePath = (slug) => `/forum/${encodeURIComponent(slug)}`;++const threadPath = (spaceSlug, threadSlug) => `/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(threadSlug)}`;++++/**++ * An old /s… path (the remainder express gives a router mounted at /s, query included) → its /forum path.++ * /s/general/t/a-title becomes /forum/general/a-title; everything else keeps its shape.++ */++function legacyForumPath(url) {++ const qi = url.indexOf('?');++ const path = qi === -1 ? url : url.slice(0, qi);++ const query = qi === -1 ? '' : url.slice(qi);++ let rest = path === '/' ? '' : path.replace(/\/+$/, '');++ if (rest === '/new-space') return `/forum/new${query}`;++ rest = rest.replace(/^\/([^/]+)\/t(?=\/|$)/, '/$1');++ return `/forum${rest}${query}`;++}++++module.exports = { RESERVED_SPACE_SLUGS, RESERVED_THREAD_SLUGS, spacePath, threadPath, legacyForumPath };@@@ -3,18 +3,21 @@/*** Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):*-- * GET /s the Forum's board index (a forum is a `space` in code and the API)-- * GET /forum, /forum/<rest> 301 → /s, /s/<rest> (query kept)-- * 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)++ * GET /forum the Forum's board index (a forum is a `space` in code and the API)++ * GET /s, /s/<rest> 301 → /forum, /forum/<rest> (query kept); POST answers 308++ * GET /forum/feed.xml RSS: latest threads in public spaces++ * GET /forum/:space threads (?sort=hot|new|top&page=)++ * GET /forum/:space/feed.xml RSS: latest threads of one public space++ * GET /forum/:space/log the space's public moderation log (newest 100)++ * GET /forum/:space/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread++ * GET /forum/:space/new POST /forum/:space/new start a thread++ * GET /forum/:space/:slug a thread (?page=)++ * POST /forum/:space/:slug/reply | /vote | /state | /delete | /members-only no-JS forms++ * POST /forum/:space/:slug/post/:id/delete a moderator removes a post (rule and reason)++ * POST /forum/:space/chat-room, /forum/:space/chat-room/detach the space's chat room (its owner or staff)++ *++ * The forum used to be /s (spaces were its old name) and a thread lived at /s/:space/t/:slug. Every old++ * address redirects permanently: 301 for reads, 308 for form posts so the method and body survive.** A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the* creator's join link (403), never its posts.@@@ -33,6 +36,7 @@ const pages = require('../render/pages');const forumPages = require('../render/forum');const boardPages = require('../render/board');const { ApiError } = require('../http/v1');++const { spacePath, threadPath, legacyForumPath } = require('./paths');function createForumRoutes({ forum, viewers, config }) {const router = express.Router();@@@ -79,33 +83,33 @@ function createForumRoutes({ forum, viewers, config }) {next();}-- // People call it the Forum (spaces are OpenVibe.Space's), so /forum and /forum/<rest> answer too: a 301 to the-- // /s address every link, sitemap entry and Search document already uses.-- router.use('/forum', (req, res, next) => {-- if (req.method !== 'GET' && req.method !== 'HEAD') return next();-- res.redirect(301, `/s${req.url === '/' || req.url.startsWith('/?') ? req.url.slice(1) : req.url}`);++ // The old /s address (spaces) still works: every read answers a permanent 301 to /forum, every other++ // method a 308 (the method and body are kept). Old thread links /s/<space>/t/<slug> become /forum/<space>/<slug>.++ router.use('/s', (req, res) => {++ const status = req.method === 'GET' || req.method === 'HEAD' ? 301 : 308;++ res.redirect(status, legacyForumPath(req.url));});// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.-- router.get('/s', withViewer, wrap(async (req, res, next) => {++ router.get('/forum', withViewer, wrap(async (req, res, next) => {try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }}));// Moderators: a new space, either style.-- router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {++ router.get('/forum/new', withViewer, wrap(async (req, res, next) => {try {-- if (!req.user) return login(res, '/s/new-space');++ if (!req.user) return login(res, '/forum/new');if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');html(res, boardPages.newSpacePage({ groups: await forum.groups() }));} catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };try {const out = await forum.createSpace(req.viewer, values);discovery.resetCaches();-- res.redirect(303, `/s/${out.space.slug}`);++ res.redirect(303, spacePath(out.space.slug));} catch (err) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);@@@ -113,7 +117,7 @@ function createForumRoutes({ forum, viewers, config }) {}));// "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.-- router.get('/s/discuss', withViewer, wrap(async (req, res, next) => {++ router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {try {const paste = String(req.query.paste || '').slice(0, 80);const { groups } = await forum.listSpaces(req.viewer);@@@ -124,26 +128,26 @@ function createForumRoutes({ forum, viewers, config }) {// RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,-- link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,++ link: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}`,description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',language: 'en',-- selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,++ selfUrl: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}/feed.xml`,items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),}, { format: 'rss' }));-- router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));++ router.get('/forum/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));-- router.get('/s/:space/feed.xml', wrap(async (req, res) => {++ router.get('/forum/:space/feed.xml', wrap(async (req, res) => {const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);if (!space) return res.status(404).type('text/plain').send('Not found');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) => {++ router.get('/forum/:space/: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}`;++ const base = `${config.baseUrl}${threadPath(out.space.slug, 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}`,@@@ -160,14 +164,14 @@ function createForumRoutes({ forum, viewers, config }) {}));// 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) => {++ router.get('/forum/: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('/forum/: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 });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');@@@ -179,7 +183,7 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {++ router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {try {// A members-only space: the teaser, not the form, for someone who may not post there.const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });@@@ -190,7 +194,7 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };if ((req.body || {}).members_only === '1') values.members_only = true;if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);@@@ -225,11 +229,11 @@ function createForumRoutes({ forum, viewers, config }) {html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);}-- router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {++ router.get('/forum/:space/:slug', withViewer, wrap(async (req, res, next) => {try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {const draft = String((req.body || {}).body || '').slice(0, 40_000);try {const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];@@@ -247,9 +251,9 @@ function createForumRoutes({ forum, viewers, config }) {}}));-- const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;++ const back = (req) => threadPath(req.params.space, req.params.slug);-- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });res.redirect(303, back(req));@@@ -257,7 +261,7 @@ function createForumRoutes({ forum, viewers, config }) {}));// Members-only on/off (no JS): on gates the thread to its author's VIP members.-- router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });discovery.resetCaches();@@@ -266,7 +270,7 @@ function createForumRoutes({ forum, viewers, config }) {}));// Content labels (L1, no JS): the author or a moderator ticks what the thread carries.-- router.post('/s/:space/t/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {const labels = labelsUi.fromForm(req.body);await forum.setThreadLabels(req.viewer, req.params.space, req.params.slug, { labels: labels === undefined ? [] : (labels === null ? ['not-a-label'] : labels) });@@@ -275,7 +279,7 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};const flags = { rule: b.rule, reason: b.reason };if (b.pinned !== undefined) flags.pinned = b.pinned === '1';@@@ -287,7 +291,7 @@ function createForumRoutes({ forum, viewers, config }) {}));// Rate a post (no JS): the rating's button in the post's form; back to the post.-- router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};try {await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });@@@ -297,7 +301,7 @@ function createForumRoutes({ forum, viewers, config }) {}));// Crosspost to another space (no JS).-- router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });discovery.resetCaches();@@@ -306,21 +310,21 @@ function createForumRoutes({ forum, viewers, config }) {}));// Moderators: a space's settings (no JS).-- router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};try {// The group (the board index) is staff's; a space's own moderators change the rest.await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });discovery.resetCaches();-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);++ res.redirect(303, spacePath(req.params.space));} catch (err) { failPage(req, res, err, next); }}));// The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's// moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.-- const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;++ const modsBack = (req, error) => `${spacePath(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'community.blocked' || err.code === 'identity.unavailable');-- router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));res.redirect(303, modsBack(req));@@@ -329,7 +333,7 @@ function createForumRoutes({ forum, viewers, config }) {failPage(req, res, err, next);}}));-- router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));res.redirect(303, modsBack(req));@@@ -340,8 +344,8 @@ function createForumRoutes({ forum, viewers, config }) {}));// The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.-- const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;-- router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const chatBack = (req, error) => `${spacePath(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;++ router.post('/forum/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });res.redirect(303, chatBack(req));@@@ -350,7 +354,7 @@ function createForumRoutes({ forum, viewers, config }) {failPage(req, res, err, next);}}));-- router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.detachChatRoom(req.viewer, req.params.space);res.redirect(303, chatBack(req));@@@ -358,7 +362,7 @@ 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) => {++ router.post('/forum/:space/: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(b.status || ''), rule: b.rule, reason: b.reason });@@@ -366,7 +370,7 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ router.post('/forum/:space/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });res.redirect(303, back(req));@@@ -374,18 +378,18 @@ function createForumRoutes({ forum, viewers, config }) {}));// 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) => {++ router.post('/forum/:space/: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('/forum/:space/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {try {await forum.deleteThread(req.viewer, req.params.space, req.params.slug, req.body || {});discovery.resetCaches();-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);++ res.redirect(303, spacePath(req.params.space));} catch (err) { failPage(req, res, err, next); }});@@@ -40,6 +40,7 @@* (moderator services can detach). Community keeps the link: space_chat_rooms.*/const store = require('./store');++const { spacePath, threadPath, RESERVED_SPACE_SLUGS } = require('./paths');const events = require('../events');const { applyVote, myVotes, parseVote } = require('../votes');const { fail, isoTime } = require('../http/v1');@@@ -114,11 +115,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst site = String(config.baseUrl || '').replace(/\/$/, '');const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };-- const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}/s/${s.slug}`, `${site}/sitemap.xml`]); };++ const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}${spacePath(s.slug)}`, `${site}/sitemap.xml`]); };const pingThread = (space, t, { withSpace = false } = {}) => {if (!indexableSpace(space) || !t || t.members_only_owner) return;-- const urls = [`${site}/s/${space.slug}/t/${t.slug}`, `${site}/sitemap.xml`];-- if (withSpace) urls.push(`${site}/s/${space.slug}`);++ const urls = [`${site}${threadPath(space.slug, t.slug)}`, `${site}/sitemap.xml`];++ if (withSpace) urls.push(`${site}${spacePath(space.slug)}`);ping(urls);};@@@ -244,7 +245,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}// ── shapes ───────────────────────────────────────────────-- const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;++ const threadUrl = (space, t) => threadPath(space.slug, t.slug);const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);@@@ -348,7 +349,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync function shapeSpace(s, mo = null) {return {-- slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,++ slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: spacePath(s.slug),thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,@@@ -398,11 +399,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (thread.crosspost_of) {const t = await store.getThread(db, thread.crosspost_of);const sp = t ? await store.getSpaceById(db, t.space_id) : null;-- if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };++ if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: threadPath(sp.slug, t.slug) };}const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id)).map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))-- .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));++ .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: threadPath(sp.slug, r.slug) }));const targets = person(v) && !thread.members_only_owner? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v))).map((sp) => ({ slug: sp.slug, name: sp.name }))@@@ -547,7 +548,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst shaped = await Promise.all(rows.map(async (r) => {const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));const l = last.get(r.id);-- sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;++ sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: threadPath(r.slug, l.thread_slug) }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;return sp;}));// The board index: groups in order, each with its top-level spaces; child boards under their parent.@@@ -593,7 +594,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync createSpace(v, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');const slug = String(body.slug || '').trim().toLowerCase();-- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');++ if (!SPACE_SLUG.test(slug) || RESERVED_SPACE_SLUGS.has(slug) || slug === 'new-space') fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new, discuss, feed.xml, search, about or new-space)');if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';const fields = await spaceFields({ ...body, name: body.name }, null);@@@ -636,7 +637,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });pingSpace(space);-- return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };++ return { space: { slug: space.slug, name: space.name, url: spacePath(space.slug) }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };},/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */@@@ -712,7 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vientries: 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,++ thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: threadPath(space.slug, r.thread_slug) } : null,post_id: r.post_id || null,})),};@@@ -787,7 +788,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');-- const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;++ const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](${threadPath(space.slug, thread.slug)})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });if (pulse) await hook(async () => await pulse.threadCreated(created, target));@@@ -10,6 +10,7 @@*/const SORTS = ['hot', 'new', 'top', 'active'];++const { RESERVED_THREAD_SLUGS } = require('./paths');// ── Spaces ───────────────────────────────────────────────────@@@ -91,7 +92,10 @@ function slugify(title) {async function uniqueSlug(db, spaceId, base) {const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');-- if (!await taken.get(spaceId, base)) return base;++ // /forum/<space>/<slug> shares its namespace with the space's own sub-routes: a title that makes one of++ // those words takes the -2 suffix instead.++ const start = RESERVED_THREAD_SLUGS.has(base) ? `${base.slice(0, 54)}-2` : base;++ if (!await taken.get(spaceId, start)) return start;for (let n = 2; n < 10_000; n++) {const candidate = `${base.slice(0, 54)}-${n}`;if (!await taken.get(spaceId, candidate)) return candidate;@@@ -79,14 +79,14 @@ function createPulse({ db, network = null, config = {} } = {}) {async threadCreated(thread, space) {if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;await recordLocal('thread', thread.id, {-- title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,++ title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,});},async postCreated(post, thread, space) {if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;await recordLocal('post', post.id, {-- title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,++ title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#post-${post.id}`,actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,});},@@@ -198,7 +198,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl}return 'someone';}-- const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;++ const threadUrl = (thread, space) => `${base}/forum/${space.slug}/${thread.slug}`;async function postUrl(post, thread, space) {const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);@@@ -211,13 +211,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst url = threadUrl(thread, space);return {username: 'OpenVibe.Community',-- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,++ content: `New thread in **forum/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,allowed_mentions: { parse: [] },embeds: [{title: String(thread.title).slice(0, 256),url,description: markdownToText(opening ? opening.body_markdown : '', 300),-- footer: { text: `OpenVibe.Community · s/${space.slug}` },++ footer: { text: `OpenVibe.Community · forum/${space.slug}` },timestamp: isoTime(thread.created_at) || undefined,}],};@@@ -229,13 +229,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst title = String(thread.title).slice(0, 200);return {username: 'OpenVibe.Community',-- content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,++ content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **forum/${escapeDiscord(space.slug)}**: <${url}>`,allowed_mentions: { parse: [] },embeds: [{title: `Re: ${title}`.slice(0, 256),url,description: markdownToText(post.body_markdown, 500),-- footer: { text: `OpenVibe.Community · s/${space.slug}` },++ footer: { text: `OpenVibe.Community · forum/${space.slug}` },timestamp: isoTime(post.created_at) || undefined,}],};@@@ -476,7 +476,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glstatus: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },-- thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },++ thread: { id: d.thread_id, title: d.thread_title, url: `/forum/${d.space_slug}/${d.thread_slug}` },post: d.post_id ? { id: d.post_id } : null,}));}@@@ -1,7 +1,7 @@'use strict';/**-- * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,++ * Forum-style pages, after vBulletin and SMF: the board index at /forum (every space, grouped, with topics,* posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics* first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,* posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js@@@ -19,7 +19,7 @@ const labelsUi = require('./labels');const STYLE_LABELS = { forum: 'Classic', feed: 'Feed' }; // the two styles, as the Forum page and the settings name themconst monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;--const trail = (space) => [['Home', '/'], ['Forum', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];++const trail = (space) => [['Home', '/'], ['Forum', '/forum'], ...(space.group ? [[space.group.name, `/forum#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/forum/${space.parent.slug}`]] : [])];const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */@@@ -28,10 +28,10 @@ function boardRow(sp) {const last = sp.last_post? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`: '<span class="muted small">No posts yet</span>';-- const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';++ const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';return `<tr><td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>-- <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}++ <a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}</div></td><td class="num" data-label="Topics">${num(sp.thread_count)}</td>@@@ -46,14 +46,14 @@ function boardTable(spaces, caption) {<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;}--// ── /s ───────────────────────────────────────────────────────++// ── /forum ───────────────────────────────────────────────────────function boardIndexPage({ groups = [], user, canModerate = false }) {const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Forum', '/s']])}++ ${crumbs([['Home', '/'], ['Forum', '/forum']])}<h1>Forum</h1><p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>-- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}++ ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}</header>${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}"><h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>@@@ -64,10 +64,10 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to postreturn renderPage({title: 'Forum',description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',-- canonicalPath: '/s',++ canonicalPath: '/forum',active: 'spaces',-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],body,});}@@@ -80,7 +80,7 @@ function topicPages(base, pages) {}function topicRow(t, space) {-- const base = `/s/${space.slug}/t/${t.slug}`;++ const base = `/forum/${space.slug}/${t.slug}`;const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');@@@ -100,7 +100,7 @@ function topicRow(t, space) {function settingsForm(space, groups = []) {const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>--<form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings">++<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings"><label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label><label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label><label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>@@@ -121,18 +121,18 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})const name = m.username ? `@${m.username}` : (m.display_name || 'this person');const self = me && m.subject === me;return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}-- <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;++ <form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;}).join('');return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}--<form class="paste-form" method="post" action="/s/${slug}/moderators">++<form class="paste-form" method="post" action="/forum/${slug}/moderators"><label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label><div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div></form></details>`;}--// ── /s/:space (forum style) ──────────────────────────────────++// ── /forum/:space (forum style) ──────────────────────────────────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;@@@ -142,13 +142,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =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 statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';const start = viewer.can_start !== false-- ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`++ ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);const body = `<header class="page-head">-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}-- <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>++ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}++ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${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>` : ''}@@@ -158,19 +158,19 @@ ${tags}${statuses}<section data-results>${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption><thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>-- <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="/forum/${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 }) : ''}--<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;++<footer class="space-foot"><a class="small" href="/forum/${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: 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}` }])],++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],body,});}@@@ -208,9 +208,9 @@ function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {</article>`;}--// ── /s/:space/t/:slug (forum style) ──────────────────────────++// ── /forum/:space/:slug (forum style) ──────────────────────────function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {-- const base = `/s/${space.slug}/t/${thread.slug}`;++ const base = `/forum/${space.slug}/${thread.slug}`;const opening = posts.find((p) => p.is_opening) || null;const gated = !!(space.members_only || thread.members_only);const indexable = space.visibility === 'public' && !gated;@@@ -231,7 +231,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, vieweconst labelBox = f.labelsBox(base, thread, viewer);const body = `<article class="thread forum-topic">-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}++ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`], [ld.clean(thread.title, 60), base]])}<header class="thread-head">${space.votes === false ? '' : f.voteForm(base, thread, viewer)}<div>@@@ -264,12 +264,12 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewefooterVariant: 'compact',...(labelBox ? { pageCss: ['labels.css'] } : {}),feeds: [-- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${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 }]),++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),],body,});@@@ -280,14 +280,14 @@ function newSpacePage({ groups = [], error = null, values = {} }) {const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Forum', '/s'], ['New forum', '/s/new-space']])}++ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['New forum', '/forum/new']])}<h1>New forum</h1><p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p></header>--<form class="paste-form" method="post" action="/s/new-space">++<form class="paste-form" method="post" action="/forum/new">${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>-- <label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>++ <label class="field"><span>Address (/forum/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label><label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label><label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label><label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>@@@ -295,7 +295,7 @@ function newSpacePage({ groups = [], error = null, values = {} }) {<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label><div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div></form>`;-- return renderPage({ title: 'New forum', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });++ return renderPage({ title: 'New forum', canonicalPath: '/forum/new', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });}/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */@@@ -303,13 +303,13 @@ function discussPage({ groups = [], paste = '', user }) {const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Forum', '/s'], ['Discuss', '/s/discuss']])}++ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1><p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>-- ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}++ ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}</header>--${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;-- return renderPage({ title: 'Discuss in the forum', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });++${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;++ return renderPage({ title: 'Discuss in the forum', canonicalPath: '/forum/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });}module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };@@@ -1,7 +1,7 @@'use strict';/**-- * Forum pages — /s (spaces), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.++ * Forum pages — /forum (spaces), /forum/:space (threads), /forum/:space/:slug (a thread), /forum/:space/new.* Complete without JavaScript: sorting and pagination are links, replying, voting and the* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe* Markdown renderer's output (render/markdown.js).@@@ -38,7 +38,7 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nulif (sort && sort !== 'hot') q.set('sort', sort);if (page > 1) q.set('page', String(page));const s = q.toString();-- return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;++ return `/forum/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;}/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */@@@ -67,11 +67,11 @@ const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vconst vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');--// ── /s ───────────────────────────────────────────────────────++// ── /forum ───────────────────────────────────────────────────────function spacesPage({ spaces }) {const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `<li class="space-item">-- <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>++ <a class="space-link" href="/forum/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p></li>`).join('')}@@@ -86,22 +86,22 @@ ${list}`;return renderPage({title: 'Forum',description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',-- canonicalPath: '/s',++ canonicalPath: '/forum',active: 'spaces',-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],body,});}--// ── /s/:space ────────────────────────────────────────────────++// ── /forum/:space ────────────────────────────────────────────────function threadRow(t, space) {const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;const labels = labelsUi.inForce(t);return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}<div class="thread-main">-- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}++ <a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}${labelsUi.chips(labels)}<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p></div>@@@ -119,11 +119,11 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <form class="paste-form" method="post" action="/s/${esc(space.slug)}/chat-room">++ <form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room"><label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label><div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div></form>-- ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}++ ${r ? `<form class="inline-form" method="post" action="/forum/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}</details>` : '';return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;}@@@ -138,13 +138,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categoriesconst 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 canStart = viewer.can_start !== false;const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';-- const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`-- : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;++ const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`++ : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;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)}${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>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>++ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${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>@@@ -152,19 +152,19 @@ ${chatRoom}${categoryNav}${statusNav}<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav><section data-results>-- ${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="/forum/${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 class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;++<footer class="space-foot"><a class="small" href="/forum/${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: 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}` }])],++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],body,});}@@@ -181,7 +181,7 @@ function modActions({ base, space, thread, viewer, categories = [] }) {</div>` : '';}--// ── /s/:space/t/:slug ────────────────────────────────────────++// ── /forum/:space/:slug ────────────────────────────────────────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}">@@@ -271,7 +271,7 @@ function labelsBox(base, thread, viewer) {}function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {-- const base = `/s/${space.slug}/t/${thread.slug}`;++ const base = `/forum/${space.slug}/${thread.slug}`;const opening = posts.find((p) => p.is_opening) || null;const gated = !!(space.members_only || thread.members_only);const indexable = space.visibility === 'public' && !gated;@@@ -292,7 +292,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uconst body = `<article class="thread">-- <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">${esc(ld.clean(thread.title, 60))}</span></nav>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav><header class="thread-head">${space.votes === false ? '' : voteForm(base, thread, viewer)}<div>@@@ -325,21 +325,21 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, ufooterVariant: 'compact',...(labelBox ? { pageCss: ['labels.css'] } : {}),feeds: [-- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${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 }]),++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),],body,});}--// ── /s/:space/new ────────────────────────────────────────────++// ── /forum/:space/new ────────────────────────────────────────────function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';-- const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>++ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>@@@ -353,17 +353,17 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button><span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span></div>--</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;++</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;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">New thread</span></nav>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav><h1>New thread in ${esc(space.name)}</h1></header>${form}`;return renderPage({title: `New thread · ${space.name}`,description: `Start a thread in ${space.name} on OpenVibe.Community.`,-- canonicalPath: `/s/${space.slug}/new`,++ canonicalPath: `/forum/${space.slug}/new`,robots: 'noindex,follow',active: 'spaces',footerVariant: 'compact',@@@ -377,14 +377,14 @@ ${form}`;* What someone without the creator's VIP membership sees: the space (and thread title), why, and* the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).*/--function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {++function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/forum' }) {const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';const why = reason === 'not_signed_in' || !user? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`: reason === 'vip_unavailable' || reason === 'entitlement_unknown'? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>': '<p class="muted">Your account does not have an active membership for this.</p>';-- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;++ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › ${thread ? `<a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;const body = `<header class="page-head">${crumbs}@@@ -399,7 +399,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reasonreturn renderPage({title: `${thread ? thread.title : space.name} · members only`,description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,-- canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,++ canonicalPath: thread ? `/forum/${space.slug}/${thread.slug}` : `/forum/${space.slug}`,robots: 'noindex,nofollow',active: 'spaces',footerVariant: 'compact',@@@ -407,7 +407,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason});}--// ── /s/:space/log ────────────────────────────────────────────++// ── /forum/: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>';@@@ -432,23 +432,23 @@ function moderationLogPage({ space, entries }) {</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>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${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>`;++<footer class="space-foot"><a class="small" href="/forum/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/forum/${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`,++ canonicalPath: `/forum/${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` }])],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: 'Moderation log', url: `/forum/${space.slug}/log` }])],body,});}@@@ -82,7 +82,7 @@ function discussionAuthorLd(a) {/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */function threadLd({ space, thread, posts, opening, description }) {const { markdownToText } = require('./markdown');-- const url = abs(`/s/${space.slug}/t/${thread.slug}`);++ const url = abs(`/forum/${space.slug}/${thread.slug}`);const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);return {'@context': 'https://schema.org',@@@ -94,7 +94,7 @@ function threadLd({ space, thread, posts, opening, description }) {author: discussionAuthorLd(thread.author),datePublished: isoDate(thread.created_at) || undefined,dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,-- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },++ isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/forum/${space.slug}`) },commentCount: thread.reply_count,interactionStatistic: [{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },@@@ -64,7 +64,7 @@ function navbarInit(opts) {links: [// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.-- { label: 'Forum', href: '/s', icon: 'fa-comments', active: opts.active === 'spaces' },++ { label: 'Forum', href: '/forum', icon: 'fa-comments', active: opts.active === 'spaces' },{ label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },{ label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },@@@ -95,11 +95,11 @@ function footerInit(opts) {heading: 'Community',items: [{ name: 'Pastes', url: '/pastes' },-- { name: 'Forum', url: '/s' },++ { name: 'Forum', url: '/forum' },{ name: 'Pulse', url: '/pulse' },{ name: 'New paste', url: '/new' },{ name: 'My pastes', url: '/my' },-- { name: 'Roadmap', url: '/s/roadmap' },++ { name: 'Roadmap', url: '/forum/roadmap' },{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Community' },],}],@@@ -137,7 +137,7 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {// like an app"): what Community is and the two things to do first, the numbers as one strip, the newest pastes and the// moments from streams as rails of the shared kit's cards (openvibe-shared kit.rail + kit.card, room.css) with See all// and the kinds as quick filters, the forum's places as a launcher (showcase.apps), the roadmap and Pulse as short lists,--// and the parts read once (the API) folded. The full lists live on /pastes, /s, /s/roadmap and /pulse.++// and the parts read once (the API) folded. The full lists live on /pastes, /forum, /forum/roadmap and /pulse.const TERMINAL = `$ curl -F 'content=<build.log' \\https://openvibe.community/api/pastes{ "slug": "amber-fox-42",@@@ -218,18 +218,18 @@ function forumShelf(spaces) {return showcase.apps({id: 'forum', title: 'The forum',items: spaces.slice(0, 8).map((sp) => ({-- icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/s/${sp.slug}`,++ icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/forum/${sp.slug}`,text: `${sp.description ? `${ld.clean(sp.description, 90)} · ` : ''}${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}`,})),-- more: { href: '/s', label: 'All forums' },++ more: { href: '/forum', label: 'All forums' },});}const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });function roadmapShelf(items) {if (!items || !items.length) return '';-- return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/s/roadmap', label: 'The whole roadmap' })}--${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/s/roadmap/t/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;++ return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/forum/roadmap', label: 'The whole roadmap' })}++${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/forum/roadmap/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;}function pulseShelf(items) {@@@ -465,7 +465,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>-- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}++ ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}</div>`;@@@ -61,7 +61,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin// The first page of each filter is worth indexing; deep cursors and empty pages are not.robots: after || !items.length ? 'noindex,follow' : 'index,follow',active: 'pulse',-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],body,});@@@ -78,7 +78,7 @@ function createSearchDocuments({ db }) {if (t.crosspost_of) facets.crosspost = true;const doc = {owner: 'community', type: 'thread', id: String(t.id), deleted: false, visibility: 'public',-- canonical_url: `${config.baseUrl}/s/${encodeURIComponent(t.space_slug)}/t/${encodeURIComponent(t.slug)}`,++ canonical_url: `${config.baseUrl}/forum/${encodeURIComponent(t.space_slug)}/${encodeURIComponent(t.slug)}`,title: clean(t.title, 500) || 'Thread',summary: clean(plain(opening && opening.body_markdown), 300) || `A thread in ${t.space_name} on OpenVibe.Community.`,body: posts.map((p) => clean(plain(p.body_markdown), 8000)).filter(Boolean).join('\n').slice(0, 40000),@@@ -113,7 +113,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',assert.strictEqual(page.attachments.enabled, true);r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });assert.strictEqual(r.status, 400, 'already on a post');-- const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;++ const html = (await t.get(`/forum/general/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));});@@@ -122,7 +122,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',fd.append('body', 'Two more');fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');-- const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });++ const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });assert.strictEqual(r.status, 303, r.text.slice(0, 300));const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();const reply = page.posts.find((p) => p.body_markdown === 'Two more');@@@ -132,7 +132,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',empty.append('body', '');empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');const before = objects.size;-- await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });++ await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');});@@@ -76,7 +76,7 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });assert.strictEqual(r.status, 201, r.text);// The no-JS form: the thread page again, the plain message in the reply form, the draft kept.-- r = await call(`/s/general/t/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });++ r = await call(`/forum/general/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });assert.strictEqual(r.status, 403);assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');assert.ok(r.text.includes('my <draft></textarea>'), 'the draft kept, escaped');@@@ -115,22 +115,22 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');});await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {-- let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });++ let r = await call('/forum/feedback?category=ideas', { cookie: alexJwt });assert.strictEqual(r.status, 200);const html = r.text;assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');-- r = await call('/s/roadmap', { cookie: alexJwt });++ r = await call('/forum/roadmap', { cookie: alexJwt });assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));assert.ok(r.text.includes('badge-status status-in_progress'));-- r = await call('/s/roadmap/new', { cookie: alexJwt });++ r = await call('/forum/roadmap/new', { cookie: alexJwt });assert.strictEqual(r.status, 403);-- r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });++ r = await call(`/forum/feedback/${req.slug}`, { cookie: modJwt });assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));-- r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });++ r = await call(`/forum/feedback/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });assert.strictEqual(r.status, 303);assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');-- r = await call('/s/feedback/new', { cookie: alexJwt });++ r = await call('/forum/feedback/new', { cookie: alexJwt });assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));});@@@ -125,9 +125,9 @@ function startStubChat() {chat.state.down = true;r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);-- const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });++ const f = await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' });assert.strictEqual(f.status, 303);-- assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);++ assert.match(f.headers.get('location'), /^\/forum\/showcase\?chat_error=.*#chat-room$/);const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);chat.state.down = false;@@@ -135,23 +135,23 @@ function startStubChat() {});await check('the space page links the room; its owner gets the forms; others only the link', async () => {-- let page = await t.get('/s/showcase');++ let page = await t.get('/forum/showcase');assert.strictEqual(page.status, 200);assert.match(page.text, /<section class="chat-room" id="chat-room"/);assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');-- page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');-- page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });-- assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));-- page = await t.get('/s/general');++ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'no forms for a visitor');++ page = await t.get('/forum/showcase', { cookies: [`ov_token=${samJwt}`] });++ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'nor for another person');++ page = await t.get('/forum/showcase', { cookies: [`ov_token=${alexJwt}`] });++ assert.ok(page.text.includes('action="/forum/showcase/chat-room"') && page.text.includes('action="/forum/showcase/chat-room/detach"'));++ page = await t.get('/forum/general');assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');-- page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });++ page = await t.get('/forum/general', { cookies: [`ov_token=${bossJwt}`] });assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');// Forum-style spaces show it too.await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });-- assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);-- assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');++ assert.match((await t.get('/forum/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);++ assert.strictEqual((await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');});await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {@@@ -164,8 +164,8 @@ function startStubChat() {// The owner puts theirs back and detaches it without JavaScript: both sides go.r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });assert.strictEqual(r.status, 201);-- const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});-- assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);++ const f = await form(alexJwt, '/forum/showcase/chat-room/detach', {});++ assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/forum/showcase#chat-room']);assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');r = await api('DELETE', '/showcase/chat-room', alexJwt);@@@ -17,7 +17,7 @@ const { check, done } = require('./helpers/app');(async () => {await check('robots.txt keeps the disallow set for every crawler and names the sitemap', () => {const txt = seo.robotsTxt({ sitemaps: ['https://openvibe.community/sitemap.xml'], disallow: discovery.ROBOTS_DISALLOW });-- assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']);++ assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']);// Shared 4.13 opens robots.txt with its content-signals note (a comment and Content-Signal); the rules follow.assert.ok(txt.replace(/^(?:#[^\n]*\n|Content-Signal:[^\n]*\n|\n)*/, '').startsWith('User-agent: *\nAllow: /\nDisallow: /api/\n'));assert.strictEqual(txt.split('Disallow: /*?sso=').length - 1, 2, 'the generic and the search/AI group both disallow');@@@ -29,10 +29,10 @@ const { check, done } = require('./helpers/app');const txt = seo.llmsTxt({ name: 'OpenVibe.Community', summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, markdown: true, base: 'https://openvibe.community', sections, optional });assert.ok(txt.includes('## Browse\n\n- [OpenVibe.Community](https://openvibe.community/index.md): pastes, forum and network activity'));assert.ok(txt.includes('- [Pastes](https://openvibe.community/pastes.md): public pastes, newest first'));-- assert.ok(txt.includes('- [Forum](https://openvibe.community/s) (HTML): open discussion forums and their threads'));++ assert.ok(txt.includes('- [Forum](https://openvibe.community/forum) (HTML): open discussion forums and their threads'));assert.ok(txt.includes('- [Pulse](https://openvibe.community/pulse) (HTML): network activity'));assert.ok(txt.includes('## Optional\n\n- [Sitemap](https://openvibe.community/sitemap.xml)'));-- assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/s/feed.xml)'));++ assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/forum/feed.xml)'));assert.ok(txt.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));assert.ok(txt.includes('labelled as AI-generated'));});@@@ -57,11 +57,11 @@ const { check, done } = require('./helpers/app');await check('thread feed items use the 2.5.0 feedXml shape and keep the thread link as guid', () => {const items = discovery.threadFeedItems([{ title: 'Hi <b>', space_slug: 'general', slug: 'hi-1', opening: 'Some **text**', created_at: '2026-09-15 10:00:00', last_activity_at: '2026-09-16 10:00:00' }]);assert.deepStrictEqual(Object.keys(items[0]), ['title', 'link', 'guid', 'description', 'content', 'author', 'published', 'updated']);-- assert.strictEqual(items[0].link, 'https://openvibe.community/s/general/t/hi-1');++ assert.strictEqual(items[0].link, 'https://openvibe.community/forum/general/hi-1');assert.strictEqual(items[0].published, '2026-09-15T10:00:00.000Z');assert.strictEqual(items[0].updated, '2026-09-16T10:00:00.000Z');-- const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/s', description: 'd', selfUrl: 'https://openvibe.community/s/feed.xml', items }, { format: 'rss' });-- assert.ok(xml.includes('<atom:link href="https://openvibe.community/s/feed.xml" rel="self" type="application/rss+xml"/>'));++ const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/forum', description: 'd', selfUrl: 'https://openvibe.community/forum/feed.xml', items }, { format: 'rss' });++ assert.ok(xml.includes('<atom:link href="https://openvibe.community/forum/feed.xml" rel="self" type="application/rss+xml"/>'));assert.ok(xml.includes('<title>Hi <b></title>'));assert.ok(xml.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));});@@@ -73,7 +73,7 @@ const { check, done } = require('./helpers/app');assert.strictEqual(out['@type'], 'DiscussionForumPosting');assert.deepStrictEqual(out.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });assert.deepStrictEqual(out.comment[0].author, { '@type': 'Organization', name: 'OpenVibe AI' });-- assert.strictEqual(out.comment[0].url, 'https://openvibe.community/s/general/t/hi-1#post-2');++ assert.strictEqual(out.comment[0].url, 'https://openvibe.community/forum/general/hi-1#post-2');assert.strictEqual(ld.isoDate('nope'), null);});@@@ -58,7 +58,7 @@ const comments = require('../server/comments/store');assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);evs.forEach(ok);assert.strictEqual(evs[0].visibility, 'public');-- assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));++ assert.ok(/\/forum\/general\//.test(evs[0].payload.url));assert.strictEqual(evs[2].visibility, 'internal');assert.strictEqual(evs[2].payload.visibility, 'members');assert.strictEqual(evs[2].payload.url, null);@@@ -489,7 +489,7 @@},{"method": "GET",-- "path": "/s",++ "path": "/forum","auth": "anon","accept": "html","status": 200,@@@ -556,7 +556,7 @@},{"method": "GET",-- "path": "/s/general",++ "path": "/forum/general","auth": "anon","accept": "html","status": 200,@@@ -633,7 +633,7 @@},{"method": "GET",-- "path": "/s/discuss?paste={@paste}",++ "path": "/forum/discuss?paste={@paste}","auth": "anon","accept": "html","status": 200,@@@ -644,40 +644,40 @@},{"method": "GET",-- "path": "/s/general/feed.xml",++ "path": "/forum/general/feed.xml","auth": "anon","accept": "html","status": 200,"kind": "other","from": [-- "page /s/general"++ "page /forum/general"]},{"method": "GET",-- "path": "/s/general/new",++ "path": "/forum/general/new","auth": "anon","accept": "html","status": 200,"kind": "html","from": [-- "page /s/general"++ "page /forum/general"]},{"method": "GET",-- "path": "/s/general/t/{@thread_slug}",++ "path": "/forum/general/{@thread_slug}","auth": "anon","accept": "html","status": 200,"kind": "html","from": [-- "page /s/general"++ "page /forum/general"]},{"method": "GET",-- "path": "/s/general/t/n-1-thread",++ "path": "/forum/general/n-1-thread","auth": "anon","accept": "html","status": 200,@@@ -688,14 +688,14 @@},{"method": "GET",-- "path": "/auth/login?next=%2Fs%2Fgeneral%2Ft%2F{@thread_slug}",++ "path": "/auth/login?next=%2Fforum%2Fgeneral%2Ft%2F{@thread_slug}","auth": "anon","accept": "html","status": 302,"kind": "html","location": "/oauth/authorize","from": [-- "page /s/general/t/n-1-thread"++ "page /forum/general/n-1-thread"]},{@@@ -734,7 +734,7 @@},{"method": "GET",-- "path": "/s/feed.xml",++ "path": "/forum/feed.xml","auth": "anon","accept": "html","status": 200,@@@ -944,7 +944,7 @@},{"method": "POST",-- "path": "/s/new-space",++ "path": "/forum/new","auth": "anon","status": 403,"kind": "html",@@@ -954,7 +954,7 @@},{"method": "POST",-- "path": "/s/general/chat-room",++ "path": "/forum/general/chat-room","auth": "anon","status": 303,"kind": "empty",@@@ -965,7 +965,7 @@},{"method": "POST",-- "path": "/s/general/chat-room/detach",++ "path": "/forum/general/chat-room/detach","auth": "anon","status": 303,"kind": "empty",@@@ -976,7 +976,7 @@},{"method": "POST",-- "path": "/s/general/new",++ "path": "/forum/general/new","auth": "anon","status": 303,"kind": "empty",@@@ -987,7 +987,7 @@},{"method": "POST",-- "path": "/s/general/settings",++ "path": "/forum/general/settings","auth": "anon","status": 403,"kind": "html",@@@ -997,7 +997,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/category",++ "path": "/forum/general/n-1-thread/category","auth": "anon","status": 403,"kind": "html",@@@ -1007,7 +1007,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/crosspost",++ "path": "/forum/general/n-1-thread/crosspost","auth": "anon","status": 303,"kind": "empty",@@@ -1018,7 +1018,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/delete",++ "path": "/forum/general/n-1-thread/delete","auth": "anon","status": 403,"kind": "html",@@@ -1028,7 +1028,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/members-only",++ "path": "/forum/general/n-1-thread/members-only","auth": "anon","status": 403,"kind": "html",@@@ -1038,7 +1038,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/react",++ "path": "/forum/general/n-1-thread/react","auth": "anon","status": 303,"kind": "empty",@@@ -1049,7 +1049,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/reply",++ "path": "/forum/general/n-1-thread/reply","auth": "anon","status": 303,"kind": "empty",@@@ -1061,7 +1061,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/state",++ "path": "/forum/general/n-1-thread/state","auth": "anon","status": 403,"kind": "html",@@@ -1071,7 +1071,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/status",++ "path": "/forum/general/n-1-thread/status","auth": "anon","status": 403,"kind": "html",@@@ -1081,7 +1081,7 @@},{"method": "POST",-- "path": "/s/general/t/n-1-thread/vote",++ "path": "/forum/general/n-1-thread/vote","auth": "anon","status": 400,"kind": "html",@@@ -1,35 +1,63 @@'use strict';/**-- * The forum is Community's again (2026-10-08 owner decision): /s and its nested paths serve the-- * forum here. OpenVibe.Space, which briefly held the forum, now hosts code and "spaces" and-- * redirects its forum paths to us; nothing here redirects to Space.++ * The forum is Community's (2026-10-08 owner decision) and moved from /s to /forum (2026-11-07):++ * /forum and its nested paths serve it, and every old /s address answers a permanent redirect++ * (301 for reads, 308 for form posts) with the query string kept. Nothing here redirects to Space.*/const assert = require('assert');const { boot, check, done } = require('./helpers/app');(async () => {const t = await boot();-- await check('GET /s and nested paths serve the forum here, with no redirect to Space', async () => {-- for (const path of ['/s', '/s/general', '/s/general?sort=new']) {++ await check('GET /forum and nested paths serve the forum here, with no redirect to Space', async () => {++ for (const path of ['/forum', '/forum/general', '/forum/general?sort=new']) {const r = await t.get(path);assert.strictEqual(r.status, 200, path);assert.strictEqual(r.headers.get('location'), null, path);}-- const board = await t.get('/s');++ const board = await t.get('/forum');assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');});-- await check('the boards are called the Forum (openvibe.space is a different product); /forum answers with a 301 to /s', async () => {-- const board = await t.get('/s');++ await check('the boards are called the Forum; the nav and canonical use /forum', async () => {++ const board = await t.get('/forum');assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');++ assert.ok(board.text.includes('<link rel="canonical" href="https://openvibe.community/forum">'), 'canonical is /forum');const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);-- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').label, 'Forum', 'the nav says Forum');++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').label, 'Forum', 'the nav says Forum');assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');-- for (const [from, to] of [['/forum', '/s'], ['/forum/', '/s'], ['/forum?x=1', '/s?x=1'], ['/forum/general?sort=new', '/s/general?sort=new'], ['/forum/general/t/hello', '/s/general/t/hello']]) {++ });++ await check('every old /s address answers a 301 to its /forum address, query kept', async () => {++ const map = [++ ['/s', '/forum'],++ ['/s/', '/forum'],++ ['/s?x=1', '/forum?x=1'],++ ['/s/general', '/forum/general'],++ ['/s/general?sort=new', '/forum/general?sort=new'],++ ['/s/general/t/hello', '/forum/general/hello'],++ ['/s/general/t/hello?page=2', '/forum/general/hello?page=2'],++ ['/s/general/t/hello/feed.xml', '/forum/general/hello/feed.xml'],++ ['/s/general/new', '/forum/general/new'],++ ['/s/general/log', '/forum/general/log'],++ ['/s/general/feed.xml', '/forum/general/feed.xml'],++ ['/s/feed.xml', '/forum/feed.xml'],++ ['/s/new-space', '/forum/new'],++ ['/s/discuss', '/forum/discuss'],++ ];++ for (const [from, to] of map) {const r = await t.get(from, { redirect: 'manual' });assert.strictEqual(r.status, 301, from);assert.strictEqual(r.headers.get('location'), to, from);}const nope = await t.get('/forums', { redirect: 'manual' });-- assert.notStrictEqual(nope.status, 301, '/forums is not /forum');++ assert.notStrictEqual(nope.status, 301, '/forums is not the forum');++ });++ await check('an old form post answers a 308 to the /forum address, method and body kept', async () => {++ const r = await t.get('/s/general/t/hello/reply', {++ method: 'POST', redirect: 'manual',++ body: new URLSearchParams({ body: 'old tab' }).toString(),++ headers: { 'content-type': 'application/x-www-form-urlencoded' },++ });++ assert.strictEqual(r.status, 308);++ assert.strictEqual(r.headers.get('location'), '/forum/general/hello/reply');});await t.close();done();@@@ -1,7 +1,7 @@'use strict';/**-- * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug-- * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,++ * Forum pages without JavaScript: /forum, /forum/:space (sort + pagination links), /forum/:space/:slug++ * (posts, reply/vote/moderation forms), /forum/:space/new — plain form posts, SEO (canonical, OG,* JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.*/const assert = require('assert');@@@ -25,39 +25,39 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringconst adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });-- await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {-- const r = await t.get('/s');++ await check('/forum lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {++ const r = await t.get('/forum');assert.strictEqual(r.status, 200);has(r.text, '<title>Forum · OpenVibe.Community</title>');-- has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');++ has(r.text, '<link rel="canonical" href="https://openvibe.community/forum">');has(r.text, '<meta name="robots" content="index,follow">');-- has(r.text, 'href="/s/feed.xml"');-- for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);++ has(r.text, 'href="/forum/feed.xml"');++ for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/forum/${s}"`);assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);-- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').active, true);});await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {-- const page = await t.get('/s/general/new');++ const page = await t.get('/forum/general/new');assert.strictEqual(page.status, 200);-- has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');++ has(page.text, '/auth/login?next=%2Fforum%2Fgeneral%2Fnew');has(page.text, '<meta name="robots" content="noindex,follow">');-- const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));++ const anon = await t.get('/forum/general/new', form({ title: 'Anon thread', body: 'x' }));assert.strictEqual(anon.status, 303);-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);-- const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fforum%2Fgeneral%2Fnew$/);++ const bad = await t.get('/forum/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));assert.strictEqual(bad.status, 400);has(bad.text, 'Titles are 3 to 200 characters');has(bad.text, '<img src=x onerror=alert(1)></textarea>');-- const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));++ const ok = await t.get('/forum/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));assert.strictEqual(ok.status, 303, ok.text);-- assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');-- const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));++ assert.strictEqual(ok.headers.get('location'), '/forum/general/how-do-i-b-embed-b-comments');++ const foreign = await t.get('/forum/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');});-- const url = '/s/general/t/how-do-i-b-embed-b-comments';++ const url = '/forum/general/how-do-i-b-embed-b-comments';await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {const r = await t.get(url);@@@ -117,57 +117,57 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringassert.ok(!r.text.includes(`action="${url}/reply"`));has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');-- const list = await t.get('/s/general');++ const list = await t.get('/forum/general');has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');const admin = await t.get(url, as(adminJwt));has(admin.text, 'Unpin');has(admin.text, 'Unlock');-- const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));++ const mine = await t.get('/forum/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));const mineUrl = mine.headers.get('location');assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));assert.strictEqual(del.status, 303);-- assert.strictEqual(del.headers.get('location'), '/s/general');++ assert.strictEqual(del.headers.get('location'), '/forum/general');assert.strictEqual((await t.get(mineUrl)).status, 404);});await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {const space = await forumStore.getSpace(t.db, 'showcase');for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });-- const p1 = await t.get('/s/showcase?sort=new');++ const p1 = await t.get('/forum/showcase?sort=new');assert.strictEqual(p1.status, 200);-- has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');-- has(p1.text, 'href="/s/showcase"');-- has(p1.text, 'href="/s/showcase?sort=top"');-- has(p1.text, 'rel="next" href="/s/showcase?sort=new&page=2"');++ has(p1.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new">');++ has(p1.text, 'href="/forum/showcase"');++ has(p1.text, 'href="/forum/showcase?sort=top"');++ has(p1.text, 'rel="next" href="/forum/showcase?sort=new&page=2"');has(p1.text, 'Page 1 of 2');assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);-- const p2 = await t.get('/s/showcase?sort=new&page=2');-- has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');-- has(p2.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new&page=2">');++ const p2 = await t.get('/forum/showcase?sort=new&page=2');++ has(p2.text, 'rel="prev" href="/forum/showcase?sort=new"');++ has(p2.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new&page=2">');has(p2.text, 'Showcase item 1<');-- assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);-- assert.strictEqual((await t.get('/s/nowhere')).status, 404);++ assert.strictEqual((await t.get('/forum/showcase?page=9')).status, 404);++ assert.strictEqual((await t.get('/forum/nowhere')).status, 404);});await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();const ins = await forumStore.getSpace(t.db, 'insiders');await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });-- const anon = await t.get('/s/insiders');++ const anon = await t.get('/forum/insiders');assert.strictEqual(anon.status, 303);-- assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');-- const member = await t.get('/s/insiders', as(samJwt));++ assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fforum%2Finsiders');++ const member = await t.get('/forum/insiders', as(samJwt));assert.strictEqual(member.status, 200);has(member.text, '<meta name="robots" content="noindex,nofollow">');-- const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));++ const th = await t.get('/forum/insiders/members-only-chat', as(samJwt));has(th.text, '<meta name="robots" content="noindex,nofollow">');-- assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);-- assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);-- const feed = await t.get('/s/feed.xml');++ assert.strictEqual((await t.get('/forum/mods', as(samJwt))).status, 404);++ assert.strictEqual((await t.get('/forum/mods', as(adminJwt))).status, 200);++ const feed = await t.get('/forum/feed.xml');assert.ok(!feed.text.includes('Members only chat'));-- assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);++ assert.strictEqual((await t.get('/forum/insiders/feed.xml')).status, 404);require('../server/discovery').resetCaches();const map = await t.get('/sitemap.xml');assert.ok(!map.text.includes('members-only-chat'));@@@ -176,7 +176,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringawait check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {const space = await forumStore.getSpace(t.db, 'feedback');await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });-- const r = await t.get('/s/feedback/t/weekly-summary');++ const r = await t.get('/forum/feedback/weekly-summary');has(r.text, 'OpenVibe AI');has(r.text, '<span class="badge badge-ai"');assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });@@@ -185,17 +185,17 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringawait check('sitemap, feeds and robots.txt include the forum', async () => {require('../server/discovery').resetCaches();const map = await t.get('/sitemap.xml');-- for (const loc of ['/s', '/s/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);-- const feed = await t.get('/s/feed.xml');++ for (const loc of ['/forum', '/forum/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);++ const feed = await t.get('/forum/feed.xml');assert.strictEqual(feed.status, 200);has(feed.text, '<rss version="2.0"');has(feed.text, `<link>https://openvibe.community${url}</link>`);has(feed.text, '<title>How do I <b>embed</b> comments?</title>');-- const spaceFeed = await t.get('/s/showcase/feed.xml');++ const spaceFeed = await t.get('/forum/showcase/feed.xml');has(spaceFeed.text, 'Showcase item 27');assert.ok(!spaceFeed.text.includes('embed'));const robots = await t.get('/robots.txt');-- has(robots.text, 'Disallow: /s/*/new');++ has(robots.text, 'Disallow: /forum/*/new');});await t.close();@@@ -42,9 +42,9 @@ const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters')await check('the page renders the guide with its links (no raw HTML)', async () => {const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');-- const r = await call(`/s/feedback/t/${th.slug}`);++ const r = await call(`/forum/feedback/${th.slug}`);assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('href="/s/roadmap"'), 'same-site link');++ assert.ok(r.text.includes('href="/forum/roadmap"'), 'same-site link');assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));});@@@ -37,15 +37,15 @@ const { boot, check, done } = require('./helpers/app');r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });assert.strictEqual(r.status, 201);assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');-- await call('/s/general/t/welcome-thread', { cookie: samJwt });-- await call('/s/general/t/welcome-thread', { cookie: samJwt });-- await call('/s/general/t/welcome-thread', { cookie: alexJwt });++ await call('/forum/general/welcome-thread', { cookie: samJwt });++ await call('/forum/general/welcome-thread', { cookie: samJwt });++ await call('/forum/general/welcome-thread', { cookie: alexJwt });const list = (await call('/api/v1/spaces/general/threads')).json();assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');const row = list.threads[0];assert.strictEqual(row.views, 2, 'one view per viewer per half hour');assert.strictEqual(row.last_post.author.username, 'sam');-- const page = (await call('/s/general')).text;++ const page = (await call('/forum/general')).text;assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');assert.ok(!page.includes('class="thread-score"'), 'no score column');});@@@ -53,7 +53,7 @@ const { boot, check, done } = require('./helpers/app');await check('the postbit avatar asks Media for the width it draws (the 64 px picture: 160)', async () => {const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';await t.db.prepare('UPDATE subject_projection SET avatar_url = ? WHERE subject_id = ?').run(obj, alex.subject_id);-- const html = (await call('/s/general/t/welcome-thread')).text;++ const html = (await call('/forum/general/welcome-thread')).text;assert.ok(html.includes(`<img class="postbit-avatar" src="${obj}?w=160"`), 'the 64 px postbit avatar asks for 160');await t.db.prepare('UPDATE subject_projection SET avatar_url = NULL WHERE subject_id = ?').run(alex.subject_id);});@@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');const general = r.groups[0].spaces.find((s) => s.slug === 'general');assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');-- const html = (await call('/s')).text;++ const html = (await call('/forum')).text;assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Classic') && html.includes('badge-style">Feed'));});@@@ -85,9 +85,9 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;++ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));-- r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });++ r = await call('/forum/general/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');@@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');await check('quote: ?quote= fills the reply box with the quoted post', async () => {const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];-- const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;++ const html = (await call(`/forum/general/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');});@@@ -108,7 +108,7 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);-- const html = (await call('/s/general/t/welcome-thread')).text;++ const html = (await call('/forum/general/welcome-thread')).text;assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');@@@ -117,10 +117,10 @@ const { boot, check, done } = require('./helpers/app');await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {const paste = (await call('/p/snip1')).text;-- assert.ok(paste.includes('/s/discuss?paste=snip1'));-- const picker = (await call('/s/discuss?paste=snip1', { cookie: alexJwt })).text;-- assert.ok(picker.includes('/s/general/new?paste=snip1') && !picker.includes('/s/roadmap/new'));-- const form = (await call('/s/general/new?paste=snip1', { cookie: alexJwt })).text;++ assert.ok(paste.includes('/forum/discuss?paste=snip1'));++ const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;++ assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));++ const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));});@@@ -130,13 +130,13 @@ const { boot, check, done } = require('./helpers/app');const cp = r.json().thread;assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();-- assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');++ assert.strictEqual(there.crosspost.from.url, '/forum/general/welcome-thread');assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;++ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));});@@@ -145,20 +145,22 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.status, 403);r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);-- assert.ok((await call('/s/showcase')).text.includes('topic-table'));-- r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });++ assert.ok((await call('/forum/showcase')).text.includes('topic-table'));++ r = await call('/forum/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });assert.strictEqual(r.status, 303);-- assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));-- assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);-- r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });-- assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');++ assert.ok((await call('/forum/showcase')).text.includes('class="thread-list"'));++ assert.strictEqual((await call('/forum/new', { cookie: alexJwt })).status, 403);++ r = await call('/forum/new', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });++ assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/forum/music');const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });assert.strictEqual(r.status, 200, r.text);const idx = (await call('/api/v1/spaces')).json();assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');-- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');++ for (const slug of ['new', 'discuss', 'search', 'about', 'new-space', 'feed.xml']) {++ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug, name: 'Nope' } })).status, 400, `${slug} is a reserved address`);++ }});await done(t);@@@ -47,7 +47,7 @@ const forumStore = require('../server/forum/store');first = r.json().thread;assert.strictEqual(first.slug, 'hello-world');assert.strictEqual(first.title, 'Hello, World!');-- assert.strictEqual(first.url, '/s/general/t/hello-world');++ assert.strictEqual(first.url, '/forum/general/hello-world');assert.strictEqual(first.author.subject, alex.subject_id);const op = r.json().post;assert.strictEqual(op.is_opening, true);@@@ -60,6 +60,17 @@ const forumStore = require('../server/forum/store');assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');});++ await check('a title that makes one of the forum\'s own route words takes -2, so /forum/<space>/<slug> stays a thread', async () => {++ for (const [title, slug] of [['New', 'new-2'], ['Log', 'log-2'], ['Settings', 'settings-2'], ['Members', 'members-2'], ['Chat room', 'chat-room-2'], ['Discuss', 'discuss-2']]) {++ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title, body: 'reserved word' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().thread.slug, slug, title);++ assert.strictEqual(r.json().thread.url, `/forum/general/${slug}`);++ }++ const page = await t.get('/forum/general/new-2');++ assert.strictEqual(page.status, 200, 'the thread renders at its new address');++ });++await check('create thread: validation and who may post', async () => {assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');@@@ -86,7 +97,7 @@ const forumStore = require('../server/forum/store');await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });assert.strictEqual(r1.status, 201, r1.text);-- assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);++ assert.match(r1.json().url, /^\/forum\/general\/hello-world#post-\d+$/);await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();assert.strictEqual(th.thread.reply_count, 2);@@@ -20,7 +20,7 @@ const { check, done } = require('./helpers/app');assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');-- assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');++ assert.strictEqual(md('[home](/forum/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/forum/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');});@@@ -183,11 +183,11 @@ function startVip() {assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);const sitemap = (await call('/sitemap.xml')).text;-- assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');-- const feed = (await call('/s/feed.xml')).text;++ assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');++ const feed = (await call('/forum/feed.xml')).text;assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));-- assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);-- const general = (await call('/s/general/feed.xml')).text;++ assert.strictEqual((await call('/forum/showcase/feed.xml')).status, 404);++ const general = (await call('/forum/general/feed.xml')).text;assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));// A thread gated after the fact leaves Pulse as well.const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;@@@ -200,34 +200,34 @@ function startVip() {});await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {-- const anon = await call(`/s/general/t/${secret.slug}`);++ const anon = await call(`/forum/general/${secret.slug}`);assert.strictEqual(anon.status, 403);assert.ok(anon.text.includes('https://openvibe.vip/cora'));assert.ok(anon.text.includes('Backstage with Cora'));assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));assert.ok(/noindex/.test(anon.text));assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');-- const space = await call('/s/showcase', { who: stan });++ const space = await call('/forum/showcase', { who: stan });assert.strictEqual(space.status, 403);assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));-- assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');-- const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });++ assert.strictEqual((await call('/forum/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');++ const reply = await form(`/forum/general/${secret.slug}/reply`, stan, { body: 'sneaky' });assert.strictEqual(reply.status, 403);-- const member = await call(`/s/general/t/${secret.slug}`, { who: mia });++ const member = await call(`/forum/general/${secret.slug}`, { who: mia });assert.strictEqual(member.status, 200);assert.ok(member.text.includes('secret backstage body'));assert.ok(/noindex/.test(member.text));assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');-- const listing = await call('/s/general', { who: stan });++ const listing = await call('/forum/general', { who: stan });assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));assert.ok(!listing.text.includes('secret backstage body'));// The author's no-JS toggle.const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;-- const page = await call(`/s/general/t/${own.slug}`, { who: cora });-- assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));-- const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });++ const page = await call(`/forum/general/${own.slug}`, { who: cora });++ assert.ok(page.text.includes(`/forum/general/${own.slug}/members-only`));++ const on = await form(`/forum/general/${own.slug}/members-only`, cora, { on: '1' });assert.strictEqual(on.status, 303);-- assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);++ assert.strictEqual((await call(`/forum/general/${own.slug}`, { who: stan })).status, 403);});await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {@@@ -255,7 +255,7 @@ function startVip() {const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });assert.strictEqual(r.json().space.members_only, null);assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);-- assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));++ assert.ok((await call('/sitemap.xml')).text.includes('/forum/showcase'));});await t.close();@@@ -47,9 +47,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fiassert.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)} ` });++ r = await form(`/forum/general/${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' });++ r = await form(`/forum/general/${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' } });@@@ -80,7 +80,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to filet 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' });++ r = await form(`/forum/general/${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');@@@ -90,7 +90,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi});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');++ const page = await t.get('/forum/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');@@@ -103,12 +103,12 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fiassert.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');++ const feedbackLog = await t.get('/forum/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"');++ const space = await t.get('/forum/general');++ has(space.text, 'href="/forum/general/log"');has(space.text, 'Moderation log');has(space.text, '<link rel="alternate" type="application/rss+xml"');});@@@ -116,10 +116,10 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fiawait 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">`);++ const page = await t.get(`/forum/feedback/${feedback.slug}`);++ has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/forum/feedback/${feedback.slug}/feed.xml">`);has(page.text, 'Follow this thread by RSS');-- const feed = await t.get(`/s/feedback/t/${feedback.slug}/feed.xml`);++ const feed = await t.get(`/forum/feedback/${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');@@@ -129,9 +129,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to ficonst 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');++ const space = await t.get('/forum/feedback');has(space.text, '<link rel="alternate" type="application/rss+xml"');-- has(space.text, '/s/feedback/feed.xml');++ has(space.text, '/forum/feedback/feed.xml');});await check('a private space\'s log is not public; its members and staff read it', async () => {@@@ -140,11 +140,11 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fiassert.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');++ const anon = await t.get('/forum/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 t.get('/forum/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`;++ const feedUrl = `/forum/insiders/${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);@@@ -157,7 +157,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to ficonst 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');++ const page = await t.get('/forum/general/log');assert.strictEqual(page.status, 200);has(page.text, 'a removed thread');has(page.text, 'Removed by a moderator');@@@ -171,13 +171,13 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to ficonst 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}`] });++ const page = await t.get(`/forum/general/${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, `<form class="mod-form" method="post" action="/forum/general/${thread2.slug}/state">`);++ has(page.text, `action="/forum/general/${thread2.slug}/post/${reply.json().post.id}/delete"`);has(page.text, 'Delete post');});@@@ -36,7 +36,7 @@ module.exports = {forms: true,keep: () => true,/** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */-- crawl: ['/', '/pastes', '/p/{@paste}', '/s/general', '/s/general/t/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],++ crawl: ['/', '/pastes', '/p/{@paste}', '/forum/general', '/forum/general/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],origins: ['https://openvibe.community'],/** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */samples: [@@@ -62,10 +62,10 @@ const { boot, check, done } = require('./helpers/app');await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });const it = await bySource('thread', thread.id);-- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);++ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/forum/general/pulse-thread', alex.subject_id, 'user']);const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');-- assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);++ assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/forum/general/pulse-thread#post-${post.id}`);await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);@@@ -314,7 +314,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });const locked = nextId();await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');-- const page = await call(`/s/general/t/${th.json().thread.slug}`);++ const page = await call(`/forum/general/${th.json().thread.slug}`);assert.strictEqual(page.status, 200);assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');await t.db.prepare('UPDATE threads SET locked = 1').run();@@@ -66,11 +66,11 @@ const { sqlTime } = require('../server/http/v1');assert.strictEqual(msg.method, 'POST');assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });-- assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/s\/general\/t\/relay-me-please>$/);++ assert.match(msg.body.content, /^New thread in \*\*forum\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/forum\/general\/relay-me-please>$/);assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');-- assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');++ assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/forum/general/relay-me-please');assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');-- assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');++ assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · forum/general');const d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);const [row] = await mapOf('thread', th.id);@@@ -234,7 +234,7 @@ const { sqlTime } = require('../server/http/v1');assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);const r = hook.hits[1];assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');-- assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);++ assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*forum\/general\*\*: <https:\/\/openvibe\.community\/forum\/general\/replies-go-too#post-\d+>$/);assert.strictEqual(r.body.embeds[0].description, 'First reply');assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });const [row] = await mapOf('post', post.id);@@@ -428,7 +428,7 @@ const { sqlTime } = require('../server/http/v1');assert.strictEqual(failed.json().deliveries.length, 1);const dl = failed.json().deliveries[0];assert.match(dl.last_error, /400: Invalid Form Body/);-- assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/s/general/t/this-one-fails', 'create', 'direct']);++ assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/forum/general/this-one-fails', 'create', 'direct']);const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);const st = await call('/api/v1/relay/status', { cookie: adminJwt });@@@ -38,7 +38,7 @@ const { createSearchDocuments } = require('../server/search/documents');const ev = (await index())[0]; ok(ev);assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');-- assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);++ assert.strictEqual(ev.payload.canonical_url, `${base}/forum/general/${thread.slug}`);assert.strictEqual(ev.payload.summary, 'First post with a link');assert.ok(ev.payload.body.includes('a reply body'));assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);@@@ -19,7 +19,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/queryif (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');return {results: [-- { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/s/roadmap/t/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },++ { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/forum/roadmap/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },{ owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },],next_cursor: 'abc_123',@@@ -32,7 +32,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/queryconst r = await t.get('/search?q=forum&type=thread');assert.strictEqual(r.status, 200);assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });-- assert.ok(r.text.includes('href="/s/roadmap/t/forum-boards"'));++ assert.ok(r.text.includes('href="/forum/roadmap/forum-boards"'));assert.ok(r.text.includes('Forum <b>boards</b>'), 'the title is escaped');assert.ok(r.text.includes('classic <mark>forum</mark> boards <img'), 'only <mark> survives');assert.ok(!r.text.includes('<img src=x'));@@@ -169,7 +169,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];await refused('gate it to sam\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: true } });await refused('gate it to alex\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: alex.subject_id } });for (const [suffix, form] of [['delete', {}], ['state', { pinned: '1', locked: '1' }], ['members-only', { on: '1' }], ['category', { category: '' }], ['status', { status: 'done' }]]) {-- await refused(`form ${suffix}`, 'POST', `/s/general/t/${thread.thread.slug}/${suffix}`, ['sam'], { form });++ await refused(`form ${suffix}`, 'POST', `/forum/general/${thread.thread.slug}/${suffix}`, ['sam'], { form });}// The right thread slug under the wrong space: not found, even for its author.await refused('delete it under another space', 'DELETE', `/api/v1/spaces/feedback/threads/${thread.thread.slug}`, ['alex', 'sam']);@@@ -203,11 +203,11 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];await check('spaces: the owner of one space cannot manage another\'s chat room; sam cannot change space settings, categories, gating or the board', async () => {await refused('cora attaches a room to alex\'s space', 'PUT', '/api/v1/spaces/general/chat-room', ['cora', 'sam'], { json: { room: 'cora-room' } });await refused('cora detaches alex\'s room', 'DELETE', '/api/v1/spaces/general/chat-room', ['cora', 'sam']);-- await refused('cora detaches it by form', 'POST', '/s/general/chat-room/detach', ['cora'], { form: {} });-- await refused('cora attaches by form', 'POST', '/s/general/chat-room', ['cora'], { form: { room: 'cora-room' } });++ await refused('cora detaches it by form', 'POST', '/forum/general/chat-room/detach', ['cora'], { form: {} });++ await refused('cora attaches by form', 'POST', '/forum/general/chat-room', ['cora'], { form: { room: 'cora-room' } });assert.ok(!chatCalls.some(([, room]) => room === 'cora-room'), 'Chat was never asked for cora\'s room on alex\'s space');await refused('space settings', 'PUT', '/api/v1/spaces/general/settings', [...SAMS, 'cora'], { json: { name: 'Taken' } });-- await refused('space settings by form', 'POST', '/s/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });++ await refused('space settings by form', 'POST', '/forum/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });await refused('a category', 'PUT', '/api/v1/spaces/general/categories/taken', [...SAMS, 'alex'], { json: { name: 'Taken' } });await refused('gate the space', 'PUT', '/api/v1/spaces/general/members-only', [...SAMS, 'alex'], { json: { owner: sam.subject_id } });await refused('a board group', 'PUT', '/api/v1/space-groups/taken', SAMS, { json: { name: 'Taken' } });@@@ -220,9 +220,9 @@ function formsOf(value) {}}for (const [who, p] of Object.entries(bad)) {-- for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/s', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));++ for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/forum', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));}-- for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/s/general/t/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {++ for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/forum/general/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {for (const [who, p] of Object.entries(personas)) inspect(`GET ${path} as ${who}`, await send('GET', path, p));}// Sign-in failures: a bad code, a missing state, a refused refresh token, a refused FedCM assertion.@@@ -115,7 +115,7 @@ function inventory() {const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });assert.strictEqual(th.status, 201, th.text);const slug = JSON.parse(th.text).thread.slug;-- assert.strictEqual((await send('GET', `/s/general/t/${slug}`)).status, 200);++ assert.strictEqual((await send('GET', `/forum/general/${slug}`)).status, 200);const p = await send('POST', '/api/pastes', { json: { title: urls[5], content: `curl ${urls[5]}\n` } });assert.strictEqual(p.status, 201, p.text);assert.strictEqual((await send('GET', `/p/${JSON.parse(p.text).slug}`)).status, 200);@@@ -130,7 +130,7 @@ function inventory() {await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');-- for (const page of ['/', '/pulse', '/api/v1/pulse', '/s/general', '/feed.xml', '/s/feed.xml', '/sitemap.xml']) await send('GET', page);++ for (const page of ['/', '/pulse', '/api/v1/pulse', '/forum/general', '/feed.xml', '/forum/feed.xml', '/sitemap.xml']) await send('GET', page);await new Promise((r) => setTimeout(r, 200));assert.deepStrictEqual(hits, [], 'Community fetched a URL it was handed');});@@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.status, 200);assert.ok(r.headers.get('content-type').startsWith('text/plain'));assert.ok(r.text.includes('User-agent: *\nAllow: /'));-- for (const d of ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);++ for (const d of ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));});@@@ -92,7 +92,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(page.viewer.can_moderate, false);assert.strictEqual(page.viewer.can_delete, false);assert.strictEqual(page.posts[0].can_edit, false);-- assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');++ assert.strictEqual((await form(`/forum/showcase/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);});@@@ -113,24 +113,24 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);const before = (await call('/api/v1/spaces/general')).json().space.group;-- r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });++ r = await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });assert.strictEqual(r.status, 303, r.text);assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');});await check('(e) a space moderator cannot create a space or change the board index', async () => {assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);-- assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);++ assert.strictEqual((await form('/forum/new', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);});await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {-- let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });++ let r = await form('/forum/general/moderators', alex, { subject: sam.subject_id });assert.strictEqual(r.status, 303, r.text);assert.ok((await mods('general')).includes(sam.subject_id));-- assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);-- r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });++ assert.strictEqual((await form('/forum/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);++ r = await form('/forum/general/moderators/remove', carol, { subject: sam.subject_id });assert.strictEqual(r.status, 303, r.text);assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });@@@ -139,31 +139,31 @@ const { boot, check, done } = require('./helpers/app');});await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {-- let r = await form('/s/general/moderators', alex, { subject: '@Sam' });++ let r = await form('/forum/general/moderators', alex, { subject: '@Sam' });assert.strictEqual(r.status, 303, r.text);-- assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);++ assert.match(String(r.headers.get('location')), /^\/forum\/general#moderators$/);assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');-- r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });++ r = await form('/forum/general/moderators', alex, { subject: '@nobody-here' });assert.strictEqual(r.status, 303, r.text);-- assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');-- assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);++ assert.match(String(r.headers.get('location')), /^\/forum\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');++ assert.match(String((await form('/forum/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');for (const style of ['forum', 'feed']) {-- assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);-- const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });++ assert.strictEqual((await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);++ const page = await call(`/forum/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });assert.strictEqual(page.status, 200, page.text);assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);-- assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);++ assert.ok(page.text.includes('action="/forum/general/moderators"'), `${style}: the add form`);assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);for (const who of [bob, undefined]) {-- const other = await call('/s/general', { who });++ const other = await call('/forum/general', { who });assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);}}-- r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });++ r = await form('/forum/general/moderators/remove', alex, { subject: sam.subject_id });assert.strictEqual(r.status, 303, r.text);assert.deepStrictEqual(await mods('general'), [alex.subject_id]);});@@@ -59,7 +59,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(cfg.navbar.service, 'community');// The bar (2026-10-28): the forum first, every place with an icon (the phone app bar); search is the magnifier,// which offers this site's own search page first.-- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/s', '/pastes', '/pulse', '/new']);++ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/forum', '/pastes', '/pulse', '/new']);assert.ok(cfg.navbar.links.every((l) => l.icon) && cfg.navbar.appBar === true && cfg.navbar.search && cfg.navbar.search.page === '/search');assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');assert.strictEqual(cfg.navbar.history.type, 'page');@@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');// The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose// FAQPage JSON-LD is exactly what the page shows.const forum = (/<section class="sc-sec sc-apps-sec" id="forum"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];-- assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/s/general">') && forum.includes('href="/s">All forums'), 'the forum\'s places as a launcher');++ assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/forum/general">') && forum.includes('href="/forum">All forums'), 'the forum\'s places as a launcher');assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));@@@ -44,22 +44,22 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toStlet boardUrl;let feedUrl;await check('the new-thread form has the picker, and a thread is posted with its labels', async () => {-- const page = await t.get('/s/general/new', as(alexJwt));++ const page = await t.get('/forum/general/new', as(alexJwt));assert.ok(page.text.includes('<legend>Content labels</legend>') && page.text.includes('name="labels_form"'), 'the picker');assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(page.text), 'its sheet');-- let r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));++ let r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));assert.strictEqual(r.status, 303, r.text.slice(0, 300));boardUrl = new URL(r.headers.get('location'), 'https://x').pathname;-- r = await t.get('/s/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));++ r = await t.get('/forum/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));assert.strictEqual(r.status, 303, r.text.slice(0, 300));feedUrl = new URL(r.headers.get('location'), 'https://x').pathname;-- r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));++ r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));assert.strictEqual(r.status, 400, 'not a label');assert.ok(r.text.includes('Labels are from this list'), r.text.slice(0, 200));});await check('the board: the row is labelled, its title cell is what a blur covers, the chips under the title', async () => {-- const r = await t.get('/s/general');++ const r = await t.get('/forum/general');assert.ok(r.text.includes(` data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}">`), 'the row');assert.ok(r.text.includes('<td class="topic-main" data-ov-labels-media>'), 'the cover goes on the title cell');assert.ok(r.text.includes('<li class="ovk-label" data-label="spoilers">Spoilers</li><li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), 'in words');@@@ -77,7 +77,7 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toStassert.ok(r.text.includes('css/labels.css'), 'with its sheet');r = await t.get(feedUrl);assert.ok(r.text.includes(`<div class="posts" data-ov-labels="flashing" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the feed style too');-- r = await t.get('/s/feedback');++ r = await t.get('/forum/feedback');assert.ok(/<li class="thread-row[^"]*" data-ov-labels="flashing" data-ov-labels-owner="/.test(r.text), 'and its list');});@@@ -30,7 +30,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString});await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {-- const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });++ const ok = await t.get('/forum/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });assert.strictEqual(ok.status, 303, ok.text);const r = await t.get(ok.headers.get('location'));assert.strictEqual(r.status, 200);
@@ -2,6 +2,18 @@
@@ -2,6 +2,18 @@
## Unreleased
## Unreleased
+- **The forum moved from `/s` to `/forum`** (design 2026-11-07, section 13 "URLs people already know"). The board
+ is `/forum`, a space `/forum/<space>`, and a thread `/forum/<space>/<thread>` (the `/t/` segment is gone);
+ `/forum/new` and `/forum/discuss` replace `/s/new-space` and `/s/discuss`. Every old `/s` address answers a
+ permanent redirect with the query kept — 301 for reads, 308 for form posts, so `/s/<space>/t/<slug>` becomes
+ `/forum/<space>/<slug>` without losing a method or body.
+ - A thread slug may never be one of a space's own sub-route words (`new`, `log`, `settings`, `feed.xml`,
+ `moderators`, `chat-room`, `members`, `about`, `search`, `rules`, `wiki`, `edit`, `discuss`): a title that would
+ make one takes `-2`. A space may not take `new`, `discuss`, `feed.xml`, `search` or `about`.
+ - Sitemap, robots (`/forum/*/new`), IndexNow pings, feeds, `llms.txt`, canonical links and BreadcrumbList
+ (`Forum › Space › Thread`), Search documents, Pulse items, `community.thread/post.created` URLs and the Discord
+ relay all use `/forum`.
+
- **Locked pastes** (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). A new text paste can be
- **Locked pastes** (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). A new text paste can be
locked: the browser seals it, Community stores the envelope and never sees the content key.
locked: the browser seals it, Community stores the envelope and never sees the content key.
- `POST /api/pastes` takes `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`; a
- `POST /api/pastes` takes `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`; a
@@ -143,15 +143,15 @@ Pages (server-rendered HTML):
@@ -143,15 +143,15 @@ Pages (server-rendered HTML):
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `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/: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); 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 /forum` | The Forum (board index); `GET /forum[/…]` answers 301 → `/forum[/…]` |
+| `GET /forum/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |
+| `GET /forum/:space/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |
+| `GET /forum/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |
+| `GET /forum/:space/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |
+| `GET /forum/:space/new`, `POST /forum/:space/new` | Start a thread (signed in) |
+| `POST /forum/:space/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |
+| `POST /forum/:space/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |
+| `POST /forum/:space/: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 |
@@ -180,8 +180,8 @@ API and machine endpoints:
@@ -180,8 +180,8 @@ API and machine endpoints:
| `POST /auth/refresh` | Rotate via refresh token |
| `POST /auth/refresh` | Rotate via refresh token |
| `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/:space/t/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |
+| `GET /forum/feed.xml`, `GET /forum/:space/feed.xml` | RSS of the latest threads (public spaces) |
+| `GET /forum/:space/: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
@@ -346,7 +346,7 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -346,7 +346,7 @@ member for now), `staff` (moderators only; looks missing to everyone else).
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;
JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;
members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap
members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap
- lists `/s`, `/pulse`, public spaces and the latest 1000 threads; `/s/*/new` is disallowed in
+ lists `/forum`, `/pulse`, public spaces and the latest 1000 threads; `/forum/*/new` is disallowed in
robots.txt.
robots.txt.
- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is the
- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is the
deterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`
deterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`
@@ -375,12 +375,12 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -375,12 +375,12 @@ member for now), `staff` (moderators only; looks missing to everyone else).
`docs/cutover-community-0005-space-moderators.md`.
`docs/cutover-community-0005-space-moderators.md`.
- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
- - the board index at `/s` lists every space under its group, with topics, posts and the last post;
+ - the board index at `/forum` lists every space under its group, with topics, posts and the last post;
- a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;
- a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;
- a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.
- a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.
Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:
Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:
the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),
the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),
- the name and description, and create spaces (`/s/new-space`).
+ the name and description, and create spaces (`/forum/new`).
Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another
Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another
space, and both link to each other.
space, and both link to each other.
- **Ratings** (after Facepunch), one per person per post, never your own:
- **Ratings** (after Facepunch), one per person per post, never your own:
@@ -431,7 +431,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -431,7 +431,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `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` `{ rule?, reason? }` · `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 /forum/:space/moderators` and `POST /forum/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
### A space's chat room (OpenVibe.Chat)
### A space's chat room (OpenVibe.Chat)
@@ -665,9 +665,9 @@ Discovery is written by `openvibe-shared/seo`; Community only supplies the data
@@ -665,9 +665,9 @@ Discovery is written by `openvibe-shared/seo`; Community only supplies the data
`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on paste
`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on paste
pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.
pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.
Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The
Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The
-sitemap lists home, `/pastes`, the latest public pastes, `/s`, `/pulse`, public spaces and
+sitemap lists home, `/pastes`, the latest public pastes, `/forum`, `/pulse`, public spaces and
their latest threads (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers
their latest threads (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers
-and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/s/*/new` and `?sso=` URLs;
+and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/forum/*/new` and `?sso=` URLs;
`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastes
`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastes
in full (no NSFW, no burn-after-read, nothing members-only).
in full (no NSFW, no burn-after-read, nothing members-only).
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"key": "welcome",
"key": "welcome",
"space": "general",
"space": "general",
"title": "Welcome to OpenVibe: start here",
"title": "Welcome to OpenVibe: start here",
- "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/s/feedback), where people vote on them. What we are building next is on the [Roadmap](/s/roadmap). Everything here follows the [terms](/terms)."
+ "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/forum/feedback), where people vote on them. What we are building next is on the [Roadmap](/forum/roadmap). Everything here follows the [terms](/terms)."
},
},
{
{
"key": "how-to-get-help",
"key": "how-to-get-help",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"key": "how-feedback-works",
"key": "how-feedback-works",
"space": "feedback",
"space": "feedback",
"title": "How feedback works",
"title": "How feedback works",
- "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/s/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."
+ "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/forum/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."
},
},
{
{
"key": "show-what-you-made",
"key": "show-what-you-made",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-/* OpenVibe.Community: the moderation log page (/s/:space/log), linked by that page alone (renderPage pageCss) so the
+/* OpenVibe.Community: the moderation log page (/forum/: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. */
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:first-child { width: 7.5em; }
.mod-log th:nth-child(3) { width: 7em; }
.mod-log th:nth-child(3) { width: 7em; }
@@ -13,9 +13,10 @@
@@ -13,9 +13,10 @@
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
- * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /s/feed.xml
+ * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /forum/feed.xml
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
- * GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
+ * GET /forum … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
+ * GET /s … 301/308 to /forum (old forum links)
* GET /pulse the network's public activity
* GET /pulse the network's public activity
* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
* GET|POST /submissions … submissions, their pages and the review queue (submissions/routes.js)
* GET|POST /submissions … submissions, their pages and the review queue (submissions/routes.js)
@@ -9,10 +9,11 @@ const config = require('./config');
@@ -9,10 +9,11 @@ const config = require('./config');
const catalog = require('./pastes/catalog');
const catalog = require('./pastes/catalog');
const source = require('./pastes/source');
const source = require('./pastes/source');
const { abs } = require('./render/layout');
const { abs } = require('./render/layout');
+const { spacePath, threadPath } = require('./forum/paths');
const { isoDate, clean } = require('./render/jsonld');
const { isoDate, clean } = require('./render/jsonld');
const SITEMAP_TTL_MS = 60 * 60 * 1000;
const SITEMAP_TTL_MS = 60 * 60 * 1000;
-const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];
+const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso='];
const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';
const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';
const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
@@ -29,7 +30,7 @@ function llmsSections() {
@@ -29,7 +30,7 @@ function llmsSections() {
{ title: 'Browse', links: [
{ title: 'Browse', links: [
{ title: 'OpenVibe.Community', url: `${u}/`, note: 'pastes, forum and network activity' },
{ title: 'OpenVibe.Community', url: `${u}/`, note: 'pastes, forum and network activity' },
{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
- { title: 'Forum', url: `${u}/s`, note: 'open discussion forums and their threads', html: true },
+ { title: 'Forum', url: `${u}/forum`, note: 'open discussion forums and their threads', html: true },
{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity', html: true },
{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity', html: true },
] },
] },
// The API is a primary entry for an agent, not something to skip.
// The API is a primary entry for an agent, not something to skip.
@@ -53,7 +54,7 @@ function llmsSections() {
@@ -53,7 +54,7 @@ function llmsSections() {
optional: [
optional: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
{ title: 'Latest pastes (RSS)', url: `${u}/feed.xml` },
{ title: 'Latest pastes (RSS)', url: `${u}/feed.xml` },
- { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },
+ { title: 'Latest threads (RSS)', url: `${u}/forum/feed.xml` },
{ title: 'Full text for language models', url: `${u}/llms-full.txt` },
{ title: 'Full text for language models', url: `${u}/llms-full.txt` },
],
],
};
};
@@ -68,7 +69,7 @@ async function llmsFullSections() {
@@ -68,7 +69,7 @@ async function llmsFullSections() {
if (_forum) {
if (_forum) {
const threads = await _forum.recentPublic({ limit: 50 });
const threads = await _forum.recentPublic({ limit: 50 });
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
- title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),
+ title: `${t.title} (${t.space_name})`, url: abs(threadPath(t.space_slug, t.slug)), text: markdownToText(t.opening, Infinity),
})) });
})) });
}
}
const pastes = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw) && !Number(p.burn_after_read) && p.type !== 'screenshot');
const pastes = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw) && !Number(p.burn_after_read) && p.type !== 'screenshot');
@@ -93,10 +94,10 @@ async function buildSitemapRows() {
@@ -93,10 +94,10 @@ async function buildSitemapRows() {
// No /p/:slug rows: a paste is link-shared content and is noindex (SEO-29), so it must not be
// No /p/:slug rows: a paste is link-shared content and is noindex (SEO-29), so it must not be
// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.
// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.
if (_forum) {
if (_forum) {
- add('/s', null, 'hourly', '0.8');
+ add('/forum', null, 'hourly', '0.8');
add('/pulse', null, 'hourly', '0.5');
add('/pulse', null, 'hourly', '0.5');
- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
+ for (const s of await _forum.publicSpaces()) add(spacePath(s.slug), s.last_activity_at || null, 'hourly', '0.7');
+ for (const t of await _forum.recentPublic({ limit: 1000 })) add(threadPath(t.space_slug, t.slug), isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
}
}
return rows;
return rows;
}
}
@@ -120,22 +121,22 @@ async function pasteFeedItems() {
@@ -120,22 +121,22 @@ async function pasteFeedItems() {
});
});
}
}
-/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */
+/** Threads (forum.recentPublic rows) for /forum/feed.xml and /forum/:space/feed.xml. */
function threadFeedItems(threads) {
function threadFeedItems(threads) {
const { markdownToText } = require('./render/markdown');
const { markdownToText } = require('./render/markdown');
return threads.map((t) => {
return threads.map((t) => {
- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
+ const url = abs(threadPath(t.space_slug, t.slug));
const description = markdownToText(t.opening, 300);
const description = markdownToText(t.opening, 300);
return { title: t.title, link: url, guid: url, description, content: description,
return { title: t.title, link: url, guid: url, description, content: description,
author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };
author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };
});
});
}
}
-/** A thread's newest posts for /s/:space/t/:slug/feed.xml (opening post and replies). */
+/** A thread's newest posts for /forum/:space/:slug/feed.xml (opening post and replies). */
function postFeedItems(space, thread, posts) {
function postFeedItems(space, thread, posts) {
const { markdownToText } = require('./render/markdown');
const { markdownToText } = require('./render/markdown');
return posts.map((p) => {
return posts.map((p) => {
- const url = abs(`/s/${space.slug}/t/${thread.slug}#post-${p.id}`);
+ const url = abs(`${threadPath(space.slug, thread.slug)}#post-${p.id}`);
const description = markdownToText(p.body_markdown || '', 300);
const description = markdownToText(p.body_markdown || '', 300);
return { title: p.is_opening ? thread.title : `Re: ${thread.title}`, link: url, guid: url, description, content: description,
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) };
author: undefined, published: isoDate(p.created_at), updated: isoDate(p.updated_at || p.created_at) };
@@ -102,14 +102,14 @@ async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
@@ -102,14 +102,14 @@ async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
const visibility = forumVisibility(thread, spaceVisibility);
return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
+ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
}
}
async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
const visibility = forumVisibility(thread, spaceVisibility);
return await record('community.post.created', { type: 'post', id: String(post.id) }, {
return await record('community.post.created', { type: 'post', id: String(post.id) }, {
post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
+ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+
+/**
+ * The forum's addresses. The forum moved from /s (spaces were its old name) to /forum, and a thread's
+ * /t/ segment went with it (design 2026-11-07): /forum, /forum/<space>, /forum/<space>/<thread>.
+ * A thread's slug therefore shares a namespace with the space's own sub-routes, so those words are
+ * reserved and never become a slug (store.js appends -2 when a title would make one).
+ */
+
+const RESERVED_SPACE_SLUGS = new Set(['new', 'discuss', 'feed.xml', 'search', 'about']);
+const RESERVED_THREAD_SLUGS = new Set(['new', 'log', 'settings', 'feed.xml', 'moderators', 'chat-room', 'members', 'about', 'search', 'rules', 'wiki', 'edit', 'discuss']);
+
+const spacePath = (slug) => `/forum/${encodeURIComponent(slug)}`;
+const threadPath = (spaceSlug, threadSlug) => `/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(threadSlug)}`;
+
+/**
+ * An old /s… path (the remainder express gives a router mounted at /s, query included) → its /forum path.
+ * /s/general/t/a-title becomes /forum/general/a-title; everything else keeps its shape.
+ */
+function legacyForumPath(url) {
+ const qi = url.indexOf('?');
+ const path = qi === -1 ? url : url.slice(0, qi);
+ const query = qi === -1 ? '' : url.slice(qi);
+ let rest = path === '/' ? '' : path.replace(/\/+$/, '');
+ if (rest === '/new-space') return `/forum/new${query}`;
+ rest = rest.replace(/^\/([^/]+)\/t(?=\/|$)/, '/$1');
+ return `/forum${rest}${query}`;
+}
+
+module.exports = { RESERVED_SPACE_SLUGS, RESERVED_THREAD_SLUGS, spacePath, threadPath, legacyForumPath };
@@ -3,18 +3,21 @@
@@ -3,18 +3,21 @@
/**
/**
* Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
* Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
*
*
- * GET /s the Forum's board index (a forum is a `space` in code and the API)
- * GET /forum, /forum/<rest> 301 → /s, /s/<rest> (query kept)
- * 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)
+ * GET /forum the Forum's board index (a forum is a `space` in code and the API)
+ * GET /s, /s/<rest> 301 → /forum, /forum/<rest> (query kept); POST answers 308
+ * GET /forum/feed.xml RSS: latest threads in public spaces
+ * GET /forum/:space threads (?sort=hot|new|top&page=)
+ * GET /forum/:space/feed.xml RSS: latest threads of one public space
+ * GET /forum/:space/log the space's public moderation log (newest 100)
+ * GET /forum/:space/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread
+ * GET /forum/:space/new POST /forum/:space/new start a thread
+ * GET /forum/:space/:slug a thread (?page=)
+ * POST /forum/:space/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
+ * POST /forum/:space/:slug/post/:id/delete a moderator removes a post (rule and reason)
+ * POST /forum/:space/chat-room, /forum/:space/chat-room/detach the space's chat room (its owner or staff)
+ *
+ * The forum used to be /s (spaces were its old name) and a thread lived at /s/:space/t/:slug. Every old
+ * address redirects permanently: 301 for reads, 308 for form posts so the method and body survive.
*
*
* 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
* creator's join link (403), never its posts.
* creator's join link (403), never its posts.
@@ -33,6 +36,7 @@ const pages = require('../render/pages');
@@ -33,6 +36,7 @@ const pages = require('../render/pages');
const forumPages = require('../render/forum');
const forumPages = require('../render/forum');
const boardPages = require('../render/board');
const boardPages = require('../render/board');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
+const { spacePath, threadPath, legacyForumPath } = require('./paths');
function createForumRoutes({ forum, viewers, config }) {
function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const router = express.Router();
@@ -79,33 +83,33 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -79,33 +83,33 @@ function createForumRoutes({ forum, viewers, config }) {
next();
next();
}
}
- // People call it the Forum (spaces are OpenVibe.Space's), so /forum and /forum/<rest> answer too: a 301 to the
- // /s address every link, sitemap entry and Search document already uses.
- router.use('/forum', (req, res, next) => {
- if (req.method !== 'GET' && req.method !== 'HEAD') return next();
- res.redirect(301, `/s${req.url === '/' || req.url.startsWith('/?') ? req.url.slice(1) : req.url}`);
+ // The old /s address (spaces) still works: every read answers a permanent 301 to /forum, every other
+ // method a 308 (the method and body are kept). Old thread links /s/<space>/t/<slug> become /forum/<space>/<slug>.
+ router.use('/s', (req, res) => {
+ const status = req.method === 'GET' || req.method === 'HEAD' ? 301 : 308;
+ res.redirect(status, legacyForumPath(req.url));
});
});
// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
- router.get('/s', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum', withViewer, wrap(async (req, res, next) => {
try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
}));
}));
// Moderators: a new space, either style.
// Moderators: a new space, either style.
- router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum/new', withViewer, wrap(async (req, res, next) => {
try {
try {
- if (!req.user) return login(res, '/s/new-space');
+ if (!req.user) return login(res, '/forum/new');
if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');
if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');
html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
try {
try {
const out = await forum.createSpace(req.viewer, values);
const out = await forum.createSpace(req.viewer, values);
discovery.resetCaches();
discovery.resetCaches();
- res.redirect(303, `/s/${out.space.slug}`);
+ res.redirect(303, spacePath(out.space.slug));
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);
html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);
@@ -113,7 +117,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -113,7 +117,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.
// "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.
- router.get('/s/discuss', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {
try {
try {
const paste = String(req.query.paste || '').slice(0, 80);
const paste = String(req.query.paste || '').slice(0, 80);
const { groups } = await forum.listSpaces(req.viewer);
const { groups } = await forum.listSpaces(req.viewer);
@@ -124,26 +128,26 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -124,26 +128,26 @@ function createForumRoutes({ forum, viewers, config }) {
// RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).
// RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).
const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
- link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
+ link: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}`,
description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',
description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',
language: 'en',
language: 'en',
- selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
+ selfUrl: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}/feed.xml`,
items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
}, { format: 'rss' }));
}, { format: 'rss' }));
- router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
+ router.get('/forum/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
- router.get('/s/:space/feed.xml', wrap(async (req, res) => {
+ router.get('/forum/:space/feed.xml', wrap(async (req, res) => {
const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
if (!space) return res.status(404).type('text/plain').send('Not found');
if (!space) return res.status(404).type('text/plain').send('Not found');
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.
// 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) => {
+ router.get('/forum/:space/:slug/feed.xml', withViewer, wrap(async (req, res, next) => {
try {
try {
const out = await forum.threadFeed(req.viewer, req.params.space, req.params.slug);
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}`;
+ const base = `${config.baseUrl}${threadPath(out.space.slug, out.thread.slug)}`;
// A private or members-only thread's feed is the member's own: never kept by Cloudflare or a proxy.
// 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({
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}`,
title: `${SITE_NAME} — ${out.thread.title}`,
@@ -160,14 +164,14 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -160,14 +164,14 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// The space's public moderation log: what moderation did and why, in role only.
// 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) => {
+ router.get('/forum/:space/log', withViewer, wrap(async (req, res, next) => {
try {
try {
const out = await forum.moderationLog(req.viewer, req.params.space);
const out = await forum.moderationLog(req.viewer, req.params.space);
html(res, forumPages.moderationLogPage(out));
html(res, forumPages.moderationLogPage(out));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum/: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 });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
@@ -179,7 +183,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -179,7 +183,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {
try {
try {
// A members-only space: the teaser, not the form, for someone who may not post there.
// A members-only space: the teaser, not the form, for someone who may not post there.
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
@@ -190,7 +194,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -190,7 +194,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
@@ -225,11 +229,11 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -225,11 +229,11 @@ function createForumRoutes({ forum, viewers, config }) {
html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);
html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);
}
}
- router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
+ router.get('/forum/:space/:slug', withViewer, wrap(async (req, res, next) => {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const draft = String((req.body || {}).body || '').slice(0, 40_000);
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
try {
const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
@@ -247,9 +251,9 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -247,9 +251,9 @@ function createForumRoutes({ forum, viewers, config }) {
}
}
}));
}));
- const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
+ const back = (req) => threadPath(req.params.space, req.params.slug);
- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
res.redirect(303, back(req));
res.redirect(303, back(req));
@@ -257,7 +261,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -257,7 +261,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// Members-only on/off (no JS): on gates the thread to its author's VIP members.
// Members-only on/off (no JS): on gates the thread to its author's VIP members.
- router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
discovery.resetCaches();
discovery.resetCaches();
@@ -266,7 +270,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -266,7 +270,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// Content labels (L1, no JS): the author or a moderator ticks what the thread carries.
// Content labels (L1, no JS): the author or a moderator ticks what the thread carries.
- router.post('/s/:space/t/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
const labels = labelsUi.fromForm(req.body);
const labels = labelsUi.fromForm(req.body);
await forum.setThreadLabels(req.viewer, req.params.space, req.params.slug, { labels: labels === undefined ? [] : (labels === null ? ['not-a-label'] : labels) });
await forum.setThreadLabels(req.viewer, req.params.space, req.params.slug, { labels: labels === undefined ? [] : (labels === null ? ['not-a-label'] : labels) });
@@ -275,7 +279,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -275,7 +279,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
const flags = { rule: b.rule, reason: b.reason };
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';
@@ -287,7 +291,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -287,7 +291,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// Rate a post (no JS): the rating's button in the post's form; back to the post.
// Rate a post (no JS): the rating's button in the post's form; back to the post.
- router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
try {
try {
await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });
await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });
@@ -297,7 +301,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -297,7 +301,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// Crosspost to another space (no JS).
// Crosspost to another space (no JS).
- router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
discovery.resetCaches();
discovery.resetCaches();
@@ -306,21 +310,21 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -306,21 +310,21 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// Moderators: a space's settings (no JS).
// Moderators: a space's settings (no JS).
- router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
try {
try {
// The group (the board index) is staff's; a space's own moderators change the rest.
// The group (the board index) is staff's; a space's own moderators change the rest.
await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });
await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });
discovery.resetCaches();
discovery.resetCaches();
- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
+ res.redirect(303, spacePath(req.params.space));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
// The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's
// The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's
// moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.
// moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.
- const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
+ const modsBack = (req, error) => `${spacePath(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'community.blocked' || err.code === 'identity.unavailable');
const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'community.blocked' || err.code === 'identity.unavailable');
- router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
res.redirect(303, modsBack(req));
res.redirect(303, modsBack(req));
@@ -329,7 +333,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -329,7 +333,7 @@ function createForumRoutes({ forum, viewers, config }) {
failPage(req, res, err, next);
failPage(req, res, err, next);
}
}
}));
}));
- router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
res.redirect(303, modsBack(req));
res.redirect(303, modsBack(req));
@@ -340,8 +344,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -340,8 +344,8 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
// The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
- const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
- router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const chatBack = (req, error) => `${spacePath(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
+ router.post('/forum/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });
await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });
res.redirect(303, chatBack(req));
res.redirect(303, chatBack(req));
@@ -350,7 +354,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -350,7 +354,7 @@ function createForumRoutes({ forum, viewers, config }) {
failPage(req, res, err, next);
failPage(req, res, err, next);
}
}
}));
}));
- router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.detachChatRoom(req.viewer, req.params.space);
await forum.detachChatRoom(req.viewer, req.params.space);
res.redirect(303, chatBack(req));
res.redirect(303, chatBack(req));
@@ -358,7 +362,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -358,7 +362,7 @@ 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('/forum/:space/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
try {
try {
await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String(b.status || ''), rule: b.rule, reason: b.reason });
await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String(b.status || ''), rule: b.rule, reason: b.reason });
@@ -366,7 +370,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -366,7 +370,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ router.post('/forum/:space/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });
await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });
res.redirect(303, back(req));
res.redirect(303, back(req));
@@ -374,18 +378,18 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -374,18 +378,18 @@ function createForumRoutes({ forum, viewers, config }) {
}));
}));
// A moderator removes one post (its opening post removes the thread): rule and reason are optional.
// 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) => {
+ router.post('/forum/:space/:slug/post/:id/delete', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.deletePost(req.viewer, req.params.id, req.body || {});
await forum.deletePost(req.viewer, req.params.id, req.body || {});
res.redirect(303, back(req));
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
+ router.post('/forum/:space/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
try {
await forum.deleteThread(req.viewer, req.params.space, req.params.slug, req.body || {});
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, spacePath(req.params.space));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
});
});
@@ -40,6 +40,7 @@
@@ -40,6 +40,7 @@
* (moderator services can detach). Community keeps the link: space_chat_rooms.
* (moderator services can detach). Community keeps the link: space_chat_rooms.
*/
*/
const store = require('./store');
const store = require('./store');
+const { spacePath, threadPath, RESERVED_SPACE_SLUGS } = require('./paths');
const events = require('../events');
const events = require('../events');
const { applyVote, myVotes, parseVote } = require('../votes');
const { applyVote, myVotes, parseVote } = require('../votes');
const { fail, isoTime } = require('../http/v1');
const { fail, isoTime } = require('../http/v1');
@@ -114,11 +115,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -114,11 +115,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const site = String(config.baseUrl || '').replace(/\/$/, '');
const site = String(config.baseUrl || '').replace(/\/$/, '');
const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);
const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);
const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };
const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };
- const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}/s/${s.slug}`, `${site}/sitemap.xml`]); };
+ const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}${spacePath(s.slug)}`, `${site}/sitemap.xml`]); };
const pingThread = (space, t, { withSpace = false } = {}) => {
const pingThread = (space, t, { withSpace = false } = {}) => {
if (!indexableSpace(space) || !t || t.members_only_owner) return;
if (!indexableSpace(space) || !t || t.members_only_owner) return;
- const urls = [`${site}/s/${space.slug}/t/${t.slug}`, `${site}/sitemap.xml`];
- if (withSpace) urls.push(`${site}/s/${space.slug}`);
+ const urls = [`${site}${threadPath(space.slug, t.slug)}`, `${site}/sitemap.xml`];
+ if (withSpace) urls.push(`${site}${spacePath(space.slug)}`);
ping(urls);
ping(urls);
};
};
@@ -244,7 +245,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -244,7 +245,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
// ── shapes ───────────────────────────────────────────────
// ── shapes ───────────────────────────────────────────────
- const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
+ const threadUrl = (space, t) => threadPath(space.slug, t.slug);
const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
@@ -348,7 +349,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -348,7 +349,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async function shapeSpace(s, mo = null) {
async function shapeSpace(s, mo = null) {
return {
return {
- slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
+ slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: spacePath(s.slug),
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,
style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,
group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,
group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,
@@ -398,11 +399,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -398,11 +399,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (thread.crosspost_of) {
if (thread.crosspost_of) {
const t = await store.getThread(db, thread.crosspost_of);
const t = await store.getThread(db, thread.crosspost_of);
const sp = t ? await store.getSpaceById(db, t.space_id) : null;
const sp = t ? await store.getSpaceById(db, t.space_id) : null;
- if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
+ if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: threadPath(sp.slug, t.slug) };
}
}
const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))
const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))
.map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))
.map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))
- .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
+ .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: threadPath(sp.slug, r.slug) }));
const targets = person(v) && !thread.members_only_owner
const targets = person(v) && !thread.members_only_owner
? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
.map((sp) => ({ slug: sp.slug, name: sp.name }))
.map((sp) => ({ slug: sp.slug, name: sp.name }))
@@ -547,7 +548,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -547,7 +548,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const shaped = await Promise.all(rows.map(async (r) => {
const shaped = await Promise.all(rows.map(async (r) => {
const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
const l = last.get(r.id);
const l = last.get(r.id);
- sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
+ sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: threadPath(r.slug, l.thread_slug) }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
return sp;
return sp;
}));
}));
// The board index: groups in order, each with its top-level spaces; child boards under their parent.
// The board index: groups in order, each with its top-level spaces; child boards under their parent.
@@ -593,7 +594,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -593,7 +594,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async createSpace(v, body = {}) {
async createSpace(v, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');
const slug = String(body.slug || '').trim().toLowerCase();
const slug = String(body.slug || '').trim().toLowerCase();
- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
+ if (!SPACE_SLUG.test(slug) || RESERVED_SPACE_SLUGS.has(slug) || slug === 'new-space') fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new, discuss, feed.xml, search, about or new-space)');
if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const fields = await spaceFields({ ...body, name: body.name }, null);
const fields = await spaceFields({ ...body, name: body.name }, null);
@@ -636,7 +637,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -636,7 +637,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
pingSpace(space);
pingSpace(space);
- return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
+ return { space: { slug: space.slug, name: space.name, url: spacePath(space.slug) }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
},
},
/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
@@ -712,7 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -712,7 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
entries: rows.map((r) => ({
entries: rows.map((r) => ({
id: r.id, action: r.action, rule: r.rule || null, reason: r.reason || null,
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),
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,
+ thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: threadPath(space.slug, r.thread_slug) } : null,
post_id: r.post_id || null,
post_id: r.post_id || null,
})),
})),
};
};
@@ -787,7 +788,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -787,7 +788,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
- const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
+ const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](${threadPath(space.slug, thread.slug)})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
if (pulse) await hook(async () => await pulse.threadCreated(created, target));
if (pulse) await hook(async () => await pulse.threadCreated(created, target));
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
*/
*/
const SORTS = ['hot', 'new', 'top', 'active'];
const SORTS = ['hot', 'new', 'top', 'active'];
+const { RESERVED_THREAD_SLUGS } = require('./paths');
// ── Spaces ───────────────────────────────────────────────────
// ── Spaces ───────────────────────────────────────────────────
@@ -91,7 +92,10 @@ function slugify(title) {
@@ -91,7 +92,10 @@ function slugify(title) {
async function uniqueSlug(db, spaceId, base) {
async function uniqueSlug(db, spaceId, base) {
const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
- if (!await taken.get(spaceId, base)) return base;
+ // /forum/<space>/<slug> shares its namespace with the space's own sub-routes: a title that makes one of
+ // those words takes the -2 suffix instead.
+ const start = RESERVED_THREAD_SLUGS.has(base) ? `${base.slice(0, 54)}-2` : base;
+ if (!await taken.get(spaceId, start)) return start;
for (let n = 2; n < 10_000; n++) {
for (let n = 2; n < 10_000; n++) {
const candidate = `${base.slice(0, 54)}-${n}`;
const candidate = `${base.slice(0, 54)}-${n}`;
if (!await taken.get(spaceId, candidate)) return candidate;
if (!await taken.get(spaceId, candidate)) return candidate;
@@ -79,14 +79,14 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -79,14 +79,14 @@ function createPulse({ db, network = null, config = {} } = {}) {
async threadCreated(thread, space) {
async threadCreated(thread, space) {
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
await recordLocal('thread', thread.id, {
await recordLocal('thread', thread.id, {
- title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
+ title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
});
});
},
},
async postCreated(post, thread, space) {
async postCreated(post, thread, space) {
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
await recordLocal('post', post.id, {
await recordLocal('post', post.id, {
- title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
+ title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#post-${post.id}`,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
});
});
},
},
@@ -198,7 +198,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -198,7 +198,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
}
}
return 'someone';
return 'someone';
}
}
- const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;
+ const threadUrl = (thread, space) => `${base}/forum/${space.slug}/${thread.slug}`;
async function postUrl(post, thread, space) {
async function postUrl(post, thread, space) {
const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
@@ -211,13 +211,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -211,13 +211,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const url = threadUrl(thread, space);
const url = threadUrl(thread, space);
return {
return {
username: 'OpenVibe.Community',
username: 'OpenVibe.Community',
- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
+ content: `New thread in **forum/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
allowed_mentions: { parse: [] },
allowed_mentions: { parse: [] },
embeds: [{
embeds: [{
title: String(thread.title).slice(0, 256),
title: String(thread.title).slice(0, 256),
url,
url,
description: markdownToText(opening ? opening.body_markdown : '', 300),
description: markdownToText(opening ? opening.body_markdown : '', 300),
- footer: { text: `OpenVibe.Community · s/${space.slug}` },
+ footer: { text: `OpenVibe.Community · forum/${space.slug}` },
timestamp: isoTime(thread.created_at) || undefined,
timestamp: isoTime(thread.created_at) || undefined,
}],
}],
};
};
@@ -229,13 +229,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -229,13 +229,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const title = String(thread.title).slice(0, 200);
const title = String(thread.title).slice(0, 200);
return {
return {
username: 'OpenVibe.Community',
username: 'OpenVibe.Community',
- content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,
+ content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **forum/${escapeDiscord(space.slug)}**: <${url}>`,
allowed_mentions: { parse: [] },
allowed_mentions: { parse: [] },
embeds: [{
embeds: [{
title: `Re: ${title}`.slice(0, 256),
title: `Re: ${title}`.slice(0, 256),
url,
url,
description: markdownToText(post.body_markdown, 500),
description: markdownToText(post.body_markdown, 500),
- footer: { text: `OpenVibe.Community · s/${space.slug}` },
+ footer: { text: `OpenVibe.Community · forum/${space.slug}` },
timestamp: isoTime(post.created_at) || undefined,
timestamp: isoTime(post.created_at) || undefined,
}],
}],
};
};
@@ -476,7 +476,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -476,7 +476,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),
next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),
mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },
mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },
- thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },
+ thread: { id: d.thread_id, title: d.thread_title, url: `/forum/${d.space_slug}/${d.thread_slug}` },
post: d.post_id ? { id: d.post_id } : null,
post: d.post_id ? { id: d.post_id } : null,
}));
}));
}
}
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,
+ * Forum-style pages, after vBulletin and SMF: the board index at /forum (every space, grouped, with topics,
* posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics
* posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics
* first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,
* first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,
* posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js
* posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js
@@ -19,7 +19,7 @@ const labelsUi = require('./labels');
@@ -19,7 +19,7 @@ const labelsUi = require('./labels');
const STYLE_LABELS = { forum: 'Classic', feed: 'Feed' }; // the two styles, as the Forum page and the settings name them
const STYLE_LABELS = { forum: 'Classic', feed: 'Feed' }; // the two styles, as the Forum page and the settings name them
const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
-const trail = (space) => [['Home', '/'], ['Forum', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];
+const trail = (space) => [['Home', '/'], ['Forum', '/forum'], ...(space.group ? [[space.group.name, `/forum#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/forum/${space.parent.slug}`]] : [])];
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
@@ -28,10 +28,10 @@ function boardRow(sp) {
@@ -28,10 +28,10 @@ function boardRow(sp) {
const last = sp.last_post
const last = sp.last_post
? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
: '<span class="muted small">No posts yet</span>';
- const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
+ const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
return `<tr>
<td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
<td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
- <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
+ <a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
</div></td>
</div></td>
<td class="num" data-label="Topics">${num(sp.thread_count)}</td>
<td class="num" data-label="Topics">${num(sp.thread_count)}</td>
@@ -46,14 +46,14 @@ function boardTable(spaces, caption) {
@@ -46,14 +46,14 @@ function boardTable(spaces, caption) {
<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
}
}
-// ── /s ───────────────────────────────────────────────────────
+// ── /forum ───────────────────────────────────────────────────────
function boardIndexPage({ groups = [], user, canModerate = false }) {
function boardIndexPage({ groups = [], user, canModerate = false }) {
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Forum', '/s']])}
+ ${crumbs([['Home', '/'], ['Forum', '/forum']])}
<h1>Forum</h1>
<h1>Forum</h1>
<p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
<p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
+ ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
</header>
</header>
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
@@ -64,10 +64,10 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
@@ -64,10 +64,10 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
return renderPage({
return renderPage({
title: 'Forum',
title: 'Forum',
description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',
description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',
- canonicalPath: '/s',
+ canonicalPath: '/forum',
active: 'spaces',
active: 'spaces',
- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
+ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],
body,
body,
});
});
}
}
@@ -80,7 +80,7 @@ function topicPages(base, pages) {
@@ -80,7 +80,7 @@ function topicPages(base, pages) {
}
}
function topicRow(t, space) {
function topicRow(t, space) {
- const base = `/s/${space.slug}/t/${t.slug}`;
+ const base = `/forum/${space.slug}/${t.slug}`;
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
@@ -100,7 +100,7 @@ function topicRow(t, space) {
@@ -100,7 +100,7 @@ function topicRow(t, space) {
function settingsForm(space, groups = []) {
function settingsForm(space, groups = []) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>
return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>
-<form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings">
+<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings">
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
@@ -121,18 +121,18 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
@@ -121,18 +121,18 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
const name = m.username ? `@${m.username}` : (m.display_name || 'this person');
const name = m.username ? `@${m.username}` : (m.display_name || 'this person');
const self = me && m.subject === me;
const self = me && m.subject === me;
return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
- <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
+ <form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
}).join('');
}).join('');
return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
-<form class="paste-form" method="post" action="/s/${slug}/moderators">
+<form class="paste-form" method="post" action="/forum/${slug}/moderators">
<label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
<label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
<div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
</form></details>`;
</form></details>`;
}
}
-// ── /s/:space (forum style) ──────────────────────────────────
+// ── /forum/:space (forum style) ──────────────────────────────────
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;
@@ -142,13 +142,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
@@ -142,13 +142,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
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>` : '';
const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
const start = viewer.can_start !== false
const start = viewer.can_start !== false
- ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
+ ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
- <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>
+ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}
+ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${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>` : ''}
@@ -158,19 +158,19 @@ ${tags}${statuses}
@@ -158,19 +158,19 @@ ${tags}${statuses}
<section data-results>
<section data-results>
${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>
${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>
<thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>
<thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>
- <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="/forum/${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>`;
+<footer class="space-foot"><a class="small" href="/forum/${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: 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}` }])],
+ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],
body,
body,
});
});
}
}
@@ -208,9 +208,9 @@ function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
@@ -208,9 +208,9 @@ function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
</article>`;
</article>`;
}
}
-// ── /s/:space/t/:slug (forum style) ──────────────────────────
+// ── /forum/:space/:slug (forum style) ──────────────────────────
function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
- const base = `/s/${space.slug}/t/${thread.slug}`;
+ const base = `/forum/${space.slug}/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
@@ -231,7 +231,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -231,7 +231,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const labelBox = f.labelsBox(base, thread, viewer);
const labelBox = f.labelsBox(base, thread, viewer);
const body = `
const body = `
<article class="thread forum-topic">
<article class="thread forum-topic">
- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}
+ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`], [ld.clean(thread.title, 60), base]])}
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
<div>
@@ -264,12 +264,12 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -264,12 +264,12 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
footerVariant: 'compact',
footerVariant: 'compact',
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
+ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/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: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),
],
],
body,
body,
});
});
@@ -280,14 +280,14 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
@@ -280,14 +280,14 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Forum', '/s'], ['New forum', '/s/new-space']])}
+ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['New forum', '/forum/new']])}
<h1>New forum</h1>
<h1>New forum</h1>
<p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p>
<p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p>
</header>
</header>
-<form class="paste-form" method="post" action="/s/new-space">
+<form class="paste-form" method="post" action="/forum/new">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
- <label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
+ <label class="field"><span>Address (/forum/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
<label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
<label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
@@ -295,7 +295,7 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
@@ -295,7 +295,7 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div>
<div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div>
</form>`;
</form>`;
- return renderPage({ title: 'New forum', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
+ return renderPage({ title: 'New forum', canonicalPath: '/forum/new', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
}
}
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
@@ -303,13 +303,13 @@ function discussPage({ groups = [], paste = '', user }) {
@@ -303,13 +303,13 @@ function discussPage({ groups = [], paste = '', user }) {
const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Forum', '/s'], ['Discuss', '/s/discuss']])}
+ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}
<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
<p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
<p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
- ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
+ ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
</header>
</header>
-${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
- return renderPage({ title: 'Discuss in the forum', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
+${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
+ return renderPage({ title: 'Discuss in the forum', canonicalPath: '/forum/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
}
}
module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Forum pages — /s (spaces), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.
+ * Forum pages — /forum (spaces), /forum/:space (threads), /forum/:space/:slug (a thread), /forum/:space/new.
* Complete without JavaScript: sorting and pagination are links, replying, voting and the
* Complete without JavaScript: sorting and pagination are links, replying, voting and the
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* Markdown renderer's output (render/markdown.js).
* Markdown renderer's output (render/markdown.js).
@@ -38,7 +38,7 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nul
@@ -38,7 +38,7 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nul
if (sort && sort !== 'hot') q.set('sort', sort);
if (sort && sort !== 'hot') q.set('sort', sort);
if (page > 1) q.set('page', String(page));
if (page > 1) q.set('page', String(page));
const s = q.toString();
const s = q.toString();
- return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
+ return `/forum/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
}
}
/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */
/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */
@@ -67,11 +67,11 @@ const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-v
@@ -67,11 +67,11 @@ const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-v
const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
-// ── /s ───────────────────────────────────────────────────────
+// ── /forum ───────────────────────────────────────────────────────
function spacesPage({ spaces }) {
function spacesPage({ spaces }) {
const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `
const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `
<li class="space-item">
<li class="space-item">
- <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
+ <a class="space-link" href="/forum/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
</li>`).join('')}
</li>`).join('')}
@@ -86,22 +86,22 @@ ${list}`;
@@ -86,22 +86,22 @@ ${list}`;
return renderPage({
return renderPage({
title: 'Forum',
title: 'Forum',
description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
- canonicalPath: '/s',
+ canonicalPath: '/forum',
active: 'spaces',
active: 'spaces',
- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
+ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],
body,
body,
});
});
}
}
-// ── /s/:space ────────────────────────────────────────────────
+// ── /forum/:space ────────────────────────────────────────────────
function threadRow(t, space) {
function threadRow(t, space) {
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
const labels = labelsUi.inForce(t);
const labels = labelsUi.inForce(t);
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
<div class="thread-main">
- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
+ <a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
${labelsUi.chips(labels)}
${labelsUi.chips(labels)}
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
</div>
</div>
@@ -119,11 +119,11 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {
@@ -119,11 +119,11 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {
const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <form class="paste-form" method="post" action="/s/${esc(space.slug)}/chat-room">
+ <form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room">
<label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>
<label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>
<div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>
</form>
</form>
- ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
+ ${r ? `<form class="inline-form" method="post" action="/forum/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
</details>` : '';
</details>` : '';
return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;
return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;
}
}
@@ -138,13 +138,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -138,13 +138,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
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>` : '';
const canStart = viewer.can_start !== false;
const canStart = viewer.can_start !== false;
const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
- const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
- : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;
+ const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
+ : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
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>
- <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>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
+ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${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>
@@ -152,19 +152,19 @@ ${chatRoom}
@@ -152,19 +152,19 @@ ${chatRoom}
${categoryNav}${statusNav}
${categoryNav}${statusNav}
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<section data-results>
<section data-results>
- ${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="/forum/${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>`;
+<footer class="space-foot"><a class="small" href="/forum/${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: 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}` }])],
+ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],
body,
body,
});
});
}
}
@@ -181,7 +181,7 @@ function modActions({ base, space, thread, viewer, categories = [] }) {
@@ -181,7 +181,7 @@ function modActions({ base, space, thread, viewer, categories = [] }) {
</div>` : '';
</div>` : '';
}
}
-// ── /s/:space/t/:slug ────────────────────────────────────────
+// ── /forum/:space/:slug ────────────────────────────────────────
function postHtml(p, n, { base = '', page = 1, catalogue = [], canModerate = false } = {}) {
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}">
@@ -271,7 +271,7 @@ function labelsBox(base, thread, viewer) {
@@ -271,7 +271,7 @@ function labelsBox(base, thread, viewer) {
}
}
function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
- const base = `/s/${space.slug}/t/${thread.slug}`;
+ const base = `/forum/${space.slug}/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
@@ -292,7 +292,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -292,7 +292,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const body = `
const body = `
<article class="thread">
<article class="thread">
- <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">${esc(ld.clean(thread.title, 60))}</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
@@ -325,21 +325,21 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -325,21 +325,21 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
footerVariant: 'compact',
footerVariant: 'compact',
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
+ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/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: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),
],
],
body,
body,
});
});
}
}
-// ── /s/:space/new ────────────────────────────────────────────
+// ── /forum/:space/new ────────────────────────────────────────────
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
- const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
+ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
@@ -353,17 +353,17 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
@@ -353,17 +353,17 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
</div>
</div>
-</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
+</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
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> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
<h1>New thread in ${esc(space.name)}</h1>
<h1>New thread in ${esc(space.name)}</h1>
</header>
</header>
${form}`;
${form}`;
return renderPage({
return renderPage({
title: `New thread · ${space.name}`,
title: `New thread · ${space.name}`,
description: `Start a thread in ${space.name} on OpenVibe.Community.`,
description: `Start a thread in ${space.name} on OpenVibe.Community.`,
- canonicalPath: `/s/${space.slug}/new`,
+ canonicalPath: `/forum/${space.slug}/new`,
robots: 'noindex,follow',
robots: 'noindex,follow',
active: 'spaces',
active: 'spaces',
footerVariant: 'compact',
footerVariant: 'compact',
@@ -377,14 +377,14 @@ ${form}`;
@@ -377,14 +377,14 @@ ${form}`;
* What someone without the creator's VIP membership sees: the space (and thread title), why, and
* What someone without the creator's VIP membership sees: the space (and thread title), why, and
* the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).
* the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).
*/
*/
-function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {
+function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/forum' }) {
const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';
const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';
const why = reason === 'not_signed_in' || !user
const why = reason === 'not_signed_in' || !user
? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`
? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
: '<p class="muted">Your account does not have an active membership for this.</p>';
: '<p class="muted">Your account does not have an active membership for this.</p>';
- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
+ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › ${thread ? `<a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs}
${crumbs}
@@ -399,7 +399,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -399,7 +399,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
return renderPage({
return renderPage({
title: `${thread ? thread.title : space.name} · members only`,
title: `${thread ? thread.title : space.name} · members only`,
description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,
description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,
- canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,
+ canonicalPath: thread ? `/forum/${space.slug}/${thread.slug}` : `/forum/${space.slug}`,
robots: 'noindex,nofollow',
robots: 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
footerVariant: 'compact',
footerVariant: 'compact',
@@ -407,7 +407,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -407,7 +407,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
});
});
}
}
-// ── /s/:space/log ────────────────────────────────────────────
+// ── /forum/: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. */
/** 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) {
function logTarget(e) {
if (!e.thread) return '<span class="muted">a removed thread</span>';
if (!e.thread) return '<span class="muted">a removed thread</span>';
@@ -432,23 +432,23 @@ function moderationLogPage({ space, entries }) {
@@ -432,23 +432,23 @@ function moderationLogPage({ space, entries }) {
</tr>`).join('');
</tr>`).join('');
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> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>
<h1>Moderation log</h1>
<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>
<p class="muted">What moderation did in ${esc(space.name)} and why. Newest first; who acted is shown by role only.</p>
</header>
</header>
${entries.length ? `<table class="board-table mod-log"><caption class="sr-only">Moderation actions in ${esc(space.name)}</caption>
${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>
<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>'}
<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>`;
+<footer class="space-foot"><a class="small" href="/forum/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/forum/${esc(space.slug)}/feed.xml">RSS</a>` : ''}</footer>`;
return renderPage({
return renderPage({
title: `Moderation log · ${space.name}`,
title: `Moderation log · ${space.name}`,
description: `What moderation did in ${space.name} on OpenVibe.Community, and why: the action, the rule and the reason.`,
description: `What moderation did in ${space.name} on OpenVibe.Community, and why: the action, the rule and the reason.`,
- canonicalPath: `/s/${space.slug}/log`,
+ canonicalPath: `/forum/${space.slug}/log`,
robots: space.visibility === 'public' && !space.members_only ? 'index,follow' : 'noindex,nofollow',
robots: space.visibility === 'public' && !space.members_only ? 'index,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
footerVariant: 'compact',
footerVariant: 'compact',
pageCss: ['modlog.css'],
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` }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: 'Moderation log', url: `/forum/${space.slug}/log` }])],
body,
body,
});
});
}
}
@@ -82,7 +82,7 @@ function discussionAuthorLd(a) {
@@ -82,7 +82,7 @@ function discussionAuthorLd(a) {
/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
function threadLd({ space, thread, posts, opening, description }) {
function threadLd({ space, thread, posts, opening, description }) {
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
- const url = abs(`/s/${space.slug}/t/${thread.slug}`);
+ const url = abs(`/forum/${space.slug}/${thread.slug}`);
const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
return {
return {
'@context': 'https://schema.org',
'@context': 'https://schema.org',
@@ -94,7 +94,7 @@ function threadLd({ space, thread, posts, opening, description }) {
@@ -94,7 +94,7 @@ function threadLd({ space, thread, posts, opening, description }) {
author: discussionAuthorLd(thread.author),
author: discussionAuthorLd(thread.author),
datePublished: isoDate(thread.created_at) || undefined,
datePublished: isoDate(thread.created_at) || undefined,
dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
+ isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/forum/${space.slug}`) },
commentCount: thread.reply_count,
commentCount: thread.reply_count,
interactionStatistic: [
interactionStatistic: [
{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
@@ -64,7 +64,7 @@ function navbarInit(opts) {
@@ -64,7 +64,7 @@ function navbarInit(opts) {
links: [
links: [
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold
// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.
// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.
- { label: 'Forum', href: '/s', icon: 'fa-comments', active: opts.active === 'spaces' },
+ { label: 'Forum', href: '/forum', icon: 'fa-comments', active: opts.active === 'spaces' },
{ label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },
{ label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },
{ label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },
{ label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
@@ -95,11 +95,11 @@ function footerInit(opts) {
@@ -95,11 +95,11 @@ function footerInit(opts) {
heading: 'Community',
heading: 'Community',
items: [
items: [
{ name: 'Pastes', url: '/pastes' },
{ name: 'Pastes', url: '/pastes' },
- { name: 'Forum', url: '/s' },
+ { name: 'Forum', url: '/forum' },
{ name: 'Pulse', url: '/pulse' },
{ name: 'Pulse', url: '/pulse' },
{ name: 'New paste', url: '/new' },
{ name: 'New paste', url: '/new' },
{ name: 'My pastes', url: '/my' },
{ name: 'My pastes', url: '/my' },
- { name: 'Roadmap', url: '/s/roadmap' },
+ { name: 'Roadmap', url: '/forum/roadmap' },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Community' },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Community' },
],
],
}],
}],
@@ -137,7 +137,7 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
@@ -137,7 +137,7 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
// like an app"): what Community is and the two things to do first, the numbers as one strip, the newest pastes and the
// like an app"): what Community is and the two things to do first, the numbers as one strip, the newest pastes and the
// moments from streams as rails of the shared kit's cards (openvibe-shared kit.rail + kit.card, room.css) with See all
// moments from streams as rails of the shared kit's cards (openvibe-shared kit.rail + kit.card, room.css) with See all
// and the kinds as quick filters, the forum's places as a launcher (showcase.apps), the roadmap and Pulse as short lists,
// and the kinds as quick filters, the forum's places as a launcher (showcase.apps), the roadmap and Pulse as short lists,
-// and the parts read once (the API) folded. The full lists live on /pastes, /s, /s/roadmap and /pulse.
+// and the parts read once (the API) folded. The full lists live on /pastes, /forum, /forum/roadmap and /pulse.
const TERMINAL = `$ curl -F 'content=<build.log' \\
const TERMINAL = `$ curl -F 'content=<build.log' \\
https://openvibe.community/api/pastes
https://openvibe.community/api/pastes
{ "slug": "amber-fox-42",
{ "slug": "amber-fox-42",
@@ -218,18 +218,18 @@ function forumShelf(spaces) {
@@ -218,18 +218,18 @@ function forumShelf(spaces) {
return showcase.apps({
return showcase.apps({
id: 'forum', title: 'The forum',
id: 'forum', title: 'The forum',
items: spaces.slice(0, 8).map((sp) => ({
items: spaces.slice(0, 8).map((sp) => ({
- icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/s/${sp.slug}`,
+ icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/forum/${sp.slug}`,
text: `${sp.description ? `${ld.clean(sp.description, 90)} · ` : ''}${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}`,
text: `${sp.description ? `${ld.clean(sp.description, 90)} · ` : ''}${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}`,
})),
})),
- more: { href: '/s', label: 'All forums' },
+ more: { href: '/forum', label: 'All forums' },
});
});
}
}
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
function roadmapShelf(items) {
function roadmapShelf(items) {
if (!items || !items.length) return '';
if (!items || !items.length) return '';
- return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/s/roadmap', label: 'The whole roadmap' })}
-${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/s/roadmap/t/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
+ return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/forum/roadmap', label: 'The whole roadmap' })}
+${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/forum/roadmap/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
}
}
function pulseShelf(items) {
function pulseShelf(items) {
@@ -465,7 +465,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -465,7 +465,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
+ ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
</div>`;
@@ -61,7 +61,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin
@@ -61,7 +61,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin
// The first page of each filter is worth indexing; deep cursors and empty pages are not.
// The first page of each filter is worth indexing; deep cursors and empty pages are not.
robots: after || !items.length ? 'noindex,follow' : 'index,follow',
robots: after || !items.length ? 'noindex,follow' : 'index,follow',
active: 'pulse',
active: 'pulse',
- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
+ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
body,
body,
});
});
@@ -78,7 +78,7 @@ function createSearchDocuments({ db }) {
@@ -78,7 +78,7 @@ function createSearchDocuments({ db }) {
if (t.crosspost_of) facets.crosspost = true;
if (t.crosspost_of) facets.crosspost = true;
const doc = {
const doc = {
owner: 'community', type: 'thread', id: String(t.id), deleted: false, visibility: 'public',
owner: 'community', type: 'thread', id: String(t.id), deleted: false, visibility: 'public',
- canonical_url: `${config.baseUrl}/s/${encodeURIComponent(t.space_slug)}/t/${encodeURIComponent(t.slug)}`,
+ canonical_url: `${config.baseUrl}/forum/${encodeURIComponent(t.space_slug)}/${encodeURIComponent(t.slug)}`,
title: clean(t.title, 500) || 'Thread',
title: clean(t.title, 500) || 'Thread',
summary: clean(plain(opening && opening.body_markdown), 300) || `A thread in ${t.space_name} on OpenVibe.Community.`,
summary: clean(plain(opening && opening.body_markdown), 300) || `A thread in ${t.space_name} on OpenVibe.Community.`,
body: posts.map((p) => clean(plain(p.body_markdown), 8000)).filter(Boolean).join('\n').slice(0, 40000),
body: posts.map((p) => clean(plain(p.body_markdown), 8000)).filter(Boolean).join('\n').slice(0, 40000),
@@ -113,7 +113,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -113,7 +113,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
assert.strictEqual(page.attachments.enabled, true);
assert.strictEqual(page.attachments.enabled, true);
r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
assert.strictEqual(r.status, 400, 'already on a post');
assert.strictEqual(r.status, 400, 'already on a post');
- const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
+ const html = (await t.get(`/forum/general/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
});
});
@@ -122,7 +122,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -122,7 +122,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
fd.append('body', 'Two more');
fd.append('body', 'Two more');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
- const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
+ const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const reply = page.posts.find((p) => p.body_markdown === 'Two more');
const reply = page.posts.find((p) => p.body_markdown === 'Two more');
@@ -132,7 +132,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -132,7 +132,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
empty.append('body', '');
empty.append('body', '');
empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
const before = objects.size;
const before = objects.size;
- await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
+ await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
});
});
@@ -76,7 +76,7 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;
@@ -76,7 +76,7 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;
r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });
r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
// The no-JS form: the thread page again, the plain message in the reply form, the draft kept.
// The no-JS form: the thread page again, the plain message in the reply form, the draft kept.
- r = await call(`/s/general/t/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });
+ r = await call(`/forum/general/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');
assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');
assert.ok(r.text.includes('my <draft></textarea>'), 'the draft kept, escaped');
assert.ok(r.text.includes('my <draft></textarea>'), 'the draft kept, escaped');
@@ -115,22 +115,22 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
@@ -115,22 +115,22 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
});
});
await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
- let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });
+ let r = await call('/forum/feedback?category=ideas', { cookie: alexJwt });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const html = r.text;
const html = r.text;
assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));
assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));
assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');
assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');
- r = await call('/s/roadmap', { cookie: alexJwt });
+ r = await call('/forum/roadmap', { cookie: alexJwt });
assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));
assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));
assert.ok(r.text.includes('badge-status status-in_progress'));
assert.ok(r.text.includes('badge-status status-in_progress'));
- r = await call('/s/roadmap/new', { cookie: alexJwt });
+ r = await call('/forum/roadmap/new', { cookie: alexJwt });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });
+ r = await call(`/forum/feedback/${req.slug}`, { cookie: modJwt });
assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));
assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));
- r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
+ r = await call(`/forum/feedback/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');
assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');
- r = await call('/s/feedback/new', { cookie: alexJwt });
+ r = await call('/forum/feedback/new', { cookie: alexJwt });
assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));
assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));
});
});
@@ -125,9 +125,9 @@ function startStubChat() {
@@ -125,9 +125,9 @@ function startStubChat() {
chat.state.down = true;
chat.state.down = true;
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);
assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);
- const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });
+ const f = await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' });
assert.strictEqual(f.status, 303);
assert.strictEqual(f.status, 303);
- assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);
+ assert.match(f.headers.get('location'), /^\/forum\/showcase\?chat_error=.*#chat-room$/);
const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
chat.state.down = false;
chat.state.down = false;
@@ -135,23 +135,23 @@ function startStubChat() {
@@ -135,23 +135,23 @@ function startStubChat() {
});
});
await check('the space page links the room; its owner gets the forms; others only the link', async () => {
await check('the space page links the room; its owner gets the forms; others only the link', async () => {
- let page = await t.get('/s/showcase');
+ let page = await t.get('/forum/showcase');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /<section class="chat-room" id="chat-room"/);
assert.match(page.text, /<section class="chat-room" id="chat-room"/);
assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);
assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);
- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');
- page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });
- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');
- page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });
- assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));
- page = await t.get('/s/general');
+ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'no forms for a visitor');
+ page = await t.get('/forum/showcase', { cookies: [`ov_token=${samJwt}`] });
+ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'nor for another person');
+ page = await t.get('/forum/showcase', { cookies: [`ov_token=${alexJwt}`] });
+ assert.ok(page.text.includes('action="/forum/showcase/chat-room"') && page.text.includes('action="/forum/showcase/chat-room/detach"'));
+ page = await t.get('/forum/general');
assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');
assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');
- page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });
+ page = await t.get('/forum/general', { cookies: [`ov_token=${bossJwt}`] });
assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');
assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');
// Forum-style spaces show it too.
// Forum-style spaces show it too.
await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });
await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });
- assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
- assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
+ assert.match((await t.get('/forum/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
+ assert.strictEqual((await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
});
});
await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {
await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {
@@ -164,8 +164,8 @@ function startStubChat() {
@@ -164,8 +164,8 @@ function startStubChat() {
// The owner puts theirs back and detaches it without JavaScript: both sides go.
// The owner puts theirs back and detaches it without JavaScript: both sides go.
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
- const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
- assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
+ const f = await form(alexJwt, '/forum/showcase/chat-room/detach', {});
+ assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/forum/showcase#chat-room']);
assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));
assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));
assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
r = await api('DELETE', '/showcase/chat-room', alexJwt);
r = await api('DELETE', '/showcase/chat-room', alexJwt);
@@ -17,7 +17,7 @@ const { check, done } = require('./helpers/app');
@@ -17,7 +17,7 @@ const { check, done } = require('./helpers/app');
(async () => {
(async () => {
await check('robots.txt keeps the disallow set for every crawler and names the sitemap', () => {
await check('robots.txt keeps the disallow set for every crawler and names the sitemap', () => {
const txt = seo.robotsTxt({ sitemaps: ['https://openvibe.community/sitemap.xml'], disallow: discovery.ROBOTS_DISALLOW });
const txt = seo.robotsTxt({ sitemaps: ['https://openvibe.community/sitemap.xml'], disallow: discovery.ROBOTS_DISALLOW });
- assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']);
+ assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']);
// Shared 4.13 opens robots.txt with its content-signals note (a comment and Content-Signal); the rules follow.
// Shared 4.13 opens robots.txt with its content-signals note (a comment and Content-Signal); the rules follow.
assert.ok(txt.replace(/^(?:#[^\n]*\n|Content-Signal:[^\n]*\n|\n)*/, '').startsWith('User-agent: *\nAllow: /\nDisallow: /api/\n'));
assert.ok(txt.replace(/^(?:#[^\n]*\n|Content-Signal:[^\n]*\n|\n)*/, '').startsWith('User-agent: *\nAllow: /\nDisallow: /api/\n'));
assert.strictEqual(txt.split('Disallow: /*?sso=').length - 1, 2, 'the generic and the search/AI group both disallow');
assert.strictEqual(txt.split('Disallow: /*?sso=').length - 1, 2, 'the generic and the search/AI group both disallow');
@@ -29,10 +29,10 @@ const { check, done } = require('./helpers/app');
@@ -29,10 +29,10 @@ const { check, done } = require('./helpers/app');
const txt = seo.llmsTxt({ name: 'OpenVibe.Community', summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, markdown: true, base: 'https://openvibe.community', sections, optional });
const txt = seo.llmsTxt({ name: 'OpenVibe.Community', summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, markdown: true, base: 'https://openvibe.community', sections, optional });
assert.ok(txt.includes('## Browse\n\n- [OpenVibe.Community](https://openvibe.community/index.md): pastes, forum and network activity'));
assert.ok(txt.includes('## Browse\n\n- [OpenVibe.Community](https://openvibe.community/index.md): pastes, forum and network activity'));
assert.ok(txt.includes('- [Pastes](https://openvibe.community/pastes.md): public pastes, newest first'));
assert.ok(txt.includes('- [Pastes](https://openvibe.community/pastes.md): public pastes, newest first'));
- assert.ok(txt.includes('- [Forum](https://openvibe.community/s) (HTML): open discussion forums and their threads'));
+ assert.ok(txt.includes('- [Forum](https://openvibe.community/forum) (HTML): open discussion forums and their threads'));
assert.ok(txt.includes('- [Pulse](https://openvibe.community/pulse) (HTML): network activity'));
assert.ok(txt.includes('- [Pulse](https://openvibe.community/pulse) (HTML): network activity'));
assert.ok(txt.includes('## Optional\n\n- [Sitemap](https://openvibe.community/sitemap.xml)'));
assert.ok(txt.includes('## Optional\n\n- [Sitemap](https://openvibe.community/sitemap.xml)'));
- assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/s/feed.xml)'));
+ assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/forum/feed.xml)'));
assert.ok(txt.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));
assert.ok(txt.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));
assert.ok(txt.includes('labelled as AI-generated'));
assert.ok(txt.includes('labelled as AI-generated'));
});
});
@@ -57,11 +57,11 @@ const { check, done } = require('./helpers/app');
@@ -57,11 +57,11 @@ const { check, done } = require('./helpers/app');
await check('thread feed items use the 2.5.0 feedXml shape and keep the thread link as guid', () => {
await check('thread feed items use the 2.5.0 feedXml shape and keep the thread link as guid', () => {
const items = discovery.threadFeedItems([{ title: 'Hi <b>', space_slug: 'general', slug: 'hi-1', opening: 'Some **text**', created_at: '2026-09-15 10:00:00', last_activity_at: '2026-09-16 10:00:00' }]);
const items = discovery.threadFeedItems([{ title: 'Hi <b>', space_slug: 'general', slug: 'hi-1', opening: 'Some **text**', created_at: '2026-09-15 10:00:00', last_activity_at: '2026-09-16 10:00:00' }]);
assert.deepStrictEqual(Object.keys(items[0]), ['title', 'link', 'guid', 'description', 'content', 'author', 'published', 'updated']);
assert.deepStrictEqual(Object.keys(items[0]), ['title', 'link', 'guid', 'description', 'content', 'author', 'published', 'updated']);
- assert.strictEqual(items[0].link, 'https://openvibe.community/s/general/t/hi-1');
+ assert.strictEqual(items[0].link, 'https://openvibe.community/forum/general/hi-1');
assert.strictEqual(items[0].published, '2026-09-15T10:00:00.000Z');
assert.strictEqual(items[0].published, '2026-09-15T10:00:00.000Z');
assert.strictEqual(items[0].updated, '2026-09-16T10:00:00.000Z');
assert.strictEqual(items[0].updated, '2026-09-16T10:00:00.000Z');
- const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/s', description: 'd', selfUrl: 'https://openvibe.community/s/feed.xml', items }, { format: 'rss' });
- assert.ok(xml.includes('<atom:link href="https://openvibe.community/s/feed.xml" rel="self" type="application/rss+xml"/>'));
+ const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/forum', description: 'd', selfUrl: 'https://openvibe.community/forum/feed.xml', items }, { format: 'rss' });
+ assert.ok(xml.includes('<atom:link href="https://openvibe.community/forum/feed.xml" rel="self" type="application/rss+xml"/>'));
assert.ok(xml.includes('<title>Hi <b></title>'));
assert.ok(xml.includes('<title>Hi <b></title>'));
assert.ok(xml.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));
assert.ok(xml.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));
});
});
@@ -73,7 +73,7 @@ const { check, done } = require('./helpers/app');
@@ -73,7 +73,7 @@ const { check, done } = require('./helpers/app');
assert.strictEqual(out['@type'], 'DiscussionForumPosting');
assert.strictEqual(out['@type'], 'DiscussionForumPosting');
assert.deepStrictEqual(out.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
assert.deepStrictEqual(out.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
assert.deepStrictEqual(out.comment[0].author, { '@type': 'Organization', name: 'OpenVibe AI' });
assert.deepStrictEqual(out.comment[0].author, { '@type': 'Organization', name: 'OpenVibe AI' });
- assert.strictEqual(out.comment[0].url, 'https://openvibe.community/s/general/t/hi-1#post-2');
+ assert.strictEqual(out.comment[0].url, 'https://openvibe.community/forum/general/hi-1#post-2');
assert.strictEqual(ld.isoDate('nope'), null);
assert.strictEqual(ld.isoDate('nope'), null);
});
});
@@ -58,7 +58,7 @@ const comments = require('../server/comments/store');
@@ -58,7 +58,7 @@ const comments = require('../server/comments/store');
assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
evs.forEach(ok);
evs.forEach(ok);
assert.strictEqual(evs[0].visibility, 'public');
assert.strictEqual(evs[0].visibility, 'public');
- assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
+ assert.ok(/\/forum\/general\//.test(evs[0].payload.url));
assert.strictEqual(evs[2].visibility, 'internal');
assert.strictEqual(evs[2].visibility, 'internal');
assert.strictEqual(evs[2].payload.visibility, 'members');
assert.strictEqual(evs[2].payload.visibility, 'members');
assert.strictEqual(evs[2].payload.url, null);
assert.strictEqual(evs[2].payload.url, null);
@@ -489,7 +489,7 @@
@@ -489,7 +489,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s",
+ "path": "/forum",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -556,7 +556,7 @@
@@ -556,7 +556,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/general",
+ "path": "/forum/general",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -633,7 +633,7 @@
@@ -633,7 +633,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/discuss?paste={@paste}",
+ "path": "/forum/discuss?paste={@paste}",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -644,40 +644,40 @@
@@ -644,40 +644,40 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/general/feed.xml",
+ "path": "/forum/general/feed.xml",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
"kind": "other",
"kind": "other",
"from": [
"from": [
- "page /s/general"
+ "page /forum/general"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/general/new",
+ "path": "/forum/general/new",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
"kind": "html",
"kind": "html",
"from": [
"from": [
- "page /s/general"
+ "page /forum/general"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/general/t/{@thread_slug}",
+ "path": "/forum/general/{@thread_slug}",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
"kind": "html",
"kind": "html",
"from": [
"from": [
- "page /s/general"
+ "page /forum/general"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/general/t/n-1-thread",
+ "path": "/forum/general/n-1-thread",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -688,14 +688,14 @@
@@ -688,14 +688,14 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/auth/login?next=%2Fs%2Fgeneral%2Ft%2F{@thread_slug}",
+ "path": "/auth/login?next=%2Fforum%2Fgeneral%2Ft%2F{@thread_slug}",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 302,
"status": 302,
"kind": "html",
"kind": "html",
"location": "/oauth/authorize",
"location": "/oauth/authorize",
"from": [
"from": [
- "page /s/general/t/n-1-thread"
+ "page /forum/general/n-1-thread"
]
]
},
},
{
{
@@ -734,7 +734,7 @@
@@ -734,7 +734,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/s/feed.xml",
+ "path": "/forum/feed.xml",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -944,7 +944,7 @@
@@ -944,7 +944,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/new-space",
+ "path": "/forum/new",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -954,7 +954,7 @@
@@ -954,7 +954,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/chat-room",
+ "path": "/forum/general/chat-room",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -965,7 +965,7 @@
@@ -965,7 +965,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/chat-room/detach",
+ "path": "/forum/general/chat-room/detach",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -976,7 +976,7 @@
@@ -976,7 +976,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/new",
+ "path": "/forum/general/new",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -987,7 +987,7 @@
@@ -987,7 +987,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/settings",
+ "path": "/forum/general/settings",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -997,7 +997,7 @@
@@ -997,7 +997,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/category",
+ "path": "/forum/general/n-1-thread/category",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -1007,7 +1007,7 @@
@@ -1007,7 +1007,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/crosspost",
+ "path": "/forum/general/n-1-thread/crosspost",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -1018,7 +1018,7 @@
@@ -1018,7 +1018,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/delete",
+ "path": "/forum/general/n-1-thread/delete",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -1028,7 +1028,7 @@
@@ -1028,7 +1028,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/members-only",
+ "path": "/forum/general/n-1-thread/members-only",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -1038,7 +1038,7 @@
@@ -1038,7 +1038,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/react",
+ "path": "/forum/general/n-1-thread/react",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -1049,7 +1049,7 @@
@@ -1049,7 +1049,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/reply",
+ "path": "/forum/general/n-1-thread/reply",
"auth": "anon",
"auth": "anon",
"status": 303,
"status": 303,
"kind": "empty",
"kind": "empty",
@@ -1061,7 +1061,7 @@
@@ -1061,7 +1061,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/state",
+ "path": "/forum/general/n-1-thread/state",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -1071,7 +1071,7 @@
@@ -1071,7 +1071,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/status",
+ "path": "/forum/general/n-1-thread/status",
"auth": "anon",
"auth": "anon",
"status": 403,
"status": 403,
"kind": "html",
"kind": "html",
@@ -1081,7 +1081,7 @@
@@ -1081,7 +1081,7 @@
},
},
{
{
"method": "POST",
"method": "POST",
- "path": "/s/general/t/n-1-thread/vote",
+ "path": "/forum/general/n-1-thread/vote",
"auth": "anon",
"auth": "anon",
"status": 400,
"status": 400,
"kind": "html",
"kind": "html",
@@ -1,35 +1,63 @@
@@ -1,35 +1,63 @@
'use strict';
'use strict';
/**
/**
- * The forum is Community's again (2026-10-08 owner decision): /s and its nested paths serve the
- * forum here. OpenVibe.Space, which briefly held the forum, now hosts code and "spaces" and
- * redirects its forum paths to us; nothing here redirects to Space.
+ * The forum is Community's (2026-10-08 owner decision) and moved from /s to /forum (2026-11-07):
+ * /forum and its nested paths serve it, and every old /s address answers a permanent redirect
+ * (301 for reads, 308 for form posts) with the query string kept. Nothing here redirects to Space.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- await check('GET /s and nested paths serve the forum here, with no redirect to Space', async () => {
- for (const path of ['/s', '/s/general', '/s/general?sort=new']) {
+ await check('GET /forum and nested paths serve the forum here, with no redirect to Space', async () => {
+ for (const path of ['/forum', '/forum/general', '/forum/general?sort=new']) {
const r = await t.get(path);
const r = await t.get(path);
assert.strictEqual(r.status, 200, path);
assert.strictEqual(r.status, 200, path);
assert.strictEqual(r.headers.get('location'), null, path);
assert.strictEqual(r.headers.get('location'), null, path);
}
}
- const board = await t.get('/s');
+ const board = await t.get('/forum');
assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');
assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');
});
});
- await check('the boards are called the Forum (openvibe.space is a different product); /forum answers with a 301 to /s', async () => {
- const board = await t.get('/s');
+ await check('the boards are called the Forum; the nav and canonical use /forum', async () => {
+ const board = await t.get('/forum');
assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');
assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');
+ assert.ok(board.text.includes('<link rel="canonical" href="https://openvibe.community/forum">'), 'canonical is /forum');
const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').label, 'Forum', 'the nav says Forum');
+ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').label, 'Forum', 'the nav says Forum');
assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');
assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');
- for (const [from, to] of [['/forum', '/s'], ['/forum/', '/s'], ['/forum?x=1', '/s?x=1'], ['/forum/general?sort=new', '/s/general?sort=new'], ['/forum/general/t/hello', '/s/general/t/hello']]) {
+ });
+ await check('every old /s address answers a 301 to its /forum address, query kept', async () => {
+ const map = [
+ ['/s', '/forum'],
+ ['/s/', '/forum'],
+ ['/s?x=1', '/forum?x=1'],
+ ['/s/general', '/forum/general'],
+ ['/s/general?sort=new', '/forum/general?sort=new'],
+ ['/s/general/t/hello', '/forum/general/hello'],
+ ['/s/general/t/hello?page=2', '/forum/general/hello?page=2'],
+ ['/s/general/t/hello/feed.xml', '/forum/general/hello/feed.xml'],
+ ['/s/general/new', '/forum/general/new'],
+ ['/s/general/log', '/forum/general/log'],
+ ['/s/general/feed.xml', '/forum/general/feed.xml'],
+ ['/s/feed.xml', '/forum/feed.xml'],
+ ['/s/new-space', '/forum/new'],
+ ['/s/discuss', '/forum/discuss'],
+ ];
+ for (const [from, to] of map) {
const r = await t.get(from, { redirect: 'manual' });
const r = await t.get(from, { redirect: 'manual' });
assert.strictEqual(r.status, 301, from);
assert.strictEqual(r.status, 301, from);
assert.strictEqual(r.headers.get('location'), to, from);
assert.strictEqual(r.headers.get('location'), to, from);
}
}
const nope = await t.get('/forums', { redirect: 'manual' });
const nope = await t.get('/forums', { redirect: 'manual' });
- assert.notStrictEqual(nope.status, 301, '/forums is not /forum');
+ assert.notStrictEqual(nope.status, 301, '/forums is not the forum');
+ });
+ await check('an old form post answers a 308 to the /forum address, method and body kept', async () => {
+ const r = await t.get('/s/general/t/hello/reply', {
+ method: 'POST', redirect: 'manual',
+ body: new URLSearchParams({ body: 'old tab' }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ });
+ assert.strictEqual(r.status, 308);
+ assert.strictEqual(r.headers.get('location'), '/forum/general/hello/reply');
});
});
await t.close();
await t.close();
done();
done();
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug
- * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,
+ * Forum pages without JavaScript: /forum, /forum/:space (sort + pagination links), /forum/:space/:slug
+ * (posts, reply/vote/moderation forms), /forum/:space/new — plain form posts, SEO (canonical, OG,
* JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.
* JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -25,39 +25,39 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -25,39 +25,39 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
- await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
- const r = await t.get('/s');
+ await check('/forum lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
+ const r = await t.get('/forum');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
has(r.text, '<title>Forum · OpenVibe.Community</title>');
has(r.text, '<title>Forum · OpenVibe.Community</title>');
- has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');
+ has(r.text, '<link rel="canonical" href="https://openvibe.community/forum">');
has(r.text, '<meta name="robots" content="index,follow">');
has(r.text, '<meta name="robots" content="index,follow">');
- has(r.text, 'href="/s/feed.xml"');
- for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);
+ has(r.text, 'href="/forum/feed.xml"');
+ for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/forum/${s}"`);
assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));
assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);
+ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').active, true);
});
});
await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {
await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {
- const page = await t.get('/s/general/new');
+ const page = await t.get('/forum/general/new');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');
+ has(page.text, '/auth/login?next=%2Fforum%2Fgeneral%2Fnew');
has(page.text, '<meta name="robots" content="noindex,follow">');
has(page.text, '<meta name="robots" content="noindex,follow">');
- const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));
+ const anon = await t.get('/forum/general/new', form({ title: 'Anon thread', body: 'x' }));
assert.strictEqual(anon.status, 303);
assert.strictEqual(anon.status, 303);
- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);
- const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
+ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fforum%2Fgeneral%2Fnew$/);
+ const bad = await t.get('/forum/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
assert.strictEqual(bad.status, 400);
assert.strictEqual(bad.status, 400);
has(bad.text, 'Titles are 3 to 200 characters');
has(bad.text, 'Titles are 3 to 200 characters');
has(bad.text, '<img src=x onerror=alert(1)></textarea>');
has(bad.text, '<img src=x onerror=alert(1)></textarea>');
- const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
+ const ok = await t.get('/forum/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
assert.strictEqual(ok.status, 303, ok.text);
assert.strictEqual(ok.status, 303, ok.text);
- assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');
- const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
+ assert.strictEqual(ok.headers.get('location'), '/forum/general/how-do-i-b-embed-b-comments');
+ const foreign = await t.get('/forum/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
});
});
- const url = '/s/general/t/how-do-i-b-embed-b-comments';
+ const url = '/forum/general/how-do-i-b-embed-b-comments';
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
const r = await t.get(url);
const r = await t.get(url);
@@ -117,57 +117,57 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -117,57 +117,57 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
assert.ok(!r.text.includes(`action="${url}/reply"`));
assert.ok(!r.text.includes(`action="${url}/reply"`));
has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
- const list = await t.get('/s/general');
+ const list = await t.get('/forum/general');
has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
const admin = await t.get(url, as(adminJwt));
const admin = await t.get(url, as(adminJwt));
has(admin.text, 'Unpin');
has(admin.text, 'Unpin');
has(admin.text, 'Unlock');
has(admin.text, 'Unlock');
- const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
+ const mine = await t.get('/forum/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
const mineUrl = mine.headers.get('location');
const mineUrl = mine.headers.get('location');
assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);
assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);
const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));
const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));
assert.strictEqual(del.status, 303);
assert.strictEqual(del.status, 303);
- assert.strictEqual(del.headers.get('location'), '/s/general');
+ assert.strictEqual(del.headers.get('location'), '/forum/general');
assert.strictEqual((await t.get(mineUrl)).status, 404);
assert.strictEqual((await t.get(mineUrl)).status, 404);
});
});
await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
const space = await forumStore.getSpace(t.db, 'showcase');
const space = await forumStore.getSpace(t.db, 'showcase');
for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
- const p1 = await t.get('/s/showcase?sort=new');
+ const p1 = await t.get('/forum/showcase?sort=new');
assert.strictEqual(p1.status, 200);
assert.strictEqual(p1.status, 200);
- has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');
- has(p1.text, 'href="/s/showcase"');
- has(p1.text, 'href="/s/showcase?sort=top"');
- has(p1.text, 'rel="next" href="/s/showcase?sort=new&page=2"');
+ has(p1.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new">');
+ has(p1.text, 'href="/forum/showcase"');
+ has(p1.text, 'href="/forum/showcase?sort=top"');
+ has(p1.text, 'rel="next" href="/forum/showcase?sort=new&page=2"');
has(p1.text, 'Page 1 of 2');
has(p1.text, 'Page 1 of 2');
assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);
assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);
- const p2 = await t.get('/s/showcase?sort=new&page=2');
- has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');
- has(p2.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new&page=2">');
+ const p2 = await t.get('/forum/showcase?sort=new&page=2');
+ has(p2.text, 'rel="prev" href="/forum/showcase?sort=new"');
+ has(p2.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new&page=2">');
has(p2.text, 'Showcase item 1<');
has(p2.text, 'Showcase item 1<');
- assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);
- assert.strictEqual((await t.get('/s/nowhere')).status, 404);
+ assert.strictEqual((await t.get('/forum/showcase?page=9')).status, 404);
+ assert.strictEqual((await t.get('/forum/nowhere')).status, 404);
});
});
await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
const ins = await forumStore.getSpace(t.db, 'insiders');
const ins = await forumStore.getSpace(t.db, 'insiders');
await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
- const anon = await t.get('/s/insiders');
+ const anon = await t.get('/forum/insiders');
assert.strictEqual(anon.status, 303);
assert.strictEqual(anon.status, 303);
- assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
- const member = await t.get('/s/insiders', as(samJwt));
+ assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fforum%2Finsiders');
+ const member = await t.get('/forum/insiders', as(samJwt));
assert.strictEqual(member.status, 200);
assert.strictEqual(member.status, 200);
has(member.text, '<meta name="robots" content="noindex,nofollow">');
has(member.text, '<meta name="robots" content="noindex,nofollow">');
- const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));
+ const th = await t.get('/forum/insiders/members-only-chat', as(samJwt));
has(th.text, '<meta name="robots" content="noindex,nofollow">');
has(th.text, '<meta name="robots" content="noindex,nofollow">');
- assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);
- assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);
- const feed = await t.get('/s/feed.xml');
+ assert.strictEqual((await t.get('/forum/mods', as(samJwt))).status, 404);
+ assert.strictEqual((await t.get('/forum/mods', as(adminJwt))).status, 200);
+ const feed = await t.get('/forum/feed.xml');
assert.ok(!feed.text.includes('Members only chat'));
assert.ok(!feed.text.includes('Members only chat'));
- assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
+ assert.strictEqual((await t.get('/forum/insiders/feed.xml')).status, 404);
require('../server/discovery').resetCaches();
require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
const map = await t.get('/sitemap.xml');
assert.ok(!map.text.includes('members-only-chat'));
assert.ok(!map.text.includes('members-only-chat'));
@@ -176,7 +176,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -176,7 +176,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
const space = await forumStore.getSpace(t.db, 'feedback');
const space = await forumStore.getSpace(t.db, 'feedback');
await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
- const r = await t.get('/s/feedback/t/weekly-summary');
+ const r = await t.get('/forum/feedback/weekly-summary');
has(r.text, 'OpenVibe AI');
has(r.text, 'OpenVibe AI');
has(r.text, '<span class="badge badge-ai"');
has(r.text, '<span class="badge badge-ai"');
assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });
assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });
@@ -185,17 +185,17 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -185,17 +185,17 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
await check('sitemap, feeds and robots.txt include the forum', async () => {
await check('sitemap, feeds and robots.txt include the forum', async () => {
require('../server/discovery').resetCaches();
require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
const map = await t.get('/sitemap.xml');
- for (const loc of ['/s', '/s/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
- const feed = await t.get('/s/feed.xml');
+ for (const loc of ['/forum', '/forum/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
+ const feed = await t.get('/forum/feed.xml');
assert.strictEqual(feed.status, 200);
assert.strictEqual(feed.status, 200);
has(feed.text, '<rss version="2.0"');
has(feed.text, '<rss version="2.0"');
has(feed.text, `<link>https://openvibe.community${url}</link>`);
has(feed.text, `<link>https://openvibe.community${url}</link>`);
has(feed.text, '<title>How do I <b>embed</b> comments?</title>');
has(feed.text, '<title>How do I <b>embed</b> comments?</title>');
- const spaceFeed = await t.get('/s/showcase/feed.xml');
+ const spaceFeed = await t.get('/forum/showcase/feed.xml');
has(spaceFeed.text, 'Showcase item 27');
has(spaceFeed.text, 'Showcase item 27');
assert.ok(!spaceFeed.text.includes('embed'));
assert.ok(!spaceFeed.text.includes('embed'));
const robots = await t.get('/robots.txt');
const robots = await t.get('/robots.txt');
- has(robots.text, 'Disallow: /s/*/new');
+ has(robots.text, 'Disallow: /forum/*/new');
});
});
await t.close();
await t.close();
@@ -42,9 +42,9 @@ const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters')
@@ -42,9 +42,9 @@ const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters')
await check('the page renders the guide with its links (no raw HTML)', async () => {
await check('the page renders the guide with its links (no raw HTML)', async () => {
const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');
const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');
- const r = await call(`/s/feedback/t/${th.slug}`);
+ const r = await call(`/forum/feedback/${th.slug}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('href="/s/roadmap"'), 'same-site link');
+ assert.ok(r.text.includes('href="/forum/roadmap"'), 'same-site link');
assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));
assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));
});
});
@@ -37,15 +37,15 @@ const { boot, check, done } = require('./helpers/app');
@@ -37,15 +37,15 @@ const { boot, check, done } = require('./helpers/app');
r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });
r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');
- await call('/s/general/t/welcome-thread', { cookie: samJwt });
- await call('/s/general/t/welcome-thread', { cookie: samJwt });
- await call('/s/general/t/welcome-thread', { cookie: alexJwt });
+ await call('/forum/general/welcome-thread', { cookie: samJwt });
+ await call('/forum/general/welcome-thread', { cookie: samJwt });
+ await call('/forum/general/welcome-thread', { cookie: alexJwt });
const list = (await call('/api/v1/spaces/general/threads')).json();
const list = (await call('/api/v1/spaces/general/threads')).json();
assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');
assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');
const row = list.threads[0];
const row = list.threads[0];
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.last_post.author.username, 'sam');
assert.strictEqual(row.last_post.author.username, 'sam');
- const page = (await call('/s/general')).text;
+ const page = (await call('/forum/general')).text;
assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');
assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
});
});
@@ -53,7 +53,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -53,7 +53,7 @@ const { boot, check, done } = require('./helpers/app');
await check('the postbit avatar asks Media for the width it draws (the 64 px picture: 160)', async () => {
await check('the postbit avatar asks Media for the width it draws (the 64 px picture: 160)', async () => {
const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
await t.db.prepare('UPDATE subject_projection SET avatar_url = ? WHERE subject_id = ?').run(obj, alex.subject_id);
await t.db.prepare('UPDATE subject_projection SET avatar_url = ? WHERE subject_id = ?').run(obj, alex.subject_id);
- const html = (await call('/s/general/t/welcome-thread')).text;
+ const html = (await call('/forum/general/welcome-thread')).text;
assert.ok(html.includes(`<img class="postbit-avatar" src="${obj}?w=160"`), 'the 64 px postbit avatar asks for 160');
assert.ok(html.includes(`<img class="postbit-avatar" src="${obj}?w=160"`), 'the 64 px postbit avatar asks for 160');
await t.db.prepare('UPDATE subject_projection SET avatar_url = NULL WHERE subject_id = ?').run(alex.subject_id);
await t.db.prepare('UPDATE subject_projection SET avatar_url = NULL WHERE subject_id = ?').run(alex.subject_id);
});
});
@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');
const general = r.groups[0].spaces.find((s) => s.slug === 'general');
const general = r.groups[0].spaces.find((s) => s.slug === 'general');
assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);
assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);
assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');
assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');
- const html = (await call('/s')).text;
+ const html = (await call('/forum')).text;
assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Classic') && html.includes('badge-style">Feed'));
assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Classic') && html.includes('badge-style">Feed'));
});
});
@@ -85,9 +85,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -85,9 +85,9 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);
assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);
assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);
assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);
assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');
assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');
- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
+ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;
assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
- r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
+ r = await call('/forum/general/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');
await check('quote: ?quote= fills the reply box with the quoted post', async () => {
await check('quote: ?quote= fills the reply box with the quoted post', async () => {
const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];
const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];
- const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
+ const html = (await call(`/forum/general/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');
assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');
});
});
@@ -108,7 +108,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -108,7 +108,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');
for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
- const html = (await call('/s/general/t/welcome-thread')).text;
+ const html = (await call('/forum/general/welcome-thread')).text;
assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
@@ -117,10 +117,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -117,10 +117,10 @@ const { boot, check, done } = require('./helpers/app');
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
const paste = (await call('/p/snip1')).text;
const paste = (await call('/p/snip1')).text;
- assert.ok(paste.includes('/s/discuss?paste=snip1'));
- const picker = (await call('/s/discuss?paste=snip1', { cookie: alexJwt })).text;
- assert.ok(picker.includes('/s/general/new?paste=snip1') && !picker.includes('/s/roadmap/new'));
- const form = (await call('/s/general/new?paste=snip1', { cookie: alexJwt })).text;
+ assert.ok(paste.includes('/forum/discuss?paste=snip1'));
+ const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;
+ assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));
+ const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;
assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));
assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));
});
});
@@ -130,13 +130,13 @@ const { boot, check, done } = require('./helpers/app');
@@ -130,13 +130,13 @@ const { boot, check, done } = require('./helpers/app');
const cp = r.json().thread;
const cp = r.json().thread;
assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);
assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);
const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();
const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();
- assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');
+ assert.strictEqual(there.crosspost.from.url, '/forum/general/welcome-thread');
assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));
assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));
const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);
assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);
assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));
assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);
- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
+ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;
assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));
assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));
});
});
@@ -145,20 +145,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -145,20 +145,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });
r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });
assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);
assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);
- assert.ok((await call('/s/showcase')).text.includes('topic-table'));
- r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
+ assert.ok((await call('/forum/showcase')).text.includes('topic-table'));
+ r = await call('/forum/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));
- assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);
- r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
- assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');
+ assert.ok((await call('/forum/showcase')).text.includes('class="thread-list"'));
+ assert.strictEqual((await call('/forum/new', { cookie: alexJwt })).status, 403);
+ r = await call('/forum/new', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
+ assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/forum/music');
const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');
const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');
assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);
assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);
r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });
r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const idx = (await call('/api/v1/spaces')).json();
const idx = (await call('/api/v1/spaces')).json();
assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');
assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');
- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');
+ for (const slug of ['new', 'discuss', 'search', 'about', 'new-space', 'feed.xml']) {
+ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug, name: 'Nope' } })).status, 400, `${slug} is a reserved address`);
+ }
});
});
await done(t);
await done(t);
@@ -47,7 +47,7 @@ const forumStore = require('../server/forum/store');
@@ -47,7 +47,7 @@ const forumStore = require('../server/forum/store');
first = r.json().thread;
first = r.json().thread;
assert.strictEqual(first.slug, 'hello-world');
assert.strictEqual(first.slug, 'hello-world');
assert.strictEqual(first.title, 'Hello, World!');
assert.strictEqual(first.title, 'Hello, World!');
- assert.strictEqual(first.url, '/s/general/t/hello-world');
+ assert.strictEqual(first.url, '/forum/general/hello-world');
assert.strictEqual(first.author.subject, alex.subject_id);
assert.strictEqual(first.author.subject, alex.subject_id);
const op = r.json().post;
const op = r.json().post;
assert.strictEqual(op.is_opening, true);
assert.strictEqual(op.is_opening, true);
@@ -60,6 +60,17 @@ const forumStore = require('../server/forum/store');
@@ -60,6 +60,17 @@ const forumStore = require('../server/forum/store');
assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');
assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');
});
});
+ await check('a title that makes one of the forum\'s own route words takes -2, so /forum/<space>/<slug> stays a thread', async () => {
+ for (const [title, slug] of [['New', 'new-2'], ['Log', 'log-2'], ['Settings', 'settings-2'], ['Members', 'members-2'], ['Chat room', 'chat-room-2'], ['Discuss', 'discuss-2']]) {
+ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title, body: 'reserved word' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().thread.slug, slug, title);
+ assert.strictEqual(r.json().thread.url, `/forum/general/${slug}`);
+ }
+ const page = await t.get('/forum/general/new-2');
+ assert.strictEqual(page.status, 200, 'the thread renders at its new address');
+ });
+
await check('create thread: validation and who may post', async () => {
await check('create thread: validation and who may post', async () => {
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');
@@ -86,7 +97,7 @@ const forumStore = require('../server/forum/store');
@@ -86,7 +97,7 @@ const forumStore = require('../server/forum/store');
await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });
const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });
assert.strictEqual(r1.status, 201, r1.text);
assert.strictEqual(r1.status, 201, r1.text);
- assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);
+ assert.match(r1.json().url, /^\/forum\/general\/hello-world#post-\d+$/);
await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });
await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });
const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
assert.strictEqual(th.thread.reply_count, 2);
assert.strictEqual(th.thread.reply_count, 2);
@@ -20,7 +20,7 @@ const { check, done } = require('./helpers/app');
@@ -20,7 +20,7 @@ const { check, done } = require('./helpers/app');
assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));
assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));
assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');
assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');
assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');
assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');
- assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
+ assert.strictEqual(md('[home](/forum/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/forum/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');
assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');
assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
});
});
@@ -183,11 +183,11 @@ function startVip() {
@@ -183,11 +183,11 @@ function startVip() {
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);
assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);
const sitemap = (await call('/sitemap.xml')).text;
const sitemap = (await call('/sitemap.xml')).text;
- assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
- const feed = (await call('/s/feed.xml')).text;
+ assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');
+ const feed = (await call('/forum/feed.xml')).text;
assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));
assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));
- assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);
- const general = (await call('/s/general/feed.xml')).text;
+ assert.strictEqual((await call('/forum/showcase/feed.xml')).status, 404);
+ const general = (await call('/forum/general/feed.xml')).text;
assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
// A thread gated after the fact leaves Pulse as well.
// A thread gated after the fact leaves Pulse as well.
const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
@@ -200,34 +200,34 @@ function startVip() {
@@ -200,34 +200,34 @@ function startVip() {
});
});
await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
- const anon = await call(`/s/general/t/${secret.slug}`);
+ const anon = await call(`/forum/general/${secret.slug}`);
assert.strictEqual(anon.status, 403);
assert.strictEqual(anon.status, 403);
assert.ok(anon.text.includes('https://openvibe.vip/cora'));
assert.ok(anon.text.includes('https://openvibe.vip/cora'));
assert.ok(anon.text.includes('Backstage with Cora'));
assert.ok(anon.text.includes('Backstage with Cora'));
assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));
assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));
assert.ok(/noindex/.test(anon.text));
assert.ok(/noindex/.test(anon.text));
assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');
assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');
- const space = await call('/s/showcase', { who: stan });
+ const space = await call('/forum/showcase', { who: stan });
assert.strictEqual(space.status, 403);
assert.strictEqual(space.status, 403);
assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));
assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));
- assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
- const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });
+ assert.strictEqual((await call('/forum/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
+ const reply = await form(`/forum/general/${secret.slug}/reply`, stan, { body: 'sneaky' });
assert.strictEqual(reply.status, 403);
assert.strictEqual(reply.status, 403);
- const member = await call(`/s/general/t/${secret.slug}`, { who: mia });
+ const member = await call(`/forum/general/${secret.slug}`, { who: mia });
assert.strictEqual(member.status, 200);
assert.strictEqual(member.status, 200);
assert.ok(member.text.includes('secret backstage body'));
assert.ok(member.text.includes('secret backstage body'));
assert.ok(/noindex/.test(member.text));
assert.ok(/noindex/.test(member.text));
assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');
assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');
- const listing = await call('/s/general', { who: stan });
+ const listing = await call('/forum/general', { who: stan });
assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));
assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));
assert.ok(!listing.text.includes('secret backstage body'));
assert.ok(!listing.text.includes('secret backstage body'));
// The author's no-JS toggle.
// The author's no-JS toggle.
const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;
const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;
- const page = await call(`/s/general/t/${own.slug}`, { who: cora });
- assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));
- const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });
+ const page = await call(`/forum/general/${own.slug}`, { who: cora });
+ assert.ok(page.text.includes(`/forum/general/${own.slug}/members-only`));
+ const on = await form(`/forum/general/${own.slug}/members-only`, cora, { on: '1' });
assert.strictEqual(on.status, 303);
assert.strictEqual(on.status, 303);
- assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);
+ assert.strictEqual((await call(`/forum/general/${own.slug}`, { who: stan })).status, 403);
});
});
await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {
await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {
@@ -255,7 +255,7 @@ function startVip() {
@@ -255,7 +255,7 @@ function startVip() {
const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });
const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });
assert.strictEqual(r.json().space.members_only, null);
assert.strictEqual(r.json().space.members_only, null);
assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);
- assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));
+ assert.ok((await call('/sitemap.xml')).text.includes('/forum/showcase'));
});
});
await t.close();
await t.close();
@@ -47,9 +47,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -47,9 +47,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.strictEqual(reply.status, 201, reply.text);
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 ' } });
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);
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)} ` });
+ r = await form(`/forum/general/${locked.slug}/state`, bossJwt, { pinned: '1', rule: 'off-topic', reason: ` Pinned for ${'x'.repeat(300)} ` });
assert.strictEqual(r.status, 303, r.text);
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' });
+ r = await form(`/forum/general/${locked.slug}/post/${reply.json().post.id}/delete`, bossJwt, { rule: 'personal information', reason: 'Names someone' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
feedback = await thread('feedback', 'A request whose status changes');
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' } });
r = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/status`, { method: 'PUT', cookie: bossJwt, json: { status: 'planned', rule: 'duplicate', reason: 'Already planned elsewhere' } });
@@ -80,7 +80,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -80,7 +80,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: false, rule: 'not-a-rule' } });
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.status, 400, r.text);
assert.strictEqual(r.json().code, 'moderation.invalid_rule');
assert.strictEqual(r.json().code, 'moderation.invalid_rule');
- r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });
+ r = await form(`/forum/general/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });
assert.strictEqual(r.status, 400, r.text);
assert.strictEqual(r.status, 400, r.text);
has(r.text, 'The rule is one of spam');
has(r.text, 'The rule is one of spam');
assert.strictEqual((await rows()).length, before, 'a refused rule writes no row');
assert.strictEqual((await rows()).length, before, 'a refused rule writes no row');
@@ -90,7 +90,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -90,7 +90,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
});
});
await check('the log page and JSON API show what happened, and the space page links it', async () => {
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');
+ const page = await t.get('/forum/general/log');
assert.strictEqual(page.status, 200, page.text);
assert.strictEqual(page.status, 200, page.text);
has(page.text, '<h1>Moderation log</h1>');
has(page.text, '<h1>Moderation log</h1>');
has(page.text, 'A thread that will be moderated');
has(page.text, 'A thread that will be moderated');
@@ -103,12 +103,12 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -103,12 +103,12 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.deepStrictEqual(json.json().entries.map((e) => e.action), ['delete', 'pin', 'lock']);
assert.deepStrictEqual(json.json().entries.map((e) => e.action), ['delete', 'pin', 'lock']);
assert.strictEqual(json.json().space.slug, 'general');
assert.strictEqual(json.json().space.slug, 'general');
assert.ok(!json.text.includes(boss.subject_id));
assert.ok(!json.text.includes(boss.subject_id));
- const feedbackLog = await t.get('/s/feedback/log');
+ const feedbackLog = await t.get('/forum/feedback/log');
assert.strictEqual(feedbackLog.status, 200);
assert.strictEqual(feedbackLog.status, 200);
has(feedbackLog.text, 'A request whose status changes');
has(feedbackLog.text, 'A request whose status changes');
has(feedbackLog.text, 'Status changed');
has(feedbackLog.text, 'Status changed');
- const space = await t.get('/s/general');
- has(space.text, 'href="/s/general/log"');
+ const space = await t.get('/forum/general');
+ has(space.text, 'href="/forum/general/log"');
has(space.text, 'Moderation log');
has(space.text, 'Moderation log');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
});
});
@@ -116,10 +116,10 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -116,10 +116,10 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
await check('a thread feed is its newest posts, advertised in the thread head and linked on the page', async () => {
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**' } });
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);
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">`);
+ const page = await t.get(`/forum/feedback/${feedback.slug}`);
+ has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/forum/feedback/${feedback.slug}/feed.xml">`);
has(page.text, 'Follow this thread by RSS');
has(page.text, 'Follow this thread by RSS');
- const feed = await t.get(`/s/feedback/t/${feedback.slug}/feed.xml`);
+ const feed = await t.get(`/forum/feedback/${feedback.slug}/feed.xml`);
assert.strictEqual(feed.status, 200, feed.text);
assert.strictEqual(feed.status, 200, feed.text);
assert.match(feed.headers.get('content-type'), /application\/rss\+xml/);
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');
assert.match(feed.headers.get('cache-control'), /^public/, 'a public thread\'s feed may be cached');
@@ -129,9 +129,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -129,9 +129,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const items = feed.text.split('<item>').slice(1);
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[0], 'A feed reply with markup', 'the newest post is the first item');
has(items[1], 'A request whose status changes');
has(items[1], 'A request whose status changes');
- const space = await t.get('/s/feedback');
+ const space = await t.get('/forum/feedback');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
- has(space.text, '/s/feedback/feed.xml');
+ has(space.text, '/forum/feedback/feed.xml');
});
});
await check('a private space\'s log is not public; its members and staff read it', async () => {
await check('a private space\'s log is not public; its members and staff read it', async () => {
@@ -140,11 +140,11 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -140,11 +140,11 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.strictEqual(insider.status, 201, insider.text);
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/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');
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');
+ const anon = await t.get('/forum/insiders/log');
assert.notStrictEqual(anon.status, 200, 'signed out: no page');
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 t.get('/forum/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);
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`;
+ const feedUrl = `/forum/insiders/${insider.json().thread.slug}/feed.xml`;
assert.notStrictEqual((await t.get(feedUrl)).status, 200, 'signed out: no feed of a members thread');
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}`] });
const own = await t.get(feedUrl, { cookies: [`ov_token=${alexJwt}`] });
assert.strictEqual(own.status, 200, own.text);
assert.strictEqual(own.status, 200, own.text);
@@ -157,7 +157,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -157,7 +157,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const log = await rows();
const log = await rows();
assert.strictEqual(log.at(-1).action, 'delete');
assert.strictEqual(log.at(-1).action, 'delete');
assert.strictEqual(log.at(-1).rule, 'illegal content');
assert.strictEqual(log.at(-1).rule, 'illegal content');
- const page = await t.get('/s/general/log');
+ const page = await t.get('/forum/general/log');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
has(page.text, 'a removed thread');
has(page.text, 'a removed thread');
has(page.text, 'Removed by a moderator');
has(page.text, 'Removed by a moderator');
@@ -171,13 +171,13 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
@@ -171,13 +171,13 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const thread2 = await thread('general', 'A thread with moderation controls');
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' } });
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);
assert.strictEqual(reply.status, 201, reply.text);
- const page = await t.get(`/s/general/t/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });
+ const page = await t.get(`/forum/general/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
has(page.text, 'Rule applied…');
has(page.text, 'Rule applied…');
has(page.text, 'name="reason"');
has(page.text, 'name="reason"');
has(page.text, 'maxlength="280"');
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, `<form class="mod-form" method="post" action="/forum/general/${thread2.slug}/state">`);
+ has(page.text, `action="/forum/general/${thread2.slug}/post/${reply.json().post.id}/delete"`);
has(page.text, 'Delete post');
has(page.text, 'Delete post');
});
});
@@ -36,7 +36,7 @@ module.exports = {
@@ -36,7 +36,7 @@ module.exports = {
forms: true,
forms: true,
keep: () => true,
keep: () => true,
/** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */
/** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */
- crawl: ['/', '/pastes', '/p/{@paste}', '/s/general', '/s/general/t/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],
+ crawl: ['/', '/pastes', '/p/{@paste}', '/forum/general', '/forum/general/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],
origins: ['https://openvibe.community'],
origins: ['https://openvibe.community'],
/** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */
/** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */
samples: [
samples: [
@@ -62,10 +62,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -62,10 +62,10 @@ const { boot, check, done } = require('./helpers/app');
await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
const it = await bySource('thread', thread.id);
const it = await bySource('thread', thread.id);
- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);
+ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/forum/general/pulse-thread', alex.subject_id, 'user']);
const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');
assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');
- assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);
+ assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/forum/general/pulse-thread#post-${post.id}`);
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);
assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);
@@ -314,7 +314,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -314,7 +314,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
const locked = nextId();
const locked = nextId();
await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');
await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');
- const page = await call(`/s/general/t/${th.json().thread.slug}`);
+ const page = await call(`/forum/general/${th.json().thread.slug}`);
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
await t.db.prepare('UPDATE threads SET locked = 1').run();
await t.db.prepare('UPDATE threads SET locked = 1').run();
@@ -66,11 +66,11 @@ const { sqlTime } = require('../server/http/v1');
@@ -66,11 +66,11 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(msg.method, 'POST');
assert.strictEqual(msg.method, 'POST');
assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');
assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');
assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });
assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });
- assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/s\/general\/t\/relay-me-please>$/);
+ assert.match(msg.body.content, /^New thread in \*\*forum\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/forum\/general\/relay-me-please>$/);
assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');
assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');
- assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');
+ assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/forum/general/relay-me-please');
assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
- assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');
+ assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · forum/general');
const d = (await deliveries()).find((x) => x.thread_id === th.id);
const d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
const [row] = await mapOf('thread', th.id);
const [row] = await mapOf('thread', th.id);
@@ -234,7 +234,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -234,7 +234,7 @@ const { sqlTime } = require('../server/http/v1');
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
const r = hook.hits[1];
const r = hook.hits[1];
assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');
assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');
- assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);
+ assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*forum\/general\*\*: <https:\/\/openvibe\.community\/forum\/general\/replies-go-too#post-\d+>$/);
assert.strictEqual(r.body.embeds[0].description, 'First reply');
assert.strictEqual(r.body.embeds[0].description, 'First reply');
assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
const [row] = await mapOf('post', post.id);
const [row] = await mapOf('post', post.id);
@@ -428,7 +428,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -428,7 +428,7 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(failed.json().deliveries.length, 1);
assert.strictEqual(failed.json().deliveries.length, 1);
const dl = failed.json().deliveries[0];
const dl = failed.json().deliveries[0];
assert.match(dl.last_error, /400: Invalid Form Body/);
assert.match(dl.last_error, /400: Invalid Form Body/);
- assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/s/general/t/this-one-fails', 'create', 'direct']);
+ assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/forum/general/this-one-fails', 'create', 'direct']);
const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });
const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });
assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);
assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);
const st = await call('/api/v1/relay/status', { cookie: adminJwt });
const st = await call('/api/v1/relay/status', { cookie: adminJwt });
@@ -38,7 +38,7 @@ const { createSearchDocuments } = require('../server/search/documents');
@@ -38,7 +38,7 @@ const { createSearchDocuments } = require('../server/search/documents');
const ev = (await index())[0]; ok(ev);
const ev = (await index())[0]; ok(ev);
assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });
assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });
assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');
assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');
- assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);
+ assert.strictEqual(ev.payload.canonical_url, `${base}/forum/general/${thread.slug}`);
assert.strictEqual(ev.payload.summary, 'First post with a link');
assert.strictEqual(ev.payload.summary, 'First post with a link');
assert.ok(ev.payload.body.includes('a reply body'));
assert.ok(ev.payload.body.includes('a reply body'));
assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);
assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);
@@ -19,7 +19,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
@@ -19,7 +19,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
if (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');
if (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');
return {
return {
results: [
results: [
- { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/s/roadmap/t/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },
+ { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/forum/roadmap/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },
{ owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },
{ owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },
],
],
next_cursor: 'abc_123',
next_cursor: 'abc_123',
@@ -32,7 +32,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
@@ -32,7 +32,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
const r = await t.get('/search?q=forum&type=thread');
const r = await t.get('/search?q=forum&type=thread');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });
assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });
- assert.ok(r.text.includes('href="/s/roadmap/t/forum-boards"'));
+ assert.ok(r.text.includes('href="/forum/roadmap/forum-boards"'));
assert.ok(r.text.includes('Forum <b>boards</b>'), 'the title is escaped');
assert.ok(r.text.includes('Forum <b>boards</b>'), 'the title is escaped');
assert.ok(r.text.includes('classic <mark>forum</mark> boards <img'), 'only <mark> survives');
assert.ok(r.text.includes('classic <mark>forum</mark> boards <img'), 'only <mark> survives');
assert.ok(!r.text.includes('<img src=x'));
assert.ok(!r.text.includes('<img src=x'));
@@ -169,7 +169,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -169,7 +169,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await refused('gate it to sam\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: true } });
await refused('gate it to sam\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: true } });
await refused('gate it to alex\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: alex.subject_id } });
await refused('gate it to alex\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: alex.subject_id } });
for (const [suffix, form] of [['delete', {}], ['state', { pinned: '1', locked: '1' }], ['members-only', { on: '1' }], ['category', { category: '' }], ['status', { status: 'done' }]]) {
for (const [suffix, form] of [['delete', {}], ['state', { pinned: '1', locked: '1' }], ['members-only', { on: '1' }], ['category', { category: '' }], ['status', { status: 'done' }]]) {
- await refused(`form ${suffix}`, 'POST', `/s/general/t/${thread.thread.slug}/${suffix}`, ['sam'], { form });
+ await refused(`form ${suffix}`, 'POST', `/forum/general/${thread.thread.slug}/${suffix}`, ['sam'], { form });
}
}
// The right thread slug under the wrong space: not found, even for its author.
// The right thread slug under the wrong space: not found, even for its author.
await refused('delete it under another space', 'DELETE', `/api/v1/spaces/feedback/threads/${thread.thread.slug}`, ['alex', 'sam']);
await refused('delete it under another space', 'DELETE', `/api/v1/spaces/feedback/threads/${thread.thread.slug}`, ['alex', 'sam']);
@@ -203,11 +203,11 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -203,11 +203,11 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await check('spaces: the owner of one space cannot manage another\'s chat room; sam cannot change space settings, categories, gating or the board', async () => {
await check('spaces: the owner of one space cannot manage another\'s chat room; sam cannot change space settings, categories, gating or the board', async () => {
await refused('cora attaches a room to alex\'s space', 'PUT', '/api/v1/spaces/general/chat-room', ['cora', 'sam'], { json: { room: 'cora-room' } });
await refused('cora attaches a room to alex\'s space', 'PUT', '/api/v1/spaces/general/chat-room', ['cora', 'sam'], { json: { room: 'cora-room' } });
await refused('cora detaches alex\'s room', 'DELETE', '/api/v1/spaces/general/chat-room', ['cora', 'sam']);
await refused('cora detaches alex\'s room', 'DELETE', '/api/v1/spaces/general/chat-room', ['cora', 'sam']);
- await refused('cora detaches it by form', 'POST', '/s/general/chat-room/detach', ['cora'], { form: {} });
- await refused('cora attaches by form', 'POST', '/s/general/chat-room', ['cora'], { form: { room: 'cora-room' } });
+ await refused('cora detaches it by form', 'POST', '/forum/general/chat-room/detach', ['cora'], { form: {} });
+ await refused('cora attaches by form', 'POST', '/forum/general/chat-room', ['cora'], { form: { room: 'cora-room' } });
assert.ok(!chatCalls.some(([, room]) => room === 'cora-room'), 'Chat was never asked for cora\'s room on alex\'s space');
assert.ok(!chatCalls.some(([, room]) => room === 'cora-room'), 'Chat was never asked for cora\'s room on alex\'s space');
await refused('space settings', 'PUT', '/api/v1/spaces/general/settings', [...SAMS, 'cora'], { json: { name: 'Taken' } });
await refused('space settings', 'PUT', '/api/v1/spaces/general/settings', [...SAMS, 'cora'], { json: { name: 'Taken' } });
- await refused('space settings by form', 'POST', '/s/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });
+ await refused('space settings by form', 'POST', '/forum/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });
await refused('a category', 'PUT', '/api/v1/spaces/general/categories/taken', [...SAMS, 'alex'], { json: { name: 'Taken' } });
await refused('a category', 'PUT', '/api/v1/spaces/general/categories/taken', [...SAMS, 'alex'], { json: { name: 'Taken' } });
await refused('gate the space', 'PUT', '/api/v1/spaces/general/members-only', [...SAMS, 'alex'], { json: { owner: sam.subject_id } });
await refused('gate the space', 'PUT', '/api/v1/spaces/general/members-only', [...SAMS, 'alex'], { json: { owner: sam.subject_id } });
await refused('a board group', 'PUT', '/api/v1/space-groups/taken', SAMS, { json: { name: 'Taken' } });
await refused('a board group', 'PUT', '/api/v1/space-groups/taken', SAMS, { json: { name: 'Taken' } });
@@ -220,9 +220,9 @@ function formsOf(value) {
@@ -220,9 +220,9 @@ function formsOf(value) {
}
}
}
}
for (const [who, p] of Object.entries(bad)) {
for (const [who, p] of Object.entries(bad)) {
- for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/s', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
+ for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/forum', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
}
}
- for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/s/general/t/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {
+ for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/forum/general/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {
for (const [who, p] of Object.entries(personas)) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
for (const [who, p] of Object.entries(personas)) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
}
}
// Sign-in failures: a bad code, a missing state, a refused refresh token, a refused FedCM assertion.
// Sign-in failures: a bad code, a missing state, a refused refresh token, a refused FedCM assertion.
@@ -115,7 +115,7 @@ function inventory() {
@@ -115,7 +115,7 @@ function inventory() {
const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });
const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });
assert.strictEqual(th.status, 201, th.text);
assert.strictEqual(th.status, 201, th.text);
const slug = JSON.parse(th.text).thread.slug;
const slug = JSON.parse(th.text).thread.slug;
- assert.strictEqual((await send('GET', `/s/general/t/${slug}`)).status, 200);
+ assert.strictEqual((await send('GET', `/forum/general/${slug}`)).status, 200);
const p = await send('POST', '/api/pastes', { json: { title: urls[5], content: `curl ${urls[5]}\n` } });
const p = await send('POST', '/api/pastes', { json: { title: urls[5], content: `curl ${urls[5]}\n` } });
assert.strictEqual(p.status, 201, p.text);
assert.strictEqual(p.status, 201, p.text);
assert.strictEqual((await send('GET', `/p/${JSON.parse(p.text).slug}`)).status, 200);
assert.strictEqual((await send('GET', `/p/${JSON.parse(p.text).slug}`)).status, 200);
@@ -130,7 +130,7 @@ function inventory() {
@@ -130,7 +130,7 @@ function inventory() {
await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });
await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });
const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });
const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });
assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');
assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');
- for (const page of ['/', '/pulse', '/api/v1/pulse', '/s/general', '/feed.xml', '/s/feed.xml', '/sitemap.xml']) await send('GET', page);
+ for (const page of ['/', '/pulse', '/api/v1/pulse', '/forum/general', '/feed.xml', '/forum/feed.xml', '/sitemap.xml']) await send('GET', page);
await new Promise((r) => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
assert.deepStrictEqual(hits, [], 'Community fetched a URL it was handed');
assert.deepStrictEqual(hits, [], 'Community fetched a URL it was handed');
});
});
@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.text.includes('User-agent: *\nAllow: /'));
assert.ok(r.text.includes('User-agent: *\nAllow: /'));
- for (const d of ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);
+ for (const d of ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);
assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));
assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));
assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
});
});
@@ -92,7 +92,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -92,7 +92,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(page.viewer.can_moderate, false);
assert.strictEqual(page.viewer.can_moderate, false);
assert.strictEqual(page.viewer.can_delete, false);
assert.strictEqual(page.viewer.can_delete, false);
assert.strictEqual(page.posts[0].can_edit, false);
assert.strictEqual(page.posts[0].can_edit, false);
- assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
+ assert.strictEqual((await form(`/forum/showcase/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);
assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);
});
});
@@ -113,24 +113,24 @@ const { boot, check, done } = require('./helpers/app');
@@ -113,24 +113,24 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);
const before = (await call('/api/v1/spaces/general')).json().space.group;
const before = (await call('/api/v1/spaces/general')).json().space.group;
- r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
+ r = await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');
assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');
});
});
await check('(e) a space moderator cannot create a space or change the board index', async () => {
await check('(e) a space moderator cannot create a space or change the board index', async () => {
assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);
assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);
- assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
+ assert.strictEqual((await form('/forum/new', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);
assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);
assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);
assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);
});
});
await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {
await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {
- let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });
+ let r = await form('/forum/general/moderators', alex, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
assert.ok((await mods('general')).includes(sam.subject_id));
assert.ok((await mods('general')).includes(sam.subject_id));
- assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
- r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });
+ assert.strictEqual((await form('/forum/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
+ r = await form('/forum/general/moderators/remove', carol, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');
assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');
r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });
r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });
@@ -139,31 +139,31 @@ const { boot, check, done } = require('./helpers/app');
@@ -139,31 +139,31 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {
await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {
- let r = await form('/s/general/moderators', alex, { subject: '@Sam' });
+ let r = await form('/forum/general/moderators', alex, { subject: '@Sam' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
- assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);
+ assert.match(String(r.headers.get('location')), /^\/forum\/general#moderators$/);
assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');
assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');
- r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });
+ r = await form('/forum/general/moderators', alex, { subject: '@nobody-here' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
- assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
- assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
+ assert.match(String(r.headers.get('location')), /^\/forum\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
+ assert.match(String((await form('/forum/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');
assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');
for (const style of ['forum', 'feed']) {
for (const style of ['forum', 'feed']) {
- assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
- const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
+ assert.strictEqual((await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
+ const page = await call(`/forum/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
assert.strictEqual(page.status, 200, page.text);
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);
assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);
assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);
assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);
- assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);
+ assert.ok(page.text.includes('action="/forum/general/moderators"'), `${style}: the add form`);
assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);
assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);
assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);
assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);
assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);
assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);
for (const who of [bob, undefined]) {
for (const who of [bob, undefined]) {
- const other = await call('/s/general', { who });
+ const other = await call('/forum/general', { who });
assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);
assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);
}
}
}
}
- r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });
+ r = await form('/forum/general/moderators/remove', alex, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
});
});
@@ -59,7 +59,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -59,7 +59,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.navbar.service, 'community');
assert.strictEqual(cfg.navbar.service, 'community');
// The bar (2026-10-28): the forum first, every place with an icon (the phone app bar); search is the magnifier,
// The bar (2026-10-28): the forum first, every place with an icon (the phone app bar); search is the magnifier,
// which offers this site's own search page first.
// which offers this site's own search page first.
- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/s', '/pastes', '/pulse', '/new']);
+ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/forum', '/pastes', '/pulse', '/new']);
assert.ok(cfg.navbar.links.every((l) => l.icon) && cfg.navbar.appBar === true && cfg.navbar.search && cfg.navbar.search.page === '/search');
assert.ok(cfg.navbar.links.every((l) => l.icon) && cfg.navbar.appBar === true && cfg.navbar.search && cfg.navbar.search.page === '/search');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.history.type, 'page');
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
// The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose
// The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose
// FAQPage JSON-LD is exactly what the page shows.
// FAQPage JSON-LD is exactly what the page shows.
const forum = (/<section class="sc-sec sc-apps-sec" id="forum"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
const forum = (/<section class="sc-sec sc-apps-sec" id="forum"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
- assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/s/general">') && forum.includes('href="/s">All forums'), 'the forum\'s places as a launcher');
+ assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/forum/general">') && forum.includes('href="/forum">All forums'), 'the forum\'s places as a launcher');
assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');
assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');
const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');
const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');
const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
@@ -44,22 +44,22 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
@@ -44,22 +44,22 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
let boardUrl;
let boardUrl;
let feedUrl;
let feedUrl;
await check('the new-thread form has the picker, and a thread is posted with its labels', async () => {
await check('the new-thread form has the picker, and a thread is posted with its labels', async () => {
- const page = await t.get('/s/general/new', as(alexJwt));
+ const page = await t.get('/forum/general/new', as(alexJwt));
assert.ok(page.text.includes('<legend>Content labels</legend>') && page.text.includes('name="labels_form"'), 'the picker');
assert.ok(page.text.includes('<legend>Content labels</legend>') && page.text.includes('name="labels_form"'), 'the picker');
assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(page.text), 'its sheet');
assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(page.text), 'its sheet');
- let r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));
+ let r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
boardUrl = new URL(r.headers.get('location'), 'https://x').pathname;
boardUrl = new URL(r.headers.get('location'), 'https://x').pathname;
- r = await t.get('/s/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));
+ r = await t.get('/forum/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
feedUrl = new URL(r.headers.get('location'), 'https://x').pathname;
feedUrl = new URL(r.headers.get('location'), 'https://x').pathname;
- r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));
+ r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));
assert.strictEqual(r.status, 400, 'not a label');
assert.strictEqual(r.status, 400, 'not a label');
assert.ok(r.text.includes('Labels are from this list'), r.text.slice(0, 200));
assert.ok(r.text.includes('Labels are from this list'), r.text.slice(0, 200));
});
});
await check('the board: the row is labelled, its title cell is what a blur covers, the chips under the title', async () => {
await check('the board: the row is labelled, its title cell is what a blur covers, the chips under the title', async () => {
- const r = await t.get('/s/general');
+ const r = await t.get('/forum/general');
assert.ok(r.text.includes(` data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}">`), 'the row');
assert.ok(r.text.includes(` data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}">`), 'the row');
assert.ok(r.text.includes('<td class="topic-main" data-ov-labels-media>'), 'the cover goes on the title cell');
assert.ok(r.text.includes('<td class="topic-main" data-ov-labels-media>'), 'the cover goes on the title cell');
assert.ok(r.text.includes('<li class="ovk-label" data-label="spoilers">Spoilers</li><li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), 'in words');
assert.ok(r.text.includes('<li class="ovk-label" data-label="spoilers">Spoilers</li><li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), 'in words');
@@ -77,7 +77,7 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
@@ -77,7 +77,7 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
assert.ok(r.text.includes('css/labels.css'), 'with its sheet');
assert.ok(r.text.includes('css/labels.css'), 'with its sheet');
r = await t.get(feedUrl);
r = await t.get(feedUrl);
assert.ok(r.text.includes(`<div class="posts" data-ov-labels="flashing" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the feed style too');
assert.ok(r.text.includes(`<div class="posts" data-ov-labels="flashing" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the feed style too');
- r = await t.get('/s/feedback');
+ r = await t.get('/forum/feedback');
assert.ok(/<li class="thread-row[^"]*" data-ov-labels="flashing" data-ov-labels-owner="/.test(r.text), 'and its list');
assert.ok(/<li class="thread-row[^"]*" data-ov-labels="flashing" data-ov-labels-owner="/.test(r.text), 'and its list');
});
});
@@ -30,7 +30,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -30,7 +30,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
});
await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {
await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {
- const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
+ const ok = await t.get('/forum/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
assert.strictEqual(ok.status, 303, ok.text);
assert.strictEqual(ok.status, 303, ok.text);
const r = await t.get(ok.headers.get('location'));
const r = await t.get(ok.headers.get('location'));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);