openvibers/openvibe.community Commits
Submissions: a reviewed section for community deliverables (#3)
Changes
@@@ -7,7 +7,8 @@The community hub of the OpenVibe network: community-run, open source, free speech withinthe rules. It is the home of **pastes** (code, text and screenshots with a link), the**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product--embeds, and **Pulse**, the network's public activity. Submissions follow.++embeds, **Pulse**, the network's public activity, and [**submissions**](#submissions) — clips, art,++ideas and reports people send in for community review.It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) thatserver-renders every page — crawlers and no-JS readers get the whole thing — and adds a@@@ -18,7 +19,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- pastes (the only authority since 2026-09-22), their versions, likes andcomments- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,-- categories), Pulse (the network's public activity) and the Discord relay++ categories), Pulse (the network's public activity), submissions and the Discord relay- the `community.*` events, Search documents for public threads, and the `community.profile` user module- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);embedded PGlite in development), with Valkey for the per-actor limit counters@@@ -129,6 +130,10 @@ Pages (server-rendered HTML):| `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) || `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 |++| `POST /submissions/:slug/withdraw\|review` | The no-JS forms: the author withdraws; a moderator accepts or rejects with a note |++| `GET /submissions/review` | The moderators' queue (pending, newest first) || `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |API and machine endpoints:@@@ -139,6 +144,7 @@ API and machine endpoints:| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) || `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) || `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |++| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) || `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) || `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) || `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |@@@ -460,9 +466,9 @@ A read model of public activity across the network, with provenance (`pulse_itemper source service/type/id).- **Community's own**: new public pastes written by a person (not burn-after-read, not NSFW),-- new threads and replies in public spaces — recorded at write time, removed when deleted or-- made non-public, and re-checked against their source on every read, so private or unlisted-- things never show.++ new threads and replies in public spaces, and submissions once accepted — recorded at write time,++ removed when deleted, made non-public, withdrawn or rejected, and re-checked against their source++ on every read, so private, unlisted or unreviewed things never show.- **Other services** publish with `community.pulse.write`:`POST /api/v1/pulse/items { ref, title, url, origin?, occurred_at?, visibility? }` — theref's `service` must be the caller's own (`svc:live` → `live`), `visibility` other than@@@ -479,6 +485,40 @@ per source service/type/id).keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —they are never attributed to a person (roadmap §33); system items name no one either.++## Submissions++++Clips, art, ideas and reports people send in for community review (`submissions`, migration++`0003_submissions.sql`; `server/submissions/`). They have a table of their own and nothing to do with++the forum, so they stay here when spaces move to a service of their own.++++- **Who submits**: a signed-in person, from the browser (`ov_token` cookie, or `Authorization: Bearer`++ from another OpenVibe site through CORS). The author is the JWT's `subject_id` (`usr_…`) — never a++ body field. Signed out: 401 `auth.required` (the form sends you to sign in). Services cannot submit:++ openvibe-contracts has no submission capability yet (403 `capability.denied`). At most 10 a day per++ person (429 `submission.daily_limit`).++- **What**: `kind` (`clip`, `art`, `idea`, `report`), `title` (200), `body` (Markdown, 10 000), an http(s)++ `url` and/or a `media_ref` (an OpenVibe.Media object, `med_…`: the bytes stay in Media). A clip or art++ needs a link or a Media object.++- **Review**: new submissions are `pending`, seen by their author and by discussion moderators++ (site staff, or a service holding `community.comment.moderate` — `discussionModerator`) and 404 for++ everyone else. A moderator accepts or rejects (also a decision already made) with an optional note++ the author sees; the author withdraws while pending or accepted. Accepted ones are public pages++ (canonical, Open Graph, IndexNow), listed at `/submissions` and recorded in [Pulse](#pulse) as the++ author's (origin `user`); pending, rejected and withdrawn ones never are.++- **Pages** work without JavaScript: filters and paging are links, submit, withdraw, accept and++ reject are plain form posts (SameSite=Lax cookie; a foreign `Origin` is refused).++++| Route | Who | What |++| --- | --- | --- |++| `GET /api/v1/submissions?kind=&status=&mine=1&after=&limit=` | anyone | newest first, keyset cursor: accepted ones; `mine=1` your own, any status; moderators any `status` (others asking for one get 403 `submission.moderators_only`) |++| `POST /api/v1/submissions { kind, title, body?, url?, media_ref? }` | a signed-in person | 201 `{ submission }`, `pending` |++| `GET /api/v1/submissions/:slug` | see above | `{ submission }` — `reviewer` and `review_note` only for the author and moderators |++| `POST /api/v1/submissions/:slug/withdraw` | the author | `withdrawn` (409 `submission.not_withdrawable` once rejected or withdrawn) |++| `POST /api/v1/submissions/:slug/review { decision: accept\|reject, note? }` | moderators | `accepted` / `rejected` (409 `submission.withdrawn`) |++++Errors are problem+json. Account deletion erases a person's submissions (and their Pulse items) and++unsigns the decisions they made; the export has `submissions.json`; a subject merge moves both.++## Platform blocksPeople block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community@@@ -624,7 +664,8 @@ every read path and nobody acts on someone else's ids (`security-private`, `secusecrets, open redirects (`security-ssrf`, `security-secrets`, `open-redirect`); Pulse and the Eventsconsumer (`pulse`, `pulse-consumer`, `events`); revocation, blocks, account export and deletion(`revocation`, `blocks`, `account-data`); the Discord relay (`relay*`); per-actor limits--(`actor-limits`); graceful shutdown (`shutdown`); and the previous release against this one (`n-1`).++(`actor-limits`); submissions from the form to review, the page and Pulse (`submissions`); graceful++shutdown (`shutdown`); and the previous release against this one (`n-1`).## Security@@@ -702,6 +743,7 @@ server/forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored clientpulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)++ submissions/ clips, art, ideas, reports for review: store, service, api (/api/v1/submissions), routes (pages)relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (createsfrom Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS@@@ -713,6 +755,7 @@ server/render/pages.js home / browse / paste / new / my / error templatesrender/forum.js spaces / threads / thread / new-thread / members-only teaser templatesrender/pulse.js the /pulse page++ render/submissions.js the submissions list, a submission's page, the review queuerender/comments.js a comment thread's own pagerender/markdown.js the safe Markdown subset for postsrender/highlight.js highlight.js wrapper, language list, download extensions@@@ -728,8 +771,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,Spaces per streamer, game and project (with membership), the Discord relay's production roundtrip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for--comment threads arriving through Events, moving paste comments onto the typed comment--threads, and submissions (clips, art, ideas, reports for community review).++comment threads arriving through Events, and moving paste comments onto the typed comment++threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).## Related servicesnnew file mode 100644@@@ -0,0 +1,25 @@++-- phase: expand++-- T10 plan item 8: submissions — clips, art, ideas and reports people send in for community review++-- (server/submissions). Additive only: one new table and its indexes, nothing existing is touched.++-- People are Network subjects (usr_…), never a local integer; a clip or picture stays in OpenVibe.Media++-- and only its med_ reference is kept here.++CREATE TABLE submissions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ slug text COLLATE "C" NOT NULL UNIQUE,++ kind text COLLATE "C" NOT NULL CHECK(kind IN ('clip', 'art', 'idea', 'report')),++ title text COLLATE "C" NOT NULL,++ body text COLLATE "C" NOT NULL DEFAULT '',++ url text COLLATE "C",++ media_ref text COLLATE "C",++ author_subject text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'accepted', 'rejected', 'withdrawn')),++ reviewer_subject text COLLATE "C",++ review_note text COLLATE "C",++ reviewed_at text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++-- The public list (accepted) and the review queue (pending), newest first; a person's own.++CREATE INDEX idx_submissions_status ON submissions (status, created_at DESC, id DESC);++CREATE INDEX idx_submissions_author ON submissions (author_subject, created_at DESC, id DESC);@@@ -2,7 +2,7 @@"name": "openvibe-community","version": "1.0.0","private": true,-- "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",++ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, Pulse and submissions (clips, art, ideas and reports for community review). Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -17,6 +17,8 @@* GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)* 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)++ * /api/v1/submissions/* submissions (submissions/api.js)** Comments, the forum, Pulse and the relay live in Community's database, alongside the pastes* Community itself is the only authority for: the native API (pastes/api.js), pages, raw text@@@ -54,6 +56,9 @@ const { createDiscordGateway } = require('./relay/discord-gateway');const { createDiscordInbound } = require('./relay/inbound');const { createRelayApi } = require('./relay/api');const { createActorLimits } = require('./actor-limits');++const { createSubmissionService } = require('./submissions/service');++const { createSubmissionsApi } = require('./submissions/api');++const { createSubmissionPages } = require('./submissions/routes');const { pulsePage } = require('./render/pulse');const { extensionFor } = require('./render/highlight');const { createIndexNow } = require('openvibe-shared/indexnow');@@@ -156,8 +161,10 @@ async function createApp(opts = {}) {const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });const comments = createCommentService({ db, network, limits: opts.commentLimits });++ // Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.++ const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });seo.useForum(forum);-- Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, indexnow });++ Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow });if (opts.startRelay !== false) relay.start();// ── Pastes: Community's own store is the only authority ──@@@ -202,6 +209,8 @@ async function createApp(opts = {}) {// ── /api/pastes — the native paste API ───────────────────app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));++ // ── /api/v1/submissions — clips, art, ideas and reports for community review ──++ app.use('/api/v1/submissions', v1.cors(config.apiCorsOrigins), createSubmissionsApi({ submissions, viewers, limits }));// ── /api/v1: comments, forum, Pulse, relay admin ─────────// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.@@@ -385,6 +394,7 @@ async function createApp(opts = {}) {// ── Forum and Pulse pages ────────────────────────────────app.use(createForumRoutes({ forum, viewers, config }));app.use(createCommentPages({ comments, viewers, config }));++ app.use(createSubmissionPages({ submissions, viewers, config }));app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';let out;@@@ -36,7 +36,7 @@ const EXPORTS = [['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],-- ['activity.json', 'pulse_items', 'actor_subject'],++ ['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],];async function exportPart(db, subject) {@@@ -125,6 +125,14 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();}if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);++ // Their submissions go (Pulse items with them, below); a decision they made stays, unsigned.++ if (await hasTable(db, 'submissions')) {++ for (const r of await db.prepare(`SELECT slug FROM submissions WHERE author_subject IN ${S}`).all(...subjects)) {++ await db.prepare("DELETE FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").run(r.slug);++ }++ add(erased, 'submissions', (await db.prepare(`DELETE FROM submissions WHERE author_subject IN ${S}`).run(...subjects)).changes);++ await db.prepare(`UPDATE submissions SET reviewer_subject = NULL WHERE reviewer_subject IN ${S}`).run(...subjects);++ }for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {@@@ -14,6 +14,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;const AUTHORSHIP = [['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],++ ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'],];// Per person per item: [table, item column, recompute(db, itemId)]const PER_ITEM = [@@@ -6,7 +6,7 @@* Sources:* - Community's own public activity, recorded at write time (the hooks below): new public* pastes written by a person (not burn-after-read, not NSFW), new threads and replies in-- * public spaces. Deletes and visibility changes take items out again, and listItems()++ * public spaces, and submissions when they are accepted. Deletes and visibility changes take items out again, and listItems()* re-checks Community's own items at read time as well.* - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef* of their own (ref.service must be the calling service), title, url, origin, occurred_at.@@@ -89,6 +89,11 @@ function createPulse({ db, network = null, config = {} } = {}) {actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,});},++ async submissionAccepted(s) {++ if (!s || s.status !== 'accepted') return;++ await recordLocal('submission', s.slug, { title: s.title, path: `/submissions/${encodeURIComponent(s.slug)}`, actor: s.author_subject, origin: 'user', at: s.reviewed_at });++ },++ async submissionGone(slug) { await forgetLocal('submission', slug); },async threadGone(id) { await forgetLocal('thread', id); },async postGone(id) { await forgetLocal('post', id); },@@@ -29,8 +29,9 @@ async function removeItem(db, service, type, id) {/*** Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked-- * against their source at read time — a paste made private, a deleted post or a thread in a-- * space that stopped being public (or became members-only) never shows, even if nothing removed its item.++ * against their source at read time — a paste made private, a deleted post, a submission no longer++ * accepted or a thread in a space that stopped being public (or became members-only) never shows,++ * even if nothing removed its item.* opts.origin 'user' | 'ai' | 'system'* opts.before [occurred_at, id] of the last item already seen* → { rows, hasMore }@@@ -47,7 +48,8 @@ async function listItems(db, { origin = null, before = null, limit = 30 } = {})AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_idWHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'-- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)))++ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))++ OR (i.source_type = 'submission' AND EXISTS (SELECT 1 FROM submissions su WHERE su.slug = i.source_id AND su.status = 'accepted')))ORDER BY i.occurred_at DESC, i.id DESCLIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });const hasMore = rows.length > limit;@@@ -15,7 +15,7 @@ const { escapeHtml } = require('./highlight');const SITE_NAME = 'OpenVibe.Community';const NETWORK_URL = 'https://openvibe.network';--const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and soon submissions. Free speech within the rules.';++const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and submissions. Free speech within the rules.';const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up@@@ -170,7 +170,7 @@ const { url } = await res.json(); // "/p/<slug>"` },<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;return renderPage({title: null,-- description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — spaces and threads, and soon submissions. Free speech within the rules.',++ description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — spaces and threads, and submissions. Free speech within the rules.',canonicalPath: '/',active: 'home',jsonLd: [seo.websiteLd()],nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++++/**++ * Submission pages (server/submissions has the rules): the list with the submit form, one++ * submission, and the moderators' review queue. Filters and paging are links and every action is++ * a plain form post, so all of it works without JavaScript.++ */++const seo = require('../seo');++const { renderPage, abs } = require('./layout');++const { escapeHtml: esc } = require('./highlight');++const { renderMarkdown, markdownToText } = require('./markdown');++const { timeTag } = require('./pages');++const { who } = require('./forum');++++const KIND_LABELS = { clip: 'Clip', art: 'Art', idea: 'Idea', report: 'Report' };++const STATUS_LABELS = { pending: 'Pending review', accepted: 'Accepted', rejected: 'Not accepted', withdrawn: 'Withdrawn' };++++const listHref = ({ kind = '', mine = false, after = null } = {}, path = '/submissions') => {++ const q = new URLSearchParams();++ if (kind) q.set('kind', kind);++ if (mine) q.set('mine', '1');++ if (after) q.set('after', after);++ const s = q.toString();++ return `${path}${s ? `?${s}` : ''}`;++};++++const kindBadge = (s) => `<span class="badge">${esc(KIND_LABELS[s.kind] || s.kind)}</span>`;++const statusBadge = (s) => (s.status === 'accepted' ? '' : ` <span class="badge">${esc(STATUS_LABELS[s.status] || s.status)}</span>`);++++function itemHtml(s) {++ return `<li class="pulse-item">++ ${kindBadge(s)}++ <div class="pulse-main">++ <a class="thread-title" href="${esc(s.page_url)}">${esc(s.title)}</a>${statusBadge(s)}++ <p class="thread-meta">${who(s.author)} <span class="sep">·</span> ${timeTag(s.created_at)}</p>++ </div>++ </li>`;++}++++function listBlock(items, { empty, prev, next }) {++ return `${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : `<p class="empty">${esc(empty)}</p>`}++ ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;++}++++function submitForm({ user, values = {}, error = null }) {++ if (!user) return `<p class="alert"><a href="/auth/login?next=${encodeURIComponent('/submissions')}">Sign in with your OpenVibe account</a> to send a clip, art, an idea or a report. Reading needs no account.</p>`;++ const kinds = Object.entries(KIND_LABELS).map(([id, label]) => `<option value="${id}"${values.kind === id ? ' selected' : ''}>${label}</option>`).join('');++ return `<form class="paste-form" method="post" action="/submissions" id="submit">++ <h2>Submit something</h2>++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++ <label class="field"><span>Kind</span><select name="kind" required>${kinds}</select></label>++ <label class="field"><span>Title</span><input type="text" name="title" maxlength="200" required value="${esc(values.title || '')}"></label>++ <label class="field"><span>Link (a clip, a picture, anything with an address)</span><input type="url" name="url" maxlength="2000" value="${esc(values.url || '')}" placeholder="https://"></label>++ <label class="field"><span>Description (Markdown)</span><textarea name="body" rows="8" maxlength="10000">${esc(values.body || '')}</textarea></label>++ <div class="form-actions">++ <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Submit for review</button>++ <span class="muted small">Sending as <strong>${esc(user.display_name || user.username || 'you')}</strong>. Moderators review it before it is public. By submitting you agree to the <a href="/terms">rules</a>.</span>++ </div>++</form>`;++}++++/** GET /submissions — accepted ones (or ?mine=1, your own), the submit form when signed in. */++function submissionsPage({ items, kind = '', mine = false, after = null, nextCursor = null, user = null, moderator = false, values = {}, error = null }) {++ const tabs = [['', 'Everything'], ...Object.entries(KIND_LABELS)].map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(listHref({ kind: id, mine }))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');++ const links = user ? `<p class="muted small">${mine ? '<a href="/submissions">All accepted submissions</a>' : '<a href="/submissions?mine=1">Your submissions</a>'}${moderator ? ' · <a href="/submissions/review">Review queue</a>' : ''}</p>` : '';++ const body = `++<header class="page-head">++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Submissions</span></nav>++ <h1>${mine ? 'Your submissions' : 'Submissions'}</h1>++ <p class="muted">Clips, art, ideas and reports from the people of OpenVibe. Anyone signed in can submit; moderators review each one, and what they accept appears here and in <a href="/pulse">Pulse</a>.</p>++ ${links}++</header>++<nav class="tabs sort-tabs" aria-label="Filter by kind">${tabs}</nav>++<section data-results>++ ${listBlock(items, { empty: mine ? 'You have not submitted anything yet.' : 'Nothing accepted yet.', prev: after ? listHref({ kind, mine }) : null, next: nextCursor ? listHref({ kind, mine, after: nextCursor }) : null })}++</section>++${submitForm({ user, values, error })}`;++ return renderPage({++ title: mine ? 'Your submissions' : `Submissions${kind ? ` — ${KIND_LABELS[kind]}` : ''}`,++ description: 'Clips, art, ideas and reports from the people of OpenVibe, reviewed by the community\'s moderators.',++ canonicalPath: listHref({ kind, after }),++ robots: mine || after ? 'noindex,follow' : 'index,follow',++ active: 'submissions',++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],++ body,++ });++}++++/** GET /submissions/:slug */++function submissionPage({ submission: s, user = null, error = null }) {++ const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';++ const media = s.media_ref ? `<p class="muted small">OpenVibe.Media object <code>${esc(s.media_ref)}</code></p>` : '';++ const review = s.status !== 'pending' && (s.review_note || s.reviewer)++ ? `<aside class="alert"><strong>${esc(STATUS_LABELS[s.status])}</strong>${s.reviewer ? ` by ${who(s.reviewer)}` : ''}${s.reviewed_at ? ` · ${timeTag(s.reviewed_at)}` : ''}${s.review_note ? `<p>${esc(s.review_note)}</p>` : ''}</aside>` : '';++ const withdraw = s.can && s.can.withdraw ? `<form method="post" action="/submissions/${esc(s.slug)}/withdraw" class="inline-form"><button class="btn" type="submit">Withdraw</button></form>` : '';++ const reviewForm = s.can && s.can.review ? `<form class="paste-form" method="post" action="/submissions/${esc(s.slug)}/review">++ <h2>Review</h2>++ <label class="field"><span>Note to the author (optional)</span><textarea name="note" rows="3" maxlength="1000"></textarea></label>++ <div class="form-actions">++ <button class="btn btn-primary" type="submit" name="decision" value="accept">Accept</button>++ <button class="btn" type="submit" name="decision" value="reject">Reject</button>++ </div>++</form>` : '';++ const body = `++<article>++<header class="page-head">++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/submissions">Submissions</a> › <span aria-current="page">${esc(s.title)}</span></nav>++ <h1>${esc(s.title)}</h1>++ <p class="thread-meta">${kindBadge(s)}${statusBadge(s)} ${who(s.author)} <span class="sep">·</span> ${timeTag(s.created_at)}</p>++</header>++${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++${review}++${link}${media}++${s.body ? `<div class="md">${renderMarkdown(s.body)}</div>` : ''}++${withdraw}++</article>++${reviewForm}`;++ const accepted = s.status === 'accepted';++ const description = markdownToText(s.body || '').replace(/\s+/g, ' ').trim().slice(0, 200) || `${KIND_LABELS[s.kind]} submitted to OpenVibe.Community.`;++ return renderPage({++ title: s.title,++ description,++ canonicalPath: s.page_url,++ // Only accepted submissions are public pages.++ robots: accepted ? 'index,follow' : 'noindex,nofollow',++ ogType: 'article',++ active: 'submissions',++ published: s.created_at,++ modified: s.updated_at,++ jsonLd: accepted ? [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }, { name: s.title, url: abs(s.page_url) }])] : [],++ body,++ });++}++++/** GET /submissions/review — what is waiting for a moderator, oldest pages behind "Older". */++function reviewQueuePage({ items, after = null, nextCursor = null }) {++ const body = `++<header class="page-head">++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/submissions">Submissions</a> › <span aria-current="page">Review queue</span></nav>++ <h1>Review queue</h1>++ <p class="muted">Submissions waiting for a decision, newest first. Open one to accept or reject it.</p>++</header>++<section data-results>++ ${listBlock(items, { empty: 'Nothing is waiting for review.', prev: after ? '/submissions/review' : null, next: nextCursor ? listHref({ after: nextCursor }, '/submissions/review') : null })}++</section>`;++ return renderPage({ title: 'Review queue', description: 'Submissions waiting for review.', canonicalPath: '/submissions/review', robots: 'noindex,nofollow', active: 'submissions', footerVariant: 'compact', body });++}++++module.exports = { submissionsPage, submissionPage, reviewQueuePage, KIND_LABELS, STATUS_LABELS };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * /api/v1/submissions — clips, art, ideas and reports for community review (service.js has the rules).++ *++ * GET / ?kind=&status=&mine=1&after=<cursor>&limit= anyone (accepted); mine=1 own; moderators any++ * POST / { kind, title, body?, url?, media_ref? } a signed-in person (browser JWT)++ * GET /:slug accepted: anyone; else author/moderators++ * POST /:slug/withdraw the author++ * POST /:slug/review { decision: accept|reject, note? } discussion moderators++ *++ * Identity is the JWT (ov_token cookie or Bearer) or a service token, never the body. Errors are++ * problem+json.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { run, jsonBody } = require('../http/v1');++++function createSubmissionsApi({ submissions, viewers, limits }) {++ const router = express.Router();++ router.use(contracts.http.middleware());++ router.use(viewers.middleware());++ // Per-actor limits (server/actor-limits.js): reads take the defaults; the service's daily cap decides++ // how many a person sends, this caps the requests.++ router.use(limits.reads('community.submission.read'));++ const write = limits('community.submission.write', { minute: 20, hour: 120 });++ const review = limits('community.submission.review', { minute: 60, hour: 600 });++++ router.get('/', run(async (req) => await submissions.list(req.viewer, req.query)));++ router.post('/', write, jsonBody, run(async (req) => await submissions.create(req.viewer, req.body || {}), 201));++ router.get('/:slug', run(async (req) => await submissions.get(req.viewer, req.params.slug)));++ router.post('/:slug/withdraw', write, run(async (req) => await submissions.withdraw(req.viewer, req.params.slug)));++ router.post('/:slug/review', review, jsonBody, run(async (req) => await submissions.review(req.viewer, req.params.slug, req.body || {})));++++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));++ return router;++}++++module.exports = { createSubmissionsApi };nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++++/**++ * Submission pages (render/submissions.js has the markup, service.js the rules):++ *++ * GET /submissions accepted ones, ?kind=, ?mine=1 (your own), ?after=; the submit form when signed in++ * POST /submissions submit (signed in; the no-JS form)++ * GET /submissions/review the moderators' queue (pending), ?after=++ * GET /submissions/:slug one submission (accepted: anyone; else its author and moderators)++ * POST /submissions/:slug/withdraw the author++ * POST /submissions/:slug/review moderators: decision=accept|reject, note++ *++ * Form posts carry the ov_token cookie (SameSite=Lax) and an Origin from another site is refused,++ * as in the forum.++ */++const express = require('express');++const pages = require('../render/pages');++const { submissionsPage, submissionPage, reviewQueuePage } = require('../render/submissions');++const { ApiError } = require('../http/v1');++++function createSubmissionPages({ submissions, viewers, config }) {++ const router = express.Router();++ const withViewer = viewers.middleware({ services: false });++ const form = express.urlencoded({ extended: false, limit: '64kb' });++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++ const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);++++ function failPage(req, res, err, next, back) {++ if (!(err instanceof ApiError)) return next(err);++ if (err.status === 401) return login(res, back || req.originalUrl);++ const titles = { 404: 'Not found', 403: 'Not allowed', 409: 'Already decided', 429: 'Slow down' };++ return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.status === 404 ? 'There is no submission at that address.' : err.message }), err.status);++ }++++ function sameOrigin(req, res, next) {++ const origin = req.get('origin');++ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);++ next();++ }++++ async function renderList(req, res, { status = 200, values = {}, error = null } = {}) {++ const kind = submissions.KINDS.includes(req.query.kind) ? req.query.kind : '';++ const mine = !!req.user && req.query.mine === '1';++ const after = req.query.after ? String(req.query.after) : null;++ // The list page is the accepted ones for everybody, moderators too (they have the queue).++ const out = await submissions.list(req.viewer, { kind, mine: mine ? '1' : '', status: mine ? '' : 'accepted', after });++ html(res, submissionsPage({ items: out.submissions, kind, mine, after, nextCursor: out.next_cursor, user: req.user, moderator: submissions.moderates(req.viewer), values, error }), status);++ }++++ router.get('/submissions', withViewer, wrap(async (req, res, next) => {++ try { await renderList(req, res); }++ catch (err) {++ if (err instanceof ApiError && err.code === 'request.invalid_cursor') return res.redirect(302, '/submissions');++ failPage(req, res, err, next);++ }++ }));++++ router.post('/submissions', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const b = req.body || {};++ const values = { kind: String(b.kind || ''), title: String(b.title || '').slice(0, 300), url: String(b.url || '').slice(0, 2100), body: String(b.body || '').slice(0, 12000) };++ try {++ // Signed-in people only (a browser's own JWT); the same rules as the API.++ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to submit');++ const out = await submissions.create(req.viewer, values);++ res.redirect(303, out.submission.page_url);++ } catch (err) {++ if (!(err instanceof ApiError) || err.status === 401) return failPage(req, res, err, next, '/submissions');++ try { await renderList(req, res, { status: err.status, values, error: err.message }); } catch (e) { failPage(req, res, e, next); }++ }++ }));++++ router.get('/submissions/review', withViewer, wrap(async (req, res, next) => {++ try {++ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to review');++ if (!submissions.moderates(req.viewer)) throw new ApiError(403, 'submission.moderators_only', 'Only moderators review submissions.');++ const after = req.query.after ? String(req.query.after) : null;++ const out = await submissions.list(req.viewer, { status: 'pending', after });++ html(res, reviewQueuePage({ items: out.submissions, after, nextCursor: out.next_cursor }));++ } catch (err) {++ if (err instanceof ApiError && err.code === 'request.invalid_cursor') return res.redirect(302, '/submissions/review');++ failPage(req, res, err, next);++ }++ }));++++ router.get('/submissions/:slug', withViewer, wrap(async (req, res, next) => {++ try { html(res, submissionPage({ submission: (await submissions.get(req.viewer, req.params.slug)).submission, user: req.user })); }++ catch (err) { failPage(req, res, err, next); }++ }));++++ // Withdraw and review: back to the submission's page (303), or the page with the reason.++ for (const action of ['withdraw', 'review']) {++ router.post(`/submissions/:slug/${action}`, withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const page = `/submissions/${encodeURIComponent(req.params.slug)}`;++ try {++ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in first');++ const b = req.body || {};++ if (action === 'withdraw') await submissions.withdraw(req.viewer, req.params.slug);++ else await submissions.review(req.viewer, req.params.slug, { decision: String(b.decision || ''), note: String(b.note || '').slice(0, 1200) });++ res.redirect(303, page);++ } catch (err) {++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next, page);++ try { html(res, submissionPage({ submission: (await submissions.get(req.viewer, req.params.slug)).submission, user: req.user, error: err.message }), err.status); }++ catch (e) { failPage(req, res, e, next); }++ }++ }));++ }++++ return router;++}++++module.exports = { createSubmissionPages };nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * Submissions — clips, art, ideas and reports people send in for community review.++ *++ * - Signed-in people submit (a browser's JWT subject, usr_…); signed-out visitors and services do++ * not: openvibe-contracts has no submission capability yet, so there is nothing for a service to hold.++ * - A new submission is pending: only its author and discussion moderators++ * (identity/capabilities.js discussionModerator) see it. Accepted ones are public; rejected and++ * withdrawn ones go back to being seen by their author and moderators only (anyone else: 404).++ * - The author withdraws (pending or accepted); a moderator accepts or rejects (pending, or a decision++ * already made), with an optional note the author sees.++ * - Accepted submissions enter Pulse as the author's (origin user); leaving accepted takes them out++ * again, and Pulse re-checks the status on every read (pulse/store.js).++ * - A clip or picture stays in OpenVibe.Media: a submission keeps a link (url) and/or a med_ reference.++ *++ * Identity only ever comes from the viewer (JWT or service token), never from a body.++ */++const store = require('./store');++const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/v1');++const { discussionModerator } = require('../identity/capabilities');++const { createAuthors } = require('../identity/authors');++++const KINDS = ['clip', 'art', 'idea', 'report'];++const STATUSES = ['pending', 'accepted', 'rejected', 'withdrawn'];++const PAGE = 30;++const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const SLUG_RE = /^[a-z]+-[a-z]+-\d{3,6}$/;++const LIMITS = { title: 200, body: 10000, url: 2000, note: 1000, perDay: 10 };++++function createSubmissionService({ db, network = null, pulse = null, indexnow = null, config = {}, limits = {} } = {}) {++ const authors = createAuthors({ db, network });++ const base = (config.baseUrl || '').replace(/\/$/, '');++ const perDay = limits.perDay != null ? limits.perDay : LIMITS.perDay;++ const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Submissions] pulse:', err.message); } };++ const ping = (slug) => { if (indexnow && indexnow.enabled) indexnow.pingSoon([`${base}/submissions/${slug}`]); };++++ const isAuthor = (v, s) => !!(v && v.kind === 'user' && v.subject && v.subject === s.author_subject);++ const moderates = (v) => discussionModerator(v);++ const canSee = (v, s) => s.status === 'accepted' || isAuthor(v, s) || moderates(v);++++ function shape(s, v, projections) {++ const close = isAuthor(v, s) || moderates(v);++ return {++ slug: s.slug,++ kind: s.kind,++ title: s.title,++ body: s.body,++ url: s.url || null,++ media_ref: s.media_ref || null,++ status: s.status,++ author: authors.author(s.author_subject, 'user', projections),++ // Who reviewed and what they said: the author and moderators only.++ reviewer: close && s.reviewer_subject ? authors.author(s.reviewer_subject, 'user', projections) : null,++ review_note: close ? (s.review_note || null) : null,++ reviewed_at: isoTime(s.reviewed_at),++ created_at: isoTime(s.created_at),++ updated_at: isoTime(s.updated_at),++ page_url: `/submissions/${s.slug}`,++ can: { withdraw: isAuthor(v, s) && ['pending', 'accepted'].includes(s.status), review: moderates(v) && s.status !== 'withdrawn' },++ };++ }++ const shapeOne = async (s, v) => shape(s, v, await authors.projectionsFor([s.author_subject, s.reviewer_subject]));++++ /** The row a viewer may see, or 404 (never a 403 that confirms a slug exists). */++ async function visible(v, slug) {++ const s = SLUG_RE.test(String(slug || '')) ? await store.getBySlug(db, slug) : null;++ if (!s || !canSee(v, s)) fail(404, 'submission.not_found', 'Submission not found');++ return s;++ }++++ function text(value, max, code, what, { required = false } = {}) {++ const t = String(value == null ? '' : value).replace(/\r\n/g, '\n').trim();++ if (required && !t) fail(400, code, `${what} is required`);++ if (t.length > max) fail(400, code, `${what} is at most ${max} characters`);++ return t;++ }++++ return {++ KINDS, STATUSES,++ moderates,++++ /** POST /api/v1/submissions { kind, title, body?, url?, media_ref? } — a signed-in person. */++ async create(v, body = {}) {++ if (!v || v.kind === 'anonymous' || !v.subject) fail(401, 'auth.required', 'Sign in to submit');++ if (v.kind !== 'user') fail(403, 'capability.denied', 'Submissions are sent by people, from their browser');++ if (!/^usr_/.test(v.subject)) fail(403, 'submission.account_required', 'Submissions need an OpenVibe account');++ const kind = String(body.kind || '');++ if (!KINDS.includes(kind)) fail(400, 'submission.invalid_kind', `kind must be one of ${KINDS.join(', ')}`);++ const title = text(body.title, LIMITS.title, 'submission.invalid_title', 'A title', { required: true }).replace(/\s+/g, ' ');++ const text_ = text(body.body, LIMITS.body, 'submission.invalid_body', 'The description');++ const url = text(body.url, LIMITS.url, 'submission.invalid_url', 'The link') || null;++ if (url) {++ let parsed = null;++ try { parsed = new URL(url); } catch { /* invalid */ }++ if (!parsed || !/^https?:$/.test(parsed.protocol)) fail(400, 'submission.invalid_url', 'The link must be an http(s) address');++ }++ const mediaRef = String(body.media_ref == null ? '' : body.media_ref).trim() || null;++ if (mediaRef && !MED_ID.test(mediaRef)) fail(400, 'submission.invalid_media_ref', 'media_ref must be an OpenVibe.Media object id (med_…)');++ if ((kind === 'clip' || kind === 'art') && !url && !mediaRef) fail(400, 'submission.media_required', 'A clip or art submission needs a link or a Media object');++ if (!text_ && !url && !mediaRef) fail(400, 'submission.empty', 'Describe it, link it, or both');++ if (perDay > 0 && await store.countByAuthorSince(db, v.subject, sqlTime(Date.now() - 86_400_000)) >= perDay) {++ fail(429, 'submission.daily_limit', `At most ${perDay} submissions a day — try again tomorrow`, { retry_after: 3600 });++ }++ const row = await store.insert(db, { slug: await store.generateSlug(db), kind, title, body: text_, url, media_ref: mediaRef, author_subject: v.subject });++ return { submission: await shapeOne(row, v) };++ },++++ /** GET /api/v1/submissions/:slug */++ async get(v, slug) {++ return { submission: await shapeOne(await visible(v, slug), v) };++ },++++ /**++ * GET /api/v1/submissions ?kind=&status=&mine=1&after=&limit=++ * Everyone: accepted. mine=1: the signed-in person's own, any status. Moderators: any status.++ */++ async list(v, q = {}) {++ const kind = q.kind == null || q.kind === '' ? null : String(q.kind);++ if (kind && !KINDS.includes(kind)) fail(400, 'submission.invalid_kind', `kind must be one of ${KINDS.join(', ')}`);++ let status = q.status == null || q.status === '' ? null : String(q.status);++ if (status && !STATUSES.includes(status)) fail(400, 'submission.invalid_status', `status must be one of ${STATUSES.join(', ')}`);++ const mine = q.mine === '1' || q.mine === 'true' || q.mine === true;++ let author = null;++ if (mine) {++ if (!v || v.kind !== 'user' || !v.subject) fail(401, 'auth.required', 'Sign in to see your submissions');++ author = v.subject;++ } else if (!moderates(v)) {++ if (status && status !== 'accepted') fail(403, 'submission.moderators_only', 'Only moderators see submissions under review');++ status = 'accepted';++ }++ const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);++ const before = decodeCursor(q.after, 2);++ const { rows, hasMore } = await store.list(db, { status, author, kind, before, limit });++ const projections = await authors.projectionsFor(rows.flatMap((r) => [r.author_subject, r.reviewer_subject]));++ const last = rows[rows.length - 1];++ return { submissions: rows.map((r) => shape(r, v, projections)), next_cursor: hasMore && last ? encodeCursor([last.created_at, last.id]) : null };++ },++++ /** POST /api/v1/submissions/:slug/withdraw — the author, while pending or accepted. */++ async withdraw(v, slug) {++ const s = await visible(v, slug);++ if (!isAuthor(v, s)) fail(403, 'submission.not_author', 'Only its author withdraws a submission');++ const next = await store.setStatus(db, s.id, { status: 'withdrawn', from: ['pending', 'accepted'] });++ if (!next) fail(409, 'submission.not_withdrawable', `A ${s.status} submission cannot be withdrawn`);++ if (s.status === 'accepted') { await tell((p) => p.submissionGone(s.slug)); ping(s.slug); }++ return { submission: await shapeOne(next, v) };++ },++++ /** POST /api/v1/submissions/:slug/review { decision: accept|reject, note? } — moderators. */++ async review(v, slug, body = {}) {++ if (!moderates(v)) {++ if (!v || v.kind === 'anonymous') fail(401, 'auth.required', 'Sign in to review');++ await visible(v, slug); // 404 first for what this viewer may not see++ fail(403, 'submission.moderators_only', 'Only moderators review submissions');++ }++ const s = await visible(v, slug);++ const decision = String(body.decision || '');++ if (!['accept', 'reject'].includes(decision)) fail(400, 'submission.invalid_decision', 'decision must be accept or reject');++ const note = text(body.note, LIMITS.note, 'submission.invalid_note', 'The note') || null;++ const status = decision === 'accept' ? 'accepted' : 'rejected';++ const next = await store.setStatus(db, s.id, { status, from: ['pending', 'accepted', 'rejected'], reviewer_subject: v.subject || null, review_note: note, reviewed: true });++ if (!next) fail(409, 'submission.withdrawn', 'Its author withdrew this submission');++ if (status === 'accepted') await tell((p) => p.submissionAccepted(next));++ else await tell((p) => p.submissionGone(next.slug));++ if (s.status === 'accepted' || status === 'accepted') ping(s.slug);++ return { submission: await shapeOne(next, v) };++ },++ };++}++++module.exports = { createSubmissionService, KINDS, STATUSES, LIMITS };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * Submissions store — pure SQL over submissions (migrations/0003_submissions.sql).++ */++const crypto = require('crypto');++const { SLUG_ADJECTIVES, SLUG_NOUNS } = require('../pastes/store');++++/** adj-noun-NNNN, like a public paste's slug with a wider number; unique in submissions. */++async function generateSlug(db) {++ const taken = db.prepare('SELECT 1 FROM submissions WHERE slug = ?');++ for (let i = 0; i < 20; i++) {++ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, i < 10 ? 10000 : 1000000)}`;++ if (!await taken.get(slug)) return slug;++ }++ throw new Error('Could not generate a unique submission slug');++}++++async function getBySlug(db, slug) {++ return await db.prepare('SELECT * FROM submissions WHERE slug = ?').get(String(slug));++}++++async function insert(db, s) {++ const info = await db.prepare(`INSERT INTO submissions (slug, kind, title, body, url, media_ref, author_subject)++ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`).run(s.slug, s.kind, s.title, s.body, s.url, s.media_ref, s.author_subject);++ return await db.prepare('SELECT * FROM submissions WHERE id = ?').get(info.lastInsertRowid);++}++++/** Move a submission on, only from one of the statuses in `from` (a race loses cleanly). → the row, or null */++async function setStatus(db, id, { status, from, reviewer_subject, review_note, reviewed }) {++ const sets = ['status = ?', 'updated_at = ov_now()'];++ const params = [status];++ if (reviewed) { sets.push('reviewer_subject = ?', 'review_note = ?', 'reviewed_at = ov_now()'); params.push(reviewer_subject || null, review_note || null); }++ const r = await db.prepare(`UPDATE submissions SET ${sets.join(', ')} WHERE id = ? AND status IN (${from.map(() => '?').join(',')})`).run(...params, id, ...from);++ return r.changes ? await db.prepare('SELECT * FROM submissions WHERE id = ?').get(id) : null;++}++++/** How many a person sent since a time (the daily cap). */++async function countByAuthorSince(db, subject, since) {++ return Number((await db.prepare('SELECT COUNT(*) AS n FROM submissions WHERE author_subject = ? AND created_at >= ?').get(subject, since)).n);++}++++/**++ * Newest first, keyset-paginated by (created_at, id).++ * opts.status one status, or null for any++ * opts.author only this person's++ * opts.kind one kind, or null++ * opts.before [created_at, id] of the last row already seen++ * → { rows, hasMore }++ */++async function list(db, { status = null, author = null, kind = null, before = null, limit = 30 } = {}) {++ const rows = await db.prepare(`++ SELECT * FROM submissions++ WHERE (@status::text IS NULL OR status = @status)++ AND (@author::text IS NULL OR author_subject = @author)++ AND (@kind::text IS NULL OR kind = @kind)++ AND (@at::text IS NULL OR created_at < @at OR (created_at = @at AND id < @id::bigint))++ ORDER BY created_at DESC, id DESC++ LIMIT @limit`).all({ status, author, kind, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });++ const hasMore = rows.length > limit;++ if (hasMore) rows.pop();++ return { rows, hasMore };++}++++module.exports = { generateSlug, getBySlug, insert, setStatus, countByAuthorSince, list };nnew file mode 100644@@@ -0,0 +1,212 @@++'use strict';++/**++ * Submissions: a signed-in person submits (API or the no-JS form) → pending, seen only by them and++ * moderators → a moderator accepts → public on its page, in the list and in Pulse (origin user, the++ * author as actor); rejected ones never reach Pulse; the author withdraws; non-moderators cannot review;++ * signed-out visitors and services cannot submit; identity never comes from the body; a form from++ * another site is refused.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++const form = (obj, headers = {}) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers } });++const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);++++(async () => {++ const t = await boot({ authority: 'community' });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++ const bossSubject = ids.newId('user');++ const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });++ const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });++ const api = (path, { method = 'GET', token, json, headers = {} } = {}) => t.get(`/api/v1/submissions${path}`, {++ method,++ headers: { ...headers, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) },++ body: json !== undefined ? JSON.stringify(json) : undefined,++ });++ const row = async (slug) => await t.db.prepare('SELECT * FROM submissions WHERE slug = ?').get(slug);++ const pulseItem = async (slug) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").get(slug);++ const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM submissions').get()).n);++ const submitApi = async (body, token = alexJwt) => { const r = await api('', { method: 'POST', token, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json().submission; };++++ let slug; // the no-JS submission the next checks follow++++ await check('signed-out visitors cannot submit (API 401, form → sign in) and see a sign-in prompt instead of the form', async () => {++ const before = await count();++ const r = await api('', { method: 'POST', json: { kind: 'idea', title: 'Anonymous idea', body: 'x' } });++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(r.json().code, 'auth.required');++ const f = await t.get('/submissions', form({ kind: 'idea', title: 'Anonymous idea', body: 'x' }));++ assert.strictEqual(f.status, 303);++ assert.ok(f.headers.get('location').startsWith('/auth/login?next=%2Fsubmissions'), f.headers.get('location'));++ assert.strictEqual(await count(), before);++ const page = await t.get('/submissions');++ assert.strictEqual(page.status, 200);++ has(page.text, 'Sign in with your OpenVibe account');++ assert.ok(!page.text.includes('action="/submissions"'), 'no submit form when signed out');++ });++++ await check('the no-JS form submits as the signed-in person → pending, seen only by its author', async () => {++ const page = await t.get('/submissions', as(alexJwt));++ has(page.text, '<form class="paste-form" method="post" action="/submissions"');++ const r = await t.get('/submissions', as(alexJwt, form({ kind: 'clip', title: 'Best play of the week', url: 'https://openvibe.live/clip/abc', body: 'Watch the **ending**.' })));++ assert.strictEqual(r.status, 303, r.text);++ slug = r.headers.get('location').replace('/submissions/', '');++ assert.ok(/^[a-z]+-[a-z]+-\d+$/.test(slug), slug);++ const s = await row(slug);++ assert.deepStrictEqual([s.kind, s.title, s.url, s.status, s.author_subject, s.reviewer_subject], ['clip', 'Best play of the week', 'https://openvibe.live/clip/abc', 'pending', alex.subject_id, null]);++ const own = await t.get(`/submissions/${slug}`, as(alexJwt));++ assert.strictEqual(own.status, 200);++ has(own.text, 'Pending review');++ has(own.text, '<strong>ending</strong>');++ has(own.text, 'noindex,nofollow');++ has(own.text, `action="/submissions/${slug}/withdraw"`);++ assert.ok(!own.text.includes(`action="/submissions/${slug}/review"`), 'the author does not review');++ });++++ await check('pending is not public: page 404 for others, not listed, not in Pulse', async () => {++ assert.strictEqual((await t.get(`/submissions/${slug}`)).status, 404);++ assert.strictEqual((await t.get(`/submissions/${slug}`, as(samJwt))).status, 404);++ assert.strictEqual((await api(`/${slug}`)).status, 404);++ assert.ok(!(await t.get('/submissions')).text.includes('Best play of the week'));++ assert.deepStrictEqual((await api('')).json().submissions.map((s) => s.slug), []);++ assert.strictEqual((await api('?status=pending', { token: samJwt })).status, 403);++ assert.strictEqual(await pulseItem(slug), undefined);++ const mine = (await api('?mine=1', { token: alexJwt })).json().submissions;++ assert.deepStrictEqual(mine.map((s) => [s.slug, s.status]), [[slug, 'pending']]);++ });++++ await check('non-moderators cannot review (404 for what they cannot see, 403 otherwise), page or API', async () => {++ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', token: samJwt, json: { decision: 'accept' } })).status, 404);++ const own = await api(`/${slug}/review`, { method: 'POST', token: alexJwt, json: { decision: 'accept' } });++ assert.strictEqual(own.status, 403);++ assert.strictEqual(own.json().code, 'submission.moderators_only');++ assert.strictEqual((await t.get(`/submissions/${slug}/review`, as(alexJwt, form({ decision: 'accept' })))).status, 403);++ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', json: { decision: 'accept' } })).status, 401);++ assert.strictEqual((await t.get('/submissions/review', as(alexJwt))).status, 403);++ assert.strictEqual((await t.get('/submissions/review')).status, 303);++ assert.strictEqual((await row(slug)).status, 'pending');++ });++++ await check('a moderator sees the queue and accepts with the no-JS form → public page, list and Pulse', async () => {++ assert.ok(!(await t.get('/submissions', as(bossJwt))).text.includes('Best play of the week'), 'the list is accepted ones, for moderators too');++ const queue = await t.get('/submissions/review', as(bossJwt));++ assert.strictEqual(queue.status, 200);++ has(queue.text, 'Best play of the week');++ const page = await t.get(`/submissions/${slug}`, as(bossJwt));++ has(page.text, `action="/submissions/${slug}/review"`);++ const r = await t.get(`/submissions/${slug}/review`, as(bossJwt, form({ decision: 'accept', note: 'Great one' })));++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(r.headers.get('location'), `/submissions/${slug}`);++ const s = await row(slug);++ assert.deepStrictEqual([s.status, s.reviewer_subject, s.review_note], ['accepted', bossSubject, 'Great one']);++ assert.ok(s.reviewed_at);++++ const pub = await t.get(`/submissions/${slug}`);++ assert.strictEqual(pub.status, 200);++ has(pub.text, `<link rel="canonical" href="https://openvibe.community/submissions/${slug}">`);++ has(pub.text, '<meta property="og:type" content="article">');++ has(pub.text, '<meta property="og:title" content="Best play of the week">');++ has(pub.text, 'index,follow');++ assert.ok(!pub.text.includes('Great one'), 'the review note is for the author and moderators');++ assert.ok(!pub.text.includes(`/submissions/${slug}/withdraw`) && !pub.text.includes(`/submissions/${slug}/review"`));++ has((await t.get(`/submissions/${slug}`, as(alexJwt))).text, 'Great one');++ has((await t.get('/submissions')).text, 'Best play of the week');++ has((await t.get('/submissions?kind=clip')).text, 'Best play of the week');++ assert.ok(!(await t.get('/submissions?kind=art')).text.includes('Best play of the week'));++ const listed = (await api('')).json().submissions;++ assert.deepStrictEqual(listed.map((x) => [x.slug, x.author.subject, x.review_note]), [[slug, alex.subject_id, null]]);++++ const it = await pulseItem(slug);++ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user', 'public']);++ const feed = (await t.get('/api/v1/pulse?origin=user')).json().items;++ assert.ok(feed.some((i) => i.source.type === 'submission' && i.source.id === slug && i.actor.subject === alex.subject_id));++ has((await t.get('/pulse')).text, 'Best play of the week');++ });++++ await check('the author withdraws: gone from the page, the list and Pulse; nobody else may; not twice', async () => {++ assert.strictEqual((await api(`/${slug}/withdraw`, { method: 'POST', token: samJwt })).status, 403);++ const r = await t.get(`/submissions/${slug}/withdraw`, as(alexJwt, form({})));++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual((await row(slug)).status, 'withdrawn');++ assert.strictEqual(await pulseItem(slug), undefined);++ assert.strictEqual((await t.get(`/submissions/${slug}`)).status, 404);++ assert.ok(!(await t.get('/submissions')).text.includes('Best play of the week'));++ has((await t.get(`/submissions/${slug}`, as(alexJwt))).text, 'Withdrawn');++ assert.strictEqual((await api(`/${slug}/withdraw`, { method: 'POST', token: alexJwt })).status, 409);++ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } })).status, 409);++ // A pending one withdraws too.++ const p = await submitApi({ kind: 'idea', title: 'Second thoughts', body: 'Maybe not' });++ assert.strictEqual((await api(`/${p.slug}/withdraw`, { method: 'POST', token: alexJwt })).json().submission.status, 'withdrawn');++ });++++ await check('rejected never reaches Pulse; a later rejection of an accepted one takes it out; Pulse re-checks on read', async () => {++ const a = await submitApi({ kind: 'report', title: 'Broken link on the home page', body: 'The footer link 404s' });++ const r = await api(`/${a.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'reject', note: 'Already fixed' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().submission.status, 'rejected');++ assert.strictEqual(await pulseItem(a.slug), undefined);++ assert.strictEqual((await api(`/${a.slug}`)).status, 404);++ assert.strictEqual((await api(`/${a.slug}`, { token: alexJwt })).json().submission.review_note, 'Already fixed');++++ const b = await submitApi({ kind: 'art', title: 'Fan art', url: 'https://example.com/art.png' });++ await api(`/${b.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });++ assert.ok(await pulseItem(b.slug));++ await api(`/${b.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'reject' } });++ assert.strictEqual(await pulseItem(b.slug), undefined);++++ const c = await submitApi({ kind: 'idea', title: 'Changed behind our back', body: 'x' });++ await api(`/${c.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });++ await t.db.prepare("UPDATE submissions SET status = 'rejected' WHERE slug = ?").run(c.slug); // no hook ran++ assert.ok(await pulseItem(c.slug), 'row still there');++ assert.ok(!(await t.get('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === c.slug), 'but never listed');++ });++++ await check('identity comes from the token, never the body; services cannot submit; input is checked', async () => {++ const s = await submitApi({ kind: 'idea', title: 'Mine', body: 'x', author_subject: sam.subject_id, status: 'accepted', reviewer_subject: sam.subject_id });++ const r = await row(s.slug);++ assert.deepStrictEqual([r.author_subject, r.status, r.reviewer_subject], [alex.subject_id, 'pending', null]);++ const svc = net.signService({ cap: ['community.comment.write', 'community.paste.create'] });++ const sv = await api('', { method: 'POST', token: svc, json: { kind: 'idea', title: 'From a service', body: 'x' }, headers: { 'x-ov-subject': alex.subject_id } });++ assert.strictEqual(sv.status, 403, sv.text);++ for (const [body, code] of [++ [{ kind: 'meme', title: 'x', body: 'y' }, 'submission.invalid_kind'],++ [{ kind: 'idea', title: '', body: 'y' }, 'submission.invalid_title'],++ [{ kind: 'clip', title: 'No clip' }, 'submission.media_required'],++ [{ kind: 'art', title: 'Bad link', url: 'javascript:alert(1)' }, 'submission.invalid_url'],++ [{ kind: 'art', title: 'Bad ref', media_ref: 'med_nope' }, 'submission.invalid_media_ref'],++ ]) {++ const out = await api('', { method: 'POST', token: alexJwt, json: body });++ assert.strictEqual(out.status, 400, `${JSON.stringify(body)}: ${out.text}`);++ assert.strictEqual(out.json().code, code);++ }++ const ok = await submitApi({ kind: 'art', title: 'Stored in Media', media_ref: 'med_01J00000000000000000000000' });++ assert.strictEqual(ok.media_ref, 'med_01J00000000000000000000000');++ const bad = await t.get('/submissions', as(alexJwt, form({ kind: 'clip', title: 'No link' })));++ assert.strictEqual(bad.status, 400);++ has(bad.text, 'A clip or art submission needs a link');++ has(bad.text, 'value="No link"');++ });++++ await check('a form sent from another site is refused (submit, withdraw, review)', async () => {++ const before = await count();++ const evil = { origin: 'https://evil.example' };++ const r = await t.get('/submissions', as(alexJwt, form({ kind: 'idea', title: 'Cross site', body: 'x' }, evil)));++ assert.strictEqual(r.status, 403);++ has(r.text, 'That form was sent from another site.');++ assert.strictEqual(await count(), before);++ const p = await submitApi({ kind: 'idea', title: 'Target', body: 'x' });++ assert.strictEqual((await t.get(`/submissions/${p.slug}/withdraw`, as(alexJwt, form({}, evil)))).status, 403);++ assert.strictEqual((await t.get(`/submissions/${p.slug}/review`, as(bossJwt, form({ decision: 'accept' }, evil)))).status, 403);++ assert.strictEqual((await row(p.slug)).status, 'pending');++ const same = await t.get('/submissions', as(alexJwt, form({ kind: 'idea', title: 'Same site', body: 'x' }, { origin: 'https://openvibe.community' })));++ assert.strictEqual(same.status, 303);++ });++++ await t.close();++ done();++})();
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
The community hub of the OpenVibe network: community-run, open source, free speech within
The community hub of the OpenVibe network: community-run, open source, free speech within
the rules. It is the home of **pastes** (code, text and screenshots with a link), the
the rules. It is the home of **pastes** (code, text and screenshots with a link), the
**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
-embeds, and **Pulse**, the network's public activity. Submissions follow.
+embeds, **Pulse**, the network's public activity, and [**submissions**](#submissions) — clips, art,
+ideas and reports people send in for community review.
It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that
It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
@@ -18,7 +19,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -18,7 +19,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- pastes (the only authority since 2026-09-22), their versions, likes and
- pastes (the only authority since 2026-09-22), their versions, likes and
comments
comments
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
- categories), Pulse (the network's public activity) and the Discord relay
+ categories), Pulse (the network's public activity), submissions and the Discord relay
- the `community.*` events, Search documents for public threads, and the `community.profile` user module
- the `community.*` events, Search documents for public threads, and the `community.profile` user module
- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);
- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);
embedded PGlite in development), with Valkey for the per-actor limit counters
embedded PGlite in development), with Valkey for the per-actor limit counters
@@ -129,6 +130,10 @@ Pages (server-rendered HTML):
@@ -129,6 +130,10 @@ Pages (server-rendered HTML):
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
| `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/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |
+| `POST /submissions/:slug/withdraw\|review` | The no-JS forms: the author withdraws; a moderator accepts or rejects with a note |
+| `GET /submissions/review` | The moderators' queue (pending, newest first) |
| `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |
| `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |
API and machine endpoints:
API and machine endpoints:
@@ -139,6 +144,7 @@ API and machine endpoints:
@@ -139,6 +144,7 @@ API and machine endpoints:
| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
+| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
@@ -460,9 +466,9 @@ A read model of public activity across the network, with provenance (`pulse_item
@@ -460,9 +466,9 @@ A read model of public activity across the network, with provenance (`pulse_item
per source service/type/id).
per source service/type/id).
- **Community's own**: new public pastes written by a person (not burn-after-read, not NSFW),
- **Community's own**: new public pastes written by a person (not burn-after-read, not NSFW),
- new threads and replies in public spaces — recorded at write time, removed when deleted or
- made non-public, and re-checked against their source on every read, so private or unlisted
- things never show.
+ new threads and replies in public spaces, and submissions once accepted — recorded at write time,
+ removed when deleted, made non-public, withdrawn or rejected, and re-checked against their source
+ on every read, so private, unlisted or unreviewed things never show.
- **Other services** publish with `community.pulse.write`:
- **Other services** publish with `community.pulse.write`:
`POST /api/v1/pulse/items { ref, title, url, origin?, occurred_at?, visibility? }` — the
`POST /api/v1/pulse/items { ref, title, url, origin?, occurred_at?, visibility? }` — the
ref's `service` must be the caller's own (`svc:live` → `live`), `visibility` other than
ref's `service` must be the caller's own (`svc:live` → `live`), `visibility` other than
@@ -479,6 +485,40 @@ per source service/type/id).
@@ -479,6 +485,40 @@ per source service/type/id).
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
they are never attributed to a person (roadmap §33); system items name no one either.
they are never attributed to a person (roadmap §33); system items name no one either.
+## Submissions
+
+Clips, art, ideas and reports people send in for community review (`submissions`, migration
+`0003_submissions.sql`; `server/submissions/`). They have a table of their own and nothing to do with
+the forum, so they stay here when spaces move to a service of their own.
+
+- **Who submits**: a signed-in person, from the browser (`ov_token` cookie, or `Authorization: Bearer`
+ from another OpenVibe site through CORS). The author is the JWT's `subject_id` (`usr_…`) — never a
+ body field. Signed out: 401 `auth.required` (the form sends you to sign in). Services cannot submit:
+ openvibe-contracts has no submission capability yet (403 `capability.denied`). At most 10 a day per
+ person (429 `submission.daily_limit`).
+- **What**: `kind` (`clip`, `art`, `idea`, `report`), `title` (200), `body` (Markdown, 10 000), an http(s)
+ `url` and/or a `media_ref` (an OpenVibe.Media object, `med_…`: the bytes stay in Media). A clip or art
+ needs a link or a Media object.
+- **Review**: new submissions are `pending`, seen by their author and by discussion moderators
+ (site staff, or a service holding `community.comment.moderate` — `discussionModerator`) and 404 for
+ everyone else. A moderator accepts or rejects (also a decision already made) with an optional note
+ the author sees; the author withdraws while pending or accepted. Accepted ones are public pages
+ (canonical, Open Graph, IndexNow), listed at `/submissions` and recorded in [Pulse](#pulse) as the
+ author's (origin `user`); pending, rejected and withdrawn ones never are.
+- **Pages** work without JavaScript: filters and paging are links, submit, withdraw, accept and
+ reject are plain form posts (SameSite=Lax cookie; a foreign `Origin` is refused).
+
+| Route | Who | What |
+| --- | --- | --- |
+| `GET /api/v1/submissions?kind=&status=&mine=1&after=&limit=` | anyone | newest first, keyset cursor: accepted ones; `mine=1` your own, any status; moderators any `status` (others asking for one get 403 `submission.moderators_only`) |
+| `POST /api/v1/submissions { kind, title, body?, url?, media_ref? }` | a signed-in person | 201 `{ submission }`, `pending` |
+| `GET /api/v1/submissions/:slug` | see above | `{ submission }` — `reviewer` and `review_note` only for the author and moderators |
+| `POST /api/v1/submissions/:slug/withdraw` | the author | `withdrawn` (409 `submission.not_withdrawable` once rejected or withdrawn) |
+| `POST /api/v1/submissions/:slug/review { decision: accept\|reject, note? }` | moderators | `accepted` / `rejected` (409 `submission.withdrawn`) |
+
+Errors are problem+json. Account deletion erases a person's submissions (and their Pulse items) and
+unsigns the decisions they made; the export has `submissions.json`; a subject merge moves both.
+
## Platform blocks
## Platform blocks
People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community
People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community
@@ -624,7 +664,8 @@ every read path and nobody acts on someone else's ids (`security-private`, `secu
@@ -624,7 +664,8 @@ every read path and nobody acts on someone else's ids (`security-private`, `secu
secrets, open redirects (`security-ssrf`, `security-secrets`, `open-redirect`); Pulse and the Events
secrets, open redirects (`security-ssrf`, `security-secrets`, `open-redirect`); Pulse and the Events
consumer (`pulse`, `pulse-consumer`, `events`); revocation, blocks, account export and deletion
consumer (`pulse`, `pulse-consumer`, `events`); revocation, blocks, account export and deletion
(`revocation`, `blocks`, `account-data`); the Discord relay (`relay*`); per-actor limits
(`revocation`, `blocks`, `account-data`); the Discord relay (`relay*`); per-actor limits
-(`actor-limits`); graceful shutdown (`shutdown`); and the previous release against this one (`n-1`).
+(`actor-limits`); submissions from the form to review, the page and Pulse (`submissions`); graceful
+shutdown (`shutdown`); and the previous release against this one (`n-1`).
## Security
## Security
@@ -702,6 +743,7 @@ server/
@@ -702,6 +743,7 @@ server/
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored client
vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored client
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
+ submissions/ clips, art, ideas, reports for review: store, service, api (/api/v1/submissions), routes (pages)
relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (creates
relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (creates
from Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)
from Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
@@ -713,6 +755,7 @@ server/
@@ -713,6 +755,7 @@ server/
render/pages.js home / browse / paste / new / my / error templates
render/pages.js home / browse / paste / new / my / error templates
render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/pulse.js the /pulse page
render/pulse.js the /pulse page
+ render/submissions.js the submissions list, a submission's page, the review queue
render/comments.js a comment thread's own page
render/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
render/markdown.js the safe Markdown subset for posts
render/highlight.js highlight.js wrapper, language list, download extensions
render/highlight.js highlight.js wrapper, language list, download extensions
@@ -728,8 +771,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,
@@ -728,8 +771,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,
Spaces per streamer, game and project (with membership), the Discord relay's production round
Spaces per streamer, game and project (with membership), the Discord relay's production round
trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for
trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for
-comment threads arriving through Events, moving paste comments onto the typed comment
-threads, and submissions (clips, art, ideas, reports for community review).
+comment threads arriving through Events, and moving paste comments onto the typed comment
+threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).
## Related services
## Related services
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+-- phase: expand
+-- T10 plan item 8: submissions — clips, art, ideas and reports people send in for community review
+-- (server/submissions). Additive only: one new table and its indexes, nothing existing is touched.
+-- People are Network subjects (usr_…), never a local integer; a clip or picture stays in OpenVibe.Media
+-- and only its med_ reference is kept here.
+CREATE TABLE submissions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ kind text COLLATE "C" NOT NULL CHECK(kind IN ('clip', 'art', 'idea', 'report')),
+ title text COLLATE "C" NOT NULL,
+ body text COLLATE "C" NOT NULL DEFAULT '',
+ url text COLLATE "C",
+ media_ref text COLLATE "C",
+ author_subject text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
+ reviewer_subject text COLLATE "C",
+ review_note text COLLATE "C",
+ reviewed_at text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+-- The public list (accepted) and the review queue (pending), newest first; a person's own.
+CREATE INDEX idx_submissions_status ON submissions (status, created_at DESC, id DESC);
+CREATE INDEX idx_submissions_author ON submissions (author_subject, created_at DESC, id DESC);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-community",
"name": "openvibe-community",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"private": true,
- "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",
+ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, Pulse and submissions (clips, art, ideas and reports for community review). Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
* GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
* GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
* 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)
+ * /api/v1/submissions/* submissions (submissions/api.js)
*
*
* Comments, the forum, Pulse and the relay live in Community's database, alongside the pastes
* Comments, the forum, Pulse and the relay live in Community's database, alongside the pastes
* Community itself is the only authority for: the native API (pastes/api.js), pages, raw text
* Community itself is the only authority for: the native API (pastes/api.js), pages, raw text
@@ -54,6 +56,9 @@ const { createDiscordGateway } = require('./relay/discord-gateway');
@@ -54,6 +56,9 @@ const { createDiscordGateway } = require('./relay/discord-gateway');
const { createDiscordInbound } = require('./relay/inbound');
const { createDiscordInbound } = require('./relay/inbound');
const { createRelayApi } = require('./relay/api');
const { createRelayApi } = require('./relay/api');
const { createActorLimits } = require('./actor-limits');
const { createActorLimits } = require('./actor-limits');
+const { createSubmissionService } = require('./submissions/service');
+const { createSubmissionsApi } = require('./submissions/api');
+const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
@@ -156,8 +161,10 @@ async function createApp(opts = {}) {
@@ -156,8 +161,10 @@ async function createApp(opts = {}) {
const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });
const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });
const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
const comments = createCommentService({ db, network, limits: opts.commentLimits });
const comments = createCommentService({ db, network, limits: opts.commentLimits });
+ // Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
+ const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
seo.useForum(forum);
seo.useForum(forum);
- Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, indexnow });
+ Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow });
if (opts.startRelay !== false) relay.start();
if (opts.startRelay !== false) relay.start();
// ── Pastes: Community's own store is the only authority ──
// ── Pastes: Community's own store is the only authority ──
@@ -202,6 +209,8 @@ async function createApp(opts = {}) {
@@ -202,6 +209,8 @@ async function createApp(opts = {}) {
// ── /api/pastes — the native paste API ───────────────────
// ── /api/pastes — the native paste API ───────────────────
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));
app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));
+ // ── /api/v1/submissions — clips, art, ideas and reports for community review ──
+ app.use('/api/v1/submissions', v1.cors(config.apiCorsOrigins), createSubmissionsApi({ submissions, viewers, limits }));
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.
// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.
@@ -385,6 +394,7 @@ async function createApp(opts = {}) {
@@ -385,6 +394,7 @@ async function createApp(opts = {}) {
// ── Forum and Pulse pages ────────────────────────────────
// ── Forum and Pulse pages ────────────────────────────────
app.use(createForumRoutes({ forum, viewers, config }));
app.use(createForumRoutes({ forum, viewers, config }));
app.use(createCommentPages({ comments, viewers, config }));
app.use(createCommentPages({ comments, viewers, config }));
+ app.use(createSubmissionPages({ submissions, viewers, config }));
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
let out;
let out;
@@ -36,7 +36,7 @@ const EXPORTS = [
@@ -36,7 +36,7 @@ const EXPORTS = [
['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
- ['activity.json', 'pulse_items', 'actor_subject'],
+ ['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
];
];
async function exportPart(db, subject) {
async function exportPart(db, subject) {
@@ -125,6 +125,14 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -125,6 +125,14 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
}
}
if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
+ // Their submissions go (Pulse items with them, below); a decision they made stays, unsigned.
+ if (await hasTable(db, 'submissions')) {
+ for (const r of await db.prepare(`SELECT slug FROM submissions WHERE author_subject IN ${S}`).all(...subjects)) {
+ await db.prepare("DELETE FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").run(r.slug);
+ }
+ add(erased, 'submissions', (await db.prepare(`DELETE FROM submissions WHERE author_subject IN ${S}`).run(...subjects)).changes);
+ await db.prepare(`UPDATE submissions SET reviewer_subject = NULL WHERE reviewer_subject IN ${S}`).run(...subjects);
+ }
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
@@ -14,6 +14,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -14,6 +14,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
const AUTHORSHIP = [
const AUTHORSHIP = [
['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],
['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],
['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],
['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],
+ ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'],
];
];
// Per person per item: [table, item column, recompute(db, itemId)]
// Per person per item: [table, item column, recompute(db, itemId)]
const PER_ITEM = [
const PER_ITEM = [
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* Sources:
* Sources:
* - Community's own public activity, recorded at write time (the hooks below): new public
* - Community's own public activity, recorded at write time (the hooks below): new public
* pastes written by a person (not burn-after-read, not NSFW), new threads and replies in
* pastes written by a person (not burn-after-read, not NSFW), new threads and replies in
- * public spaces. Deletes and visibility changes take items out again, and listItems()
+ * public spaces, and submissions when they are accepted. Deletes and visibility changes take items out again, and listItems()
* re-checks Community's own items at read time as well.
* re-checks Community's own items at read time as well.
* - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef
* - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef
* of their own (ref.service must be the calling service), title, url, origin, occurred_at.
* of their own (ref.service must be the calling service), title, url, origin, occurred_at.
@@ -89,6 +89,11 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -89,6 +89,11 @@ function createPulse({ db, network = null, config = {} } = {}) {
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,
});
});
},
},
+ async submissionAccepted(s) {
+ if (!s || s.status !== 'accepted') return;
+ await recordLocal('submission', s.slug, { title: s.title, path: `/submissions/${encodeURIComponent(s.slug)}`, actor: s.author_subject, origin: 'user', at: s.reviewed_at });
+ },
+ async submissionGone(slug) { await forgetLocal('submission', slug); },
async threadGone(id) { await forgetLocal('thread', id); },
async threadGone(id) { await forgetLocal('thread', id); },
async postGone(id) { await forgetLocal('post', id); },
async postGone(id) { await forgetLocal('post', id); },
@@ -29,8 +29,9 @@ async function removeItem(db, service, type, id) {
@@ -29,8 +29,9 @@ async function removeItem(db, service, type, id) {
/**
/**
* Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
* Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
- * against their source at read time — a paste made private, a deleted post or a thread in a
- * space that stopped being public (or became members-only) never shows, even if nothing removed its item.
+ * against their source at read time — a paste made private, a deleted post, a submission no longer
+ * accepted or a thread in a space that stopped being public (or became members-only) never shows,
+ * even if nothing removed its item.
* opts.origin 'user' | 'ai' | 'system'
* opts.origin 'user' | 'ai' | 'system'
* opts.before [occurred_at, id] of the last item already seen
* opts.before [occurred_at, id] of the last item already seen
* → { rows, hasMore }
* → { rows, hasMore }
@@ -47,7 +48,8 @@ async function listItems(db, { origin = null, before = null, limit = 30 } = {})
@@ -47,7 +48,8 @@ async function listItems(db, { origin = null, before = null, limit = 30 } = {})
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'
WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'
- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)))
+ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
+ OR (i.source_type = 'submission' AND EXISTS (SELECT 1 FROM submissions su WHERE su.slug = i.source_id AND su.status = 'accepted')))
ORDER BY i.occurred_at DESC, i.id DESC
ORDER BY i.occurred_at DESC, i.id DESC
LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
@@ -15,7 +15,7 @@ const { escapeHtml } = require('./highlight');
@@ -15,7 +15,7 @@ const { escapeHtml } = require('./highlight');
const SITE_NAME = 'OpenVibe.Community';
const SITE_NAME = 'OpenVibe.Community';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and soon submissions. Free speech within the rules.';
+const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and submissions. Free speech within the rules.';
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
@@ -170,7 +170,7 @@ const { url } = await res.json(); // "/p/<slug>"` },
@@ -170,7 +170,7 @@ const { url } = await res.json(); // "/p/<slug>"` },
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
return renderPage({
return renderPage({
title: null,
title: null,
- description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — spaces and threads, and soon submissions. Free speech within the rules.',
+ description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — spaces and threads, and submissions. Free speech within the rules.',
canonicalPath: '/',
canonicalPath: '/',
active: 'home',
active: 'home',
jsonLd: [seo.websiteLd()],
jsonLd: [seo.websiteLd()],
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+
+/**
+ * Submission pages (server/submissions has the rules): the list with the submit form, one
+ * submission, and the moderators' review queue. Filters and paging are links and every action is
+ * a plain form post, so all of it works without JavaScript.
+ */
+const seo = require('../seo');
+const { renderPage, abs } = require('./layout');
+const { escapeHtml: esc } = require('./highlight');
+const { renderMarkdown, markdownToText } = require('./markdown');
+const { timeTag } = require('./pages');
+const { who } = require('./forum');
+
+const KIND_LABELS = { clip: 'Clip', art: 'Art', idea: 'Idea', report: 'Report' };
+const STATUS_LABELS = { pending: 'Pending review', accepted: 'Accepted', rejected: 'Not accepted', withdrawn: 'Withdrawn' };
+
+const listHref = ({ kind = '', mine = false, after = null } = {}, path = '/submissions') => {
+ const q = new URLSearchParams();
+ if (kind) q.set('kind', kind);
+ if (mine) q.set('mine', '1');
+ if (after) q.set('after', after);
+ const s = q.toString();
+ return `${path}${s ? `?${s}` : ''}`;
+};
+
+const kindBadge = (s) => `<span class="badge">${esc(KIND_LABELS[s.kind] || s.kind)}</span>`;
+const statusBadge = (s) => (s.status === 'accepted' ? '' : ` <span class="badge">${esc(STATUS_LABELS[s.status] || s.status)}</span>`);
+
+function itemHtml(s) {
+ return `<li class="pulse-item">
+ ${kindBadge(s)}
+ <div class="pulse-main">
+ <a class="thread-title" href="${esc(s.page_url)}">${esc(s.title)}</a>${statusBadge(s)}
+ <p class="thread-meta">${who(s.author)} <span class="sep">·</span> ${timeTag(s.created_at)}</p>
+ </div>
+ </li>`;
+}
+
+function listBlock(items, { empty, prev, next }) {
+ return `${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : `<p class="empty">${esc(empty)}</p>`}
+ ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;
+}
+
+function submitForm({ user, values = {}, error = null }) {
+ if (!user) return `<p class="alert"><a href="/auth/login?next=${encodeURIComponent('/submissions')}">Sign in with your OpenVibe account</a> to send a clip, art, an idea or a report. Reading needs no account.</p>`;
+ const kinds = Object.entries(KIND_LABELS).map(([id, label]) => `<option value="${id}"${values.kind === id ? ' selected' : ''}>${label}</option>`).join('');
+ return `<form class="paste-form" method="post" action="/submissions" id="submit">
+ <h2>Submit something</h2>
+ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+ <label class="field"><span>Kind</span><select name="kind" required>${kinds}</select></label>
+ <label class="field"><span>Title</span><input type="text" name="title" maxlength="200" required value="${esc(values.title || '')}"></label>
+ <label class="field"><span>Link (a clip, a picture, anything with an address)</span><input type="url" name="url" maxlength="2000" value="${esc(values.url || '')}" placeholder="https://"></label>
+ <label class="field"><span>Description (Markdown)</span><textarea name="body" rows="8" maxlength="10000">${esc(values.body || '')}</textarea></label>
+ <div class="form-actions">
+ <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Submit for review</button>
+ <span class="muted small">Sending as <strong>${esc(user.display_name || user.username || 'you')}</strong>. Moderators review it before it is public. By submitting you agree to the <a href="/terms">rules</a>.</span>
+ </div>
+</form>`;
+}
+
+/** GET /submissions — accepted ones (or ?mine=1, your own), the submit form when signed in. */
+function submissionsPage({ items, kind = '', mine = false, after = null, nextCursor = null, user = null, moderator = false, values = {}, error = null }) {
+ const tabs = [['', 'Everything'], ...Object.entries(KIND_LABELS)].map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(listHref({ kind: id, mine }))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');
+ const links = user ? `<p class="muted small">${mine ? '<a href="/submissions">All accepted submissions</a>' : '<a href="/submissions?mine=1">Your submissions</a>'}${moderator ? ' · <a href="/submissions/review">Review queue</a>' : ''}</p>` : '';
+ const body = `
+<header class="page-head">
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Submissions</span></nav>
+ <h1>${mine ? 'Your submissions' : 'Submissions'}</h1>
+ <p class="muted">Clips, art, ideas and reports from the people of OpenVibe. Anyone signed in can submit; moderators review each one, and what they accept appears here and in <a href="/pulse">Pulse</a>.</p>
+ ${links}
+</header>
+<nav class="tabs sort-tabs" aria-label="Filter by kind">${tabs}</nav>
+<section data-results>
+ ${listBlock(items, { empty: mine ? 'You have not submitted anything yet.' : 'Nothing accepted yet.', prev: after ? listHref({ kind, mine }) : null, next: nextCursor ? listHref({ kind, mine, after: nextCursor }) : null })}
+</section>
+${submitForm({ user, values, error })}`;
+ return renderPage({
+ title: mine ? 'Your submissions' : `Submissions${kind ? ` — ${KIND_LABELS[kind]}` : ''}`,
+ description: 'Clips, art, ideas and reports from the people of OpenVibe, reviewed by the community\'s moderators.',
+ canonicalPath: listHref({ kind, after }),
+ robots: mine || after ? 'noindex,follow' : 'index,follow',
+ active: 'submissions',
+ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],
+ body,
+ });
+}
+
+/** GET /submissions/:slug */
+function submissionPage({ submission: s, user = null, error = null }) {
+ const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';
+ const media = s.media_ref ? `<p class="muted small">OpenVibe.Media object <code>${esc(s.media_ref)}</code></p>` : '';
+ const review = s.status !== 'pending' && (s.review_note || s.reviewer)
+ ? `<aside class="alert"><strong>${esc(STATUS_LABELS[s.status])}</strong>${s.reviewer ? ` by ${who(s.reviewer)}` : ''}${s.reviewed_at ? ` · ${timeTag(s.reviewed_at)}` : ''}${s.review_note ? `<p>${esc(s.review_note)}</p>` : ''}</aside>` : '';
+ const withdraw = s.can && s.can.withdraw ? `<form method="post" action="/submissions/${esc(s.slug)}/withdraw" class="inline-form"><button class="btn" type="submit">Withdraw</button></form>` : '';
+ const reviewForm = s.can && s.can.review ? `<form class="paste-form" method="post" action="/submissions/${esc(s.slug)}/review">
+ <h2>Review</h2>
+ <label class="field"><span>Note to the author (optional)</span><textarea name="note" rows="3" maxlength="1000"></textarea></label>
+ <div class="form-actions">
+ <button class="btn btn-primary" type="submit" name="decision" value="accept">Accept</button>
+ <button class="btn" type="submit" name="decision" value="reject">Reject</button>
+ </div>
+</form>` : '';
+ const body = `
+<article>
+<header class="page-head">
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/submissions">Submissions</a> › <span aria-current="page">${esc(s.title)}</span></nav>
+ <h1>${esc(s.title)}</h1>
+ <p class="thread-meta">${kindBadge(s)}${statusBadge(s)} ${who(s.author)} <span class="sep">·</span> ${timeTag(s.created_at)}</p>
+</header>
+${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+${review}
+${link}${media}
+${s.body ? `<div class="md">${renderMarkdown(s.body)}</div>` : ''}
+${withdraw}
+</article>
+${reviewForm}`;
+ const accepted = s.status === 'accepted';
+ const description = markdownToText(s.body || '').replace(/\s+/g, ' ').trim().slice(0, 200) || `${KIND_LABELS[s.kind]} submitted to OpenVibe.Community.`;
+ return renderPage({
+ title: s.title,
+ description,
+ canonicalPath: s.page_url,
+ // Only accepted submissions are public pages.
+ robots: accepted ? 'index,follow' : 'noindex,nofollow',
+ ogType: 'article',
+ active: 'submissions',
+ published: s.created_at,
+ modified: s.updated_at,
+ jsonLd: accepted ? [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }, { name: s.title, url: abs(s.page_url) }])] : [],
+ body,
+ });
+}
+
+/** GET /submissions/review — what is waiting for a moderator, oldest pages behind "Older". */
+function reviewQueuePage({ items, after = null, nextCursor = null }) {
+ const body = `
+<header class="page-head">
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/submissions">Submissions</a> › <span aria-current="page">Review queue</span></nav>
+ <h1>Review queue</h1>
+ <p class="muted">Submissions waiting for a decision, newest first. Open one to accept or reject it.</p>
+</header>
+<section data-results>
+ ${listBlock(items, { empty: 'Nothing is waiting for review.', prev: after ? '/submissions/review' : null, next: nextCursor ? listHref({ after: nextCursor }, '/submissions/review') : null })}
+</section>`;
+ return renderPage({ title: 'Review queue', description: 'Submissions waiting for review.', canonicalPath: '/submissions/review', robots: 'noindex,nofollow', active: 'submissions', footerVariant: 'compact', body });
+}
+
+module.exports = { submissionsPage, submissionPage, reviewQueuePage, KIND_LABELS, STATUS_LABELS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * /api/v1/submissions — clips, art, ideas and reports for community review (service.js has the rules).
+ *
+ * GET / ?kind=&status=&mine=1&after=<cursor>&limit= anyone (accepted); mine=1 own; moderators any
+ * POST / { kind, title, body?, url?, media_ref? } a signed-in person (browser JWT)
+ * GET /:slug accepted: anyone; else author/moderators
+ * POST /:slug/withdraw the author
+ * POST /:slug/review { decision: accept|reject, note? } discussion moderators
+ *
+ * Identity is the JWT (ov_token cookie or Bearer) or a service token, never the body. Errors are
+ * problem+json.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { run, jsonBody } = require('../http/v1');
+
+function createSubmissionsApi({ submissions, viewers, limits }) {
+ const router = express.Router();
+ router.use(contracts.http.middleware());
+ router.use(viewers.middleware());
+ // Per-actor limits (server/actor-limits.js): reads take the defaults; the service's daily cap decides
+ // how many a person sends, this caps the requests.
+ router.use(limits.reads('community.submission.read'));
+ const write = limits('community.submission.write', { minute: 20, hour: 120 });
+ const review = limits('community.submission.review', { minute: 60, hour: 600 });
+
+ router.get('/', run(async (req) => await submissions.list(req.viewer, req.query)));
+ router.post('/', write, jsonBody, run(async (req) => await submissions.create(req.viewer, req.body || {}), 201));
+ router.get('/:slug', run(async (req) => await submissions.get(req.viewer, req.params.slug)));
+ router.post('/:slug/withdraw', write, run(async (req) => await submissions.withdraw(req.viewer, req.params.slug)));
+ router.post('/:slug/review', review, jsonBody, run(async (req) => await submissions.review(req.viewer, req.params.slug, req.body || {})));
+
+ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
+ return router;
+}
+
+module.exports = { createSubmissionsApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+
+/**
+ * Submission pages (render/submissions.js has the markup, service.js the rules):
+ *
+ * GET /submissions accepted ones, ?kind=, ?mine=1 (your own), ?after=; the submit form when signed in
+ * POST /submissions submit (signed in; the no-JS form)
+ * GET /submissions/review the moderators' queue (pending), ?after=
+ * GET /submissions/:slug one submission (accepted: anyone; else its author and moderators)
+ * POST /submissions/:slug/withdraw the author
+ * POST /submissions/:slug/review moderators: decision=accept|reject, note
+ *
+ * Form posts carry the ov_token cookie (SameSite=Lax) and an Origin from another site is refused,
+ * as in the forum.
+ */
+const express = require('express');
+const pages = require('../render/pages');
+const { submissionsPage, submissionPage, reviewQueuePage } = require('../render/submissions');
+const { ApiError } = require('../http/v1');
+
+function createSubmissionPages({ submissions, viewers, config }) {
+ const router = express.Router();
+ const withViewer = viewers.middleware({ services: false });
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
+ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+ const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
+
+ function failPage(req, res, err, next, back) {
+ if (!(err instanceof ApiError)) return next(err);
+ if (err.status === 401) return login(res, back || req.originalUrl);
+ const titles = { 404: 'Not found', 403: 'Not allowed', 409: 'Already decided', 429: 'Slow down' };
+ return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.status === 404 ? 'There is no submission at that address.' : err.message }), err.status);
+ }
+
+ function sameOrigin(req, res, next) {
+ const origin = req.get('origin');
+ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
+ next();
+ }
+
+ async function renderList(req, res, { status = 200, values = {}, error = null } = {}) {
+ const kind = submissions.KINDS.includes(req.query.kind) ? req.query.kind : '';
+ const mine = !!req.user && req.query.mine === '1';
+ const after = req.query.after ? String(req.query.after) : null;
+ // The list page is the accepted ones for everybody, moderators too (they have the queue).
+ const out = await submissions.list(req.viewer, { kind, mine: mine ? '1' : '', status: mine ? '' : 'accepted', after });
+ html(res, submissionsPage({ items: out.submissions, kind, mine, after, nextCursor: out.next_cursor, user: req.user, moderator: submissions.moderates(req.viewer), values, error }), status);
+ }
+
+ router.get('/submissions', withViewer, wrap(async (req, res, next) => {
+ try { await renderList(req, res); }
+ catch (err) {
+ if (err instanceof ApiError && err.code === 'request.invalid_cursor') return res.redirect(302, '/submissions');
+ failPage(req, res, err, next);
+ }
+ }));
+
+ router.post('/submissions', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const b = req.body || {};
+ const values = { kind: String(b.kind || ''), title: String(b.title || '').slice(0, 300), url: String(b.url || '').slice(0, 2100), body: String(b.body || '').slice(0, 12000) };
+ try {
+ // Signed-in people only (a browser's own JWT); the same rules as the API.
+ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to submit');
+ const out = await submissions.create(req.viewer, values);
+ res.redirect(303, out.submission.page_url);
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status === 401) return failPage(req, res, err, next, '/submissions');
+ try { await renderList(req, res, { status: err.status, values, error: err.message }); } catch (e) { failPage(req, res, e, next); }
+ }
+ }));
+
+ router.get('/submissions/review', withViewer, wrap(async (req, res, next) => {
+ try {
+ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to review');
+ if (!submissions.moderates(req.viewer)) throw new ApiError(403, 'submission.moderators_only', 'Only moderators review submissions.');
+ const after = req.query.after ? String(req.query.after) : null;
+ const out = await submissions.list(req.viewer, { status: 'pending', after });
+ html(res, reviewQueuePage({ items: out.submissions, after, nextCursor: out.next_cursor }));
+ } catch (err) {
+ if (err instanceof ApiError && err.code === 'request.invalid_cursor') return res.redirect(302, '/submissions/review');
+ failPage(req, res, err, next);
+ }
+ }));
+
+ router.get('/submissions/:slug', withViewer, wrap(async (req, res, next) => {
+ try { html(res, submissionPage({ submission: (await submissions.get(req.viewer, req.params.slug)).submission, user: req.user })); }
+ catch (err) { failPage(req, res, err, next); }
+ }));
+
+ // Withdraw and review: back to the submission's page (303), or the page with the reason.
+ for (const action of ['withdraw', 'review']) {
+ router.post(`/submissions/:slug/${action}`, withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const page = `/submissions/${encodeURIComponent(req.params.slug)}`;
+ try {
+ if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in first');
+ const b = req.body || {};
+ if (action === 'withdraw') await submissions.withdraw(req.viewer, req.params.slug);
+ else await submissions.review(req.viewer, req.params.slug, { decision: String(b.decision || ''), note: String(b.note || '').slice(0, 1200) });
+ res.redirect(303, page);
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next, page);
+ try { html(res, submissionPage({ submission: (await submissions.get(req.viewer, req.params.slug)).submission, user: req.user, error: err.message }), err.status); }
+ catch (e) { failPage(req, res, e, next); }
+ }
+ }));
+ }
+
+ return router;
+}
+
+module.exports = { createSubmissionPages };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Submissions — clips, art, ideas and reports people send in for community review.
+ *
+ * - Signed-in people submit (a browser's JWT subject, usr_…); signed-out visitors and services do
+ * not: openvibe-contracts has no submission capability yet, so there is nothing for a service to hold.
+ * - A new submission is pending: only its author and discussion moderators
+ * (identity/capabilities.js discussionModerator) see it. Accepted ones are public; rejected and
+ * withdrawn ones go back to being seen by their author and moderators only (anyone else: 404).
+ * - The author withdraws (pending or accepted); a moderator accepts or rejects (pending, or a decision
+ * already made), with an optional note the author sees.
+ * - Accepted submissions enter Pulse as the author's (origin user); leaving accepted takes them out
+ * again, and Pulse re-checks the status on every read (pulse/store.js).
+ * - A clip or picture stays in OpenVibe.Media: a submission keeps a link (url) and/or a med_ reference.
+ *
+ * Identity only ever comes from the viewer (JWT or service token), never from a body.
+ */
+const store = require('./store');
+const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/v1');
+const { discussionModerator } = require('../identity/capabilities');
+const { createAuthors } = require('../identity/authors');
+
+const KINDS = ['clip', 'art', 'idea', 'report'];
+const STATUSES = ['pending', 'accepted', 'rejected', 'withdrawn'];
+const PAGE = 30;
+const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SLUG_RE = /^[a-z]+-[a-z]+-\d{3,6}$/;
+const LIMITS = { title: 200, body: 10000, url: 2000, note: 1000, perDay: 10 };
+
+function createSubmissionService({ db, network = null, pulse = null, indexnow = null, config = {}, limits = {} } = {}) {
+ const authors = createAuthors({ db, network });
+ const base = (config.baseUrl || '').replace(/\/$/, '');
+ const perDay = limits.perDay != null ? limits.perDay : LIMITS.perDay;
+ const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Submissions] pulse:', err.message); } };
+ const ping = (slug) => { if (indexnow && indexnow.enabled) indexnow.pingSoon([`${base}/submissions/${slug}`]); };
+
+ const isAuthor = (v, s) => !!(v && v.kind === 'user' && v.subject && v.subject === s.author_subject);
+ const moderates = (v) => discussionModerator(v);
+ const canSee = (v, s) => s.status === 'accepted' || isAuthor(v, s) || moderates(v);
+
+ function shape(s, v, projections) {
+ const close = isAuthor(v, s) || moderates(v);
+ return {
+ slug: s.slug,
+ kind: s.kind,
+ title: s.title,
+ body: s.body,
+ url: s.url || null,
+ media_ref: s.media_ref || null,
+ status: s.status,
+ author: authors.author(s.author_subject, 'user', projections),
+ // Who reviewed and what they said: the author and moderators only.
+ reviewer: close && s.reviewer_subject ? authors.author(s.reviewer_subject, 'user', projections) : null,
+ review_note: close ? (s.review_note || null) : null,
+ reviewed_at: isoTime(s.reviewed_at),
+ created_at: isoTime(s.created_at),
+ updated_at: isoTime(s.updated_at),
+ page_url: `/submissions/${s.slug}`,
+ can: { withdraw: isAuthor(v, s) && ['pending', 'accepted'].includes(s.status), review: moderates(v) && s.status !== 'withdrawn' },
+ };
+ }
+ const shapeOne = async (s, v) => shape(s, v, await authors.projectionsFor([s.author_subject, s.reviewer_subject]));
+
+ /** The row a viewer may see, or 404 (never a 403 that confirms a slug exists). */
+ async function visible(v, slug) {
+ const s = SLUG_RE.test(String(slug || '')) ? await store.getBySlug(db, slug) : null;
+ if (!s || !canSee(v, s)) fail(404, 'submission.not_found', 'Submission not found');
+ return s;
+ }
+
+ function text(value, max, code, what, { required = false } = {}) {
+ const t = String(value == null ? '' : value).replace(/\r\n/g, '\n').trim();
+ if (required && !t) fail(400, code, `${what} is required`);
+ if (t.length > max) fail(400, code, `${what} is at most ${max} characters`);
+ return t;
+ }
+
+ return {
+ KINDS, STATUSES,
+ moderates,
+
+ /** POST /api/v1/submissions { kind, title, body?, url?, media_ref? } — a signed-in person. */
+ async create(v, body = {}) {
+ if (!v || v.kind === 'anonymous' || !v.subject) fail(401, 'auth.required', 'Sign in to submit');
+ if (v.kind !== 'user') fail(403, 'capability.denied', 'Submissions are sent by people, from their browser');
+ if (!/^usr_/.test(v.subject)) fail(403, 'submission.account_required', 'Submissions need an OpenVibe account');
+ const kind = String(body.kind || '');
+ if (!KINDS.includes(kind)) fail(400, 'submission.invalid_kind', `kind must be one of ${KINDS.join(', ')}`);
+ const title = text(body.title, LIMITS.title, 'submission.invalid_title', 'A title', { required: true }).replace(/\s+/g, ' ');
+ const text_ = text(body.body, LIMITS.body, 'submission.invalid_body', 'The description');
+ const url = text(body.url, LIMITS.url, 'submission.invalid_url', 'The link') || null;
+ if (url) {
+ let parsed = null;
+ try { parsed = new URL(url); } catch { /* invalid */ }
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) fail(400, 'submission.invalid_url', 'The link must be an http(s) address');
+ }
+ const mediaRef = String(body.media_ref == null ? '' : body.media_ref).trim() || null;
+ if (mediaRef && !MED_ID.test(mediaRef)) fail(400, 'submission.invalid_media_ref', 'media_ref must be an OpenVibe.Media object id (med_…)');
+ if ((kind === 'clip' || kind === 'art') && !url && !mediaRef) fail(400, 'submission.media_required', 'A clip or art submission needs a link or a Media object');
+ if (!text_ && !url && !mediaRef) fail(400, 'submission.empty', 'Describe it, link it, or both');
+ if (perDay > 0 && await store.countByAuthorSince(db, v.subject, sqlTime(Date.now() - 86_400_000)) >= perDay) {
+ fail(429, 'submission.daily_limit', `At most ${perDay} submissions a day — try again tomorrow`, { retry_after: 3600 });
+ }
+ const row = await store.insert(db, { slug: await store.generateSlug(db), kind, title, body: text_, url, media_ref: mediaRef, author_subject: v.subject });
+ return { submission: await shapeOne(row, v) };
+ },
+
+ /** GET /api/v1/submissions/:slug */
+ async get(v, slug) {
+ return { submission: await shapeOne(await visible(v, slug), v) };
+ },
+
+ /**
+ * GET /api/v1/submissions ?kind=&status=&mine=1&after=&limit=
+ * Everyone: accepted. mine=1: the signed-in person's own, any status. Moderators: any status.
+ */
+ async list(v, q = {}) {
+ const kind = q.kind == null || q.kind === '' ? null : String(q.kind);
+ if (kind && !KINDS.includes(kind)) fail(400, 'submission.invalid_kind', `kind must be one of ${KINDS.join(', ')}`);
+ let status = q.status == null || q.status === '' ? null : String(q.status);
+ if (status && !STATUSES.includes(status)) fail(400, 'submission.invalid_status', `status must be one of ${STATUSES.join(', ')}`);
+ const mine = q.mine === '1' || q.mine === 'true' || q.mine === true;
+ let author = null;
+ if (mine) {
+ if (!v || v.kind !== 'user' || !v.subject) fail(401, 'auth.required', 'Sign in to see your submissions');
+ author = v.subject;
+ } else if (!moderates(v)) {
+ if (status && status !== 'accepted') fail(403, 'submission.moderators_only', 'Only moderators see submissions under review');
+ status = 'accepted';
+ }
+ const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
+ const before = decodeCursor(q.after, 2);
+ const { rows, hasMore } = await store.list(db, { status, author, kind, before, limit });
+ const projections = await authors.projectionsFor(rows.flatMap((r) => [r.author_subject, r.reviewer_subject]));
+ const last = rows[rows.length - 1];
+ return { submissions: rows.map((r) => shape(r, v, projections)), next_cursor: hasMore && last ? encodeCursor([last.created_at, last.id]) : null };
+ },
+
+ /** POST /api/v1/submissions/:slug/withdraw — the author, while pending or accepted. */
+ async withdraw(v, slug) {
+ const s = await visible(v, slug);
+ if (!isAuthor(v, s)) fail(403, 'submission.not_author', 'Only its author withdraws a submission');
+ const next = await store.setStatus(db, s.id, { status: 'withdrawn', from: ['pending', 'accepted'] });
+ if (!next) fail(409, 'submission.not_withdrawable', `A ${s.status} submission cannot be withdrawn`);
+ if (s.status === 'accepted') { await tell((p) => p.submissionGone(s.slug)); ping(s.slug); }
+ return { submission: await shapeOne(next, v) };
+ },
+
+ /** POST /api/v1/submissions/:slug/review { decision: accept|reject, note? } — moderators. */
+ async review(v, slug, body = {}) {
+ if (!moderates(v)) {
+ if (!v || v.kind === 'anonymous') fail(401, 'auth.required', 'Sign in to review');
+ await visible(v, slug); // 404 first for what this viewer may not see
+ fail(403, 'submission.moderators_only', 'Only moderators review submissions');
+ }
+ const s = await visible(v, slug);
+ const decision = String(body.decision || '');
+ if (!['accept', 'reject'].includes(decision)) fail(400, 'submission.invalid_decision', 'decision must be accept or reject');
+ const note = text(body.note, LIMITS.note, 'submission.invalid_note', 'The note') || null;
+ const status = decision === 'accept' ? 'accepted' : 'rejected';
+ const next = await store.setStatus(db, s.id, { status, from: ['pending', 'accepted', 'rejected'], reviewer_subject: v.subject || null, review_note: note, reviewed: true });
+ if (!next) fail(409, 'submission.withdrawn', 'Its author withdrew this submission');
+ if (status === 'accepted') await tell((p) => p.submissionAccepted(next));
+ else await tell((p) => p.submissionGone(next.slug));
+ if (s.status === 'accepted' || status === 'accepted') ping(s.slug);
+ return { submission: await shapeOne(next, v) };
+ },
+ };
+}
+
+module.exports = { createSubmissionService, KINDS, STATUSES, LIMITS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * Submissions store — pure SQL over submissions (migrations/0003_submissions.sql).
+ */
+const crypto = require('crypto');
+const { SLUG_ADJECTIVES, SLUG_NOUNS } = require('../pastes/store');
+
+/** adj-noun-NNNN, like a public paste's slug with a wider number; unique in submissions. */
+async function generateSlug(db) {
+ const taken = db.prepare('SELECT 1 FROM submissions WHERE slug = ?');
+ for (let i = 0; i < 20; i++) {
+ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, i < 10 ? 10000 : 1000000)}`;
+ if (!await taken.get(slug)) return slug;
+ }
+ throw new Error('Could not generate a unique submission slug');
+}
+
+async function getBySlug(db, slug) {
+ return await db.prepare('SELECT * FROM submissions WHERE slug = ?').get(String(slug));
+}
+
+async function insert(db, s) {
+ const info = await db.prepare(`INSERT INTO submissions (slug, kind, title, body, url, media_ref, author_subject)
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`).run(s.slug, s.kind, s.title, s.body, s.url, s.media_ref, s.author_subject);
+ return await db.prepare('SELECT * FROM submissions WHERE id = ?').get(info.lastInsertRowid);
+}
+
+/** Move a submission on, only from one of the statuses in `from` (a race loses cleanly). → the row, or null */
+async function setStatus(db, id, { status, from, reviewer_subject, review_note, reviewed }) {
+ const sets = ['status = ?', 'updated_at = ov_now()'];
+ const params = [status];
+ if (reviewed) { sets.push('reviewer_subject = ?', 'review_note = ?', 'reviewed_at = ov_now()'); params.push(reviewer_subject || null, review_note || null); }
+ const r = await db.prepare(`UPDATE submissions SET ${sets.join(', ')} WHERE id = ? AND status IN (${from.map(() => '?').join(',')})`).run(...params, id, ...from);
+ return r.changes ? await db.prepare('SELECT * FROM submissions WHERE id = ?').get(id) : null;
+}
+
+/** How many a person sent since a time (the daily cap). */
+async function countByAuthorSince(db, subject, since) {
+ return Number((await db.prepare('SELECT COUNT(*) AS n FROM submissions WHERE author_subject = ? AND created_at >= ?').get(subject, since)).n);
+}
+
+/**
+ * Newest first, keyset-paginated by (created_at, id).
+ * opts.status one status, or null for any
+ * opts.author only this person's
+ * opts.kind one kind, or null
+ * opts.before [created_at, id] of the last row already seen
+ * → { rows, hasMore }
+ */
+async function list(db, { status = null, author = null, kind = null, before = null, limit = 30 } = {}) {
+ const rows = await db.prepare(`
+ SELECT * FROM submissions
+ WHERE (@status::text IS NULL OR status = @status)
+ AND (@author::text IS NULL OR author_subject = @author)
+ AND (@kind::text IS NULL OR kind = @kind)
+ AND (@at::text IS NULL OR created_at < @at OR (created_at = @at AND id < @id::bigint))
+ ORDER BY created_at DESC, id DESC
+ LIMIT @limit`).all({ status, author, kind, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
+ const hasMore = rows.length > limit;
+ if (hasMore) rows.pop();
+ return { rows, hasMore };
+}
+
+module.exports = { generateSlug, getBySlug, insert, setStatus, countByAuthorSince, list };
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+'use strict';
+/**
+ * Submissions: a signed-in person submits (API or the no-JS form) → pending, seen only by them and
+ * moderators → a moderator accepts → public on its page, in the list and in Pulse (origin user, the
+ * author as actor); rejected ones never reach Pulse; the author withdraws; non-moderators cannot review;
+ * signed-out visitors and services cannot submit; identity never comes from the body; a form from
+ * another site is refused.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+const form = (obj, headers = {}) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers } });
+const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
+
+(async () => {
+ const t = await boot({ authority: 'community' });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+ const bossSubject = ids.newId('user');
+ const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });
+ const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
+ const api = (path, { method = 'GET', token, json, headers = {} } = {}) => t.get(`/api/v1/submissions${path}`, {
+ method,
+ headers: { ...headers, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) },
+ body: json !== undefined ? JSON.stringify(json) : undefined,
+ });
+ const row = async (slug) => await t.db.prepare('SELECT * FROM submissions WHERE slug = ?').get(slug);
+ const pulseItem = async (slug) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").get(slug);
+ const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM submissions').get()).n);
+ const submitApi = async (body, token = alexJwt) => { const r = await api('', { method: 'POST', token, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json().submission; };
+
+ let slug; // the no-JS submission the next checks follow
+
+ await check('signed-out visitors cannot submit (API 401, form → sign in) and see a sign-in prompt instead of the form', async () => {
+ const before = await count();
+ const r = await api('', { method: 'POST', json: { kind: 'idea', title: 'Anonymous idea', body: 'x' } });
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(r.json().code, 'auth.required');
+ const f = await t.get('/submissions', form({ kind: 'idea', title: 'Anonymous idea', body: 'x' }));
+ assert.strictEqual(f.status, 303);
+ assert.ok(f.headers.get('location').startsWith('/auth/login?next=%2Fsubmissions'), f.headers.get('location'));
+ assert.strictEqual(await count(), before);
+ const page = await t.get('/submissions');
+ assert.strictEqual(page.status, 200);
+ has(page.text, 'Sign in with your OpenVibe account');
+ assert.ok(!page.text.includes('action="/submissions"'), 'no submit form when signed out');
+ });
+
+ await check('the no-JS form submits as the signed-in person → pending, seen only by its author', async () => {
+ const page = await t.get('/submissions', as(alexJwt));
+ has(page.text, '<form class="paste-form" method="post" action="/submissions"');
+ const r = await t.get('/submissions', as(alexJwt, form({ kind: 'clip', title: 'Best play of the week', url: 'https://openvibe.live/clip/abc', body: 'Watch the **ending**.' })));
+ assert.strictEqual(r.status, 303, r.text);
+ slug = r.headers.get('location').replace('/submissions/', '');
+ assert.ok(/^[a-z]+-[a-z]+-\d+$/.test(slug), slug);
+ const s = await row(slug);
+ assert.deepStrictEqual([s.kind, s.title, s.url, s.status, s.author_subject, s.reviewer_subject], ['clip', 'Best play of the week', 'https://openvibe.live/clip/abc', 'pending', alex.subject_id, null]);
+ const own = await t.get(`/submissions/${slug}`, as(alexJwt));
+ assert.strictEqual(own.status, 200);
+ has(own.text, 'Pending review');
+ has(own.text, '<strong>ending</strong>');
+ has(own.text, 'noindex,nofollow');
+ has(own.text, `action="/submissions/${slug}/withdraw"`);
+ assert.ok(!own.text.includes(`action="/submissions/${slug}/review"`), 'the author does not review');
+ });
+
+ await check('pending is not public: page 404 for others, not listed, not in Pulse', async () => {
+ assert.strictEqual((await t.get(`/submissions/${slug}`)).status, 404);
+ assert.strictEqual((await t.get(`/submissions/${slug}`, as(samJwt))).status, 404);
+ assert.strictEqual((await api(`/${slug}`)).status, 404);
+ assert.ok(!(await t.get('/submissions')).text.includes('Best play of the week'));
+ assert.deepStrictEqual((await api('')).json().submissions.map((s) => s.slug), []);
+ assert.strictEqual((await api('?status=pending', { token: samJwt })).status, 403);
+ assert.strictEqual(await pulseItem(slug), undefined);
+ const mine = (await api('?mine=1', { token: alexJwt })).json().submissions;
+ assert.deepStrictEqual(mine.map((s) => [s.slug, s.status]), [[slug, 'pending']]);
+ });
+
+ await check('non-moderators cannot review (404 for what they cannot see, 403 otherwise), page or API', async () => {
+ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', token: samJwt, json: { decision: 'accept' } })).status, 404);
+ const own = await api(`/${slug}/review`, { method: 'POST', token: alexJwt, json: { decision: 'accept' } });
+ assert.strictEqual(own.status, 403);
+ assert.strictEqual(own.json().code, 'submission.moderators_only');
+ assert.strictEqual((await t.get(`/submissions/${slug}/review`, as(alexJwt, form({ decision: 'accept' })))).status, 403);
+ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', json: { decision: 'accept' } })).status, 401);
+ assert.strictEqual((await t.get('/submissions/review', as(alexJwt))).status, 403);
+ assert.strictEqual((await t.get('/submissions/review')).status, 303);
+ assert.strictEqual((await row(slug)).status, 'pending');
+ });
+
+ await check('a moderator sees the queue and accepts with the no-JS form → public page, list and Pulse', async () => {
+ assert.ok(!(await t.get('/submissions', as(bossJwt))).text.includes('Best play of the week'), 'the list is accepted ones, for moderators too');
+ const queue = await t.get('/submissions/review', as(bossJwt));
+ assert.strictEqual(queue.status, 200);
+ has(queue.text, 'Best play of the week');
+ const page = await t.get(`/submissions/${slug}`, as(bossJwt));
+ has(page.text, `action="/submissions/${slug}/review"`);
+ const r = await t.get(`/submissions/${slug}/review`, as(bossJwt, form({ decision: 'accept', note: 'Great one' })));
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(r.headers.get('location'), `/submissions/${slug}`);
+ const s = await row(slug);
+ assert.deepStrictEqual([s.status, s.reviewer_subject, s.review_note], ['accepted', bossSubject, 'Great one']);
+ assert.ok(s.reviewed_at);
+
+ const pub = await t.get(`/submissions/${slug}`);
+ assert.strictEqual(pub.status, 200);
+ has(pub.text, `<link rel="canonical" href="https://openvibe.community/submissions/${slug}">`);
+ has(pub.text, '<meta property="og:type" content="article">');
+ has(pub.text, '<meta property="og:title" content="Best play of the week">');
+ has(pub.text, 'index,follow');
+ assert.ok(!pub.text.includes('Great one'), 'the review note is for the author and moderators');
+ assert.ok(!pub.text.includes(`/submissions/${slug}/withdraw`) && !pub.text.includes(`/submissions/${slug}/review"`));
+ has((await t.get(`/submissions/${slug}`, as(alexJwt))).text, 'Great one');
+ has((await t.get('/submissions')).text, 'Best play of the week');
+ has((await t.get('/submissions?kind=clip')).text, 'Best play of the week');
+ assert.ok(!(await t.get('/submissions?kind=art')).text.includes('Best play of the week'));
+ const listed = (await api('')).json().submissions;
+ assert.deepStrictEqual(listed.map((x) => [x.slug, x.author.subject, x.review_note]), [[slug, alex.subject_id, null]]);
+
+ const it = await pulseItem(slug);
+ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user', 'public']);
+ const feed = (await t.get('/api/v1/pulse?origin=user')).json().items;
+ assert.ok(feed.some((i) => i.source.type === 'submission' && i.source.id === slug && i.actor.subject === alex.subject_id));
+ has((await t.get('/pulse')).text, 'Best play of the week');
+ });
+
+ await check('the author withdraws: gone from the page, the list and Pulse; nobody else may; not twice', async () => {
+ assert.strictEqual((await api(`/${slug}/withdraw`, { method: 'POST', token: samJwt })).status, 403);
+ const r = await t.get(`/submissions/${slug}/withdraw`, as(alexJwt, form({})));
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual((await row(slug)).status, 'withdrawn');
+ assert.strictEqual(await pulseItem(slug), undefined);
+ assert.strictEqual((await t.get(`/submissions/${slug}`)).status, 404);
+ assert.ok(!(await t.get('/submissions')).text.includes('Best play of the week'));
+ has((await t.get(`/submissions/${slug}`, as(alexJwt))).text, 'Withdrawn');
+ assert.strictEqual((await api(`/${slug}/withdraw`, { method: 'POST', token: alexJwt })).status, 409);
+ assert.strictEqual((await api(`/${slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } })).status, 409);
+ // A pending one withdraws too.
+ const p = await submitApi({ kind: 'idea', title: 'Second thoughts', body: 'Maybe not' });
+ assert.strictEqual((await api(`/${p.slug}/withdraw`, { method: 'POST', token: alexJwt })).json().submission.status, 'withdrawn');
+ });
+
+ await check('rejected never reaches Pulse; a later rejection of an accepted one takes it out; Pulse re-checks on read', async () => {
+ const a = await submitApi({ kind: 'report', title: 'Broken link on the home page', body: 'The footer link 404s' });
+ const r = await api(`/${a.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'reject', note: 'Already fixed' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().submission.status, 'rejected');
+ assert.strictEqual(await pulseItem(a.slug), undefined);
+ assert.strictEqual((await api(`/${a.slug}`)).status, 404);
+ assert.strictEqual((await api(`/${a.slug}`, { token: alexJwt })).json().submission.review_note, 'Already fixed');
+
+ const b = await submitApi({ kind: 'art', title: 'Fan art', url: 'https://example.com/art.png' });
+ await api(`/${b.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });
+ assert.ok(await pulseItem(b.slug));
+ await api(`/${b.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'reject' } });
+ assert.strictEqual(await pulseItem(b.slug), undefined);
+
+ const c = await submitApi({ kind: 'idea', title: 'Changed behind our back', body: 'x' });
+ await api(`/${c.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });
+ await t.db.prepare("UPDATE submissions SET status = 'rejected' WHERE slug = ?").run(c.slug); // no hook ran
+ assert.ok(await pulseItem(c.slug), 'row still there');
+ assert.ok(!(await t.get('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === c.slug), 'but never listed');
+ });
+
+ await check('identity comes from the token, never the body; services cannot submit; input is checked', async () => {
+ const s = await submitApi({ kind: 'idea', title: 'Mine', body: 'x', author_subject: sam.subject_id, status: 'accepted', reviewer_subject: sam.subject_id });
+ const r = await row(s.slug);
+ assert.deepStrictEqual([r.author_subject, r.status, r.reviewer_subject], [alex.subject_id, 'pending', null]);
+ const svc = net.signService({ cap: ['community.comment.write', 'community.paste.create'] });
+ const sv = await api('', { method: 'POST', token: svc, json: { kind: 'idea', title: 'From a service', body: 'x' }, headers: { 'x-ov-subject': alex.subject_id } });
+ assert.strictEqual(sv.status, 403, sv.text);
+ for (const [body, code] of [
+ [{ kind: 'meme', title: 'x', body: 'y' }, 'submission.invalid_kind'],
+ [{ kind: 'idea', title: '', body: 'y' }, 'submission.invalid_title'],
+ [{ kind: 'clip', title: 'No clip' }, 'submission.media_required'],
+ [{ kind: 'art', title: 'Bad link', url: 'javascript:alert(1)' }, 'submission.invalid_url'],
+ [{ kind: 'art', title: 'Bad ref', media_ref: 'med_nope' }, 'submission.invalid_media_ref'],
+ ]) {
+ const out = await api('', { method: 'POST', token: alexJwt, json: body });
+ assert.strictEqual(out.status, 400, `${JSON.stringify(body)}: ${out.text}`);
+ assert.strictEqual(out.json().code, code);
+ }
+ const ok = await submitApi({ kind: 'art', title: 'Stored in Media', media_ref: 'med_01J00000000000000000000000' });
+ assert.strictEqual(ok.media_ref, 'med_01J00000000000000000000000');
+ const bad = await t.get('/submissions', as(alexJwt, form({ kind: 'clip', title: 'No link' })));
+ assert.strictEqual(bad.status, 400);
+ has(bad.text, 'A clip or art submission needs a link');
+ has(bad.text, 'value="No link"');
+ });
+
+ await check('a form sent from another site is refused (submit, withdraw, review)', async () => {
+ const before = await count();
+ const evil = { origin: 'https://evil.example' };
+ const r = await t.get('/submissions', as(alexJwt, form({ kind: 'idea', title: 'Cross site', body: 'x' }, evil)));
+ assert.strictEqual(r.status, 403);
+ has(r.text, 'That form was sent from another site.');
+ assert.strictEqual(await count(), before);
+ const p = await submitApi({ kind: 'idea', title: 'Target', body: 'x' });
+ assert.strictEqual((await t.get(`/submissions/${p.slug}/withdraw`, as(alexJwt, form({}, evil)))).status, 403);
+ assert.strictEqual((await t.get(`/submissions/${p.slug}/review`, as(bossJwt, form({ decision: 'accept' }, evil)))).status, 403);
+ assert.strictEqual((await row(p.slug)).status, 'pending');
+ const same = await t.get('/submissions', as(alexJwt, form({ kind: 'idea', title: 'Same site', body: 'x' }, { origin: 'https://openvibe.community' })));
+ assert.strictEqual(same.status, 303);
+ });
+
+ await t.close();
+ done();
+})();