openvibers/openvibe.community Commits
Show shared inline comments on paste and Community thread pages (#384)
Changes
nnew file mode 100644@@@ -0,0 +1,15 @@++.ovc-thread { margin: 24px 0; }++.ovc-head, .ovc-order, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }++.ovc-head { justify-content: space-between; }++.ovc-comment { padding: 12px 0; border-bottom: 1px solid var(--border, #5555); }++.ovc-comment header img { border-radius: 50%; object-fit: cover; }++.ovc-comment time, .ovc-community { font-size: .875em; opacity: .8; }++.ovc-replies { margin-left: 24px; }++.ovc-actions form { display: inline; }++.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }++.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }++.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }++.ovc-order { display: inline-flex; gap: 0; border: 1px solid currentColor; border-radius: 8px; overflow: hidden; }++.ovc-order a { border: 0; border-radius: 0; }++.ovc-order a + a { border-left: 1px solid currentColor; }++.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 12%, transparent); }nnew file mode 100644@@@ -0,0 +1,81 @@++(function () {++ 'use strict';++ var thread = document.querySelector('[data-ovc-thread]');++ if (!thread) return;++ var allowed = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);++ function read(html) { return new DOMParser().parseFromString(html, 'text/html').querySelector('[data-ovc-thread]'); }++ function replace(next, url) {++ if (!next) return false;++ thread.replaceWith(next); thread = next;++ if (url) history.pushState(null, '', url);++ return true;++ }++ document.addEventListener('click', async function (event) {++ var reply = event.target.closest('[data-ovc-reply]');++ if (reply && thread.contains(reply)) {++ var form = thread.querySelector('[data-ovt-composer]');++ var item = reply.closest('.ovc-comment');++ if (!form || !item) return;++ event.preventDefault();++ var parent = form.querySelector('[name="parent_id"]');++ if (!parent) { parent = document.createElement('input'); parent.type = 'hidden'; parent.name = 'parent_id'; form.appendChild(parent); }++ parent.value = reply.dataset.ovcReply;++ var slot = item.querySelector(':scope > [data-ovc-composer-slot]');++ if (!slot) { slot = document.createElement('div'); slot.dataset.ovcComposerSlot = ''; item.insertBefore(slot, item.querySelector(':scope > .ovc-replies')); }++ slot.appendChild(form);++ form.querySelector('textarea').focus();++ history.replaceState(null, '', reply.href);++ return;++ }++ var more = event.target.closest('[data-ovc-more]');++ if (more && thread.contains(more)) {++ event.preventDefault();++ more.setAttribute('aria-busy', 'true');++ try {++ var response = await fetch(more.href, { credentials: 'same-origin' });++ if (!response.ok) throw new Error('Replies could not be loaded.');++ var next = read(await response.text());++ var currentReplies = more.closest('[data-ovc-replies]');++ var loadedReplies = next && next.querySelector('[data-ovc-replies="' + more.dataset.ovcMore + '"]');++ if (!loadedReplies) throw new Error('Replies could not be loaded.');++ currentReplies.replaceWith(loadedReplies);++ history.pushState(null, '', more.href);++ } catch (error) { more.textContent = error.message; more.removeAttribute('aria-busy'); }++ return;++ }++ var order = event.target.closest('[data-ovc-order]');++ if (order && thread.contains(order)) {++ event.preventDefault();++ order.setAttribute('aria-busy', 'true');++ try {++ var page = await fetch(order.href, { credentials: 'same-origin' });++ if (!page.ok || !replace(read(await page.text()), order.href)) throw new Error('Comments could not be refreshed.');++ } catch (error) { order.textContent = error.message; order.removeAttribute('aria-busy'); }++ }++ });++ document.addEventListener('submit', async function (event) {++ var form = event.target;++ if (!thread.contains(form) || (!form.matches('[data-ovt-composer]') && !form.closest('.ovc-actions'))) return;++ var files = form.querySelector('[data-ovt-files]');++ var note = form.querySelector('[data-ovt-note]');++ if (files && files.files.length) {++ var problem = files.files.length > 4 ? 'Attach up to four files.' : '';++ for (var file of files.files) {++ if (file.size > 16 * 1024 * 1024) problem = 'A file can be up to 16 MB.';++ if (!allowed.has(file.type)) problem = 'Attach JPEG, PNG, GIF, WebP, PDF or text files.';++ }++ if (problem) { event.preventDefault(); if (note) note.textContent = problem; return; }++ }++ event.preventDefault();++ var button = form.querySelector('[type="submit"]');++ if (button) button.disabled = true;++ if (note) note.textContent = 'Sending…';++ try {++ var body = form.matches('[data-ovt-composer]') ? new FormData(form) : new URLSearchParams(new FormData(form));++ var response = await fetch(form.action, { method: 'POST', body: body, credentials: 'same-origin' });++ var html = await response.text();++ var next = read(html);++ if (!response.ok || !replace(next, response.url)) throw new Error('The comment could not be saved. Try again.');++ } catch (error) { if (note) note.textContent = error.message; if (button) button.disabled = false; }++ });++})();@@@ -46,6 +46,7 @@ const v1 = require('./http/v1');const { createCommentService } = require('./comments/service');const { createSharing, describeRef } = require('./sharing');const { createCommentsApi } = require('./comments/api');++const commentForm = require('./comments/form');const { createCommentPages } = require('./comments/routes');const { createForumService } = require('./forum/service');const { createVipGate } = require('./vip');@@@ -630,12 +631,23 @@ async function createApp(opts = {}) {// comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while// nobody has commented. The comment service applies the paste's visibility again.// A burn-after-read paste is gone once shown, so it has no comment panel.-- const after = /^\d{1,15}$/.test(String(query.after || '')) ? String(query.after) : null;-- const sort = query.sort === 'old' ? 'old' : 'new';++ const after = /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(query.after || '')) ? String(query.after) : null;++ const sort = query.sort === 'new' ? 'new' : 'top';const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });++ const expandedId = /^\d{1,15}$/.test(String(query.replies || '')) ? String(query.replies) : null;++ let expandedReplies = null;++ if (thread && expandedId && thread.comments.some((c) => String(c.id) === expandedId)) {++ expandedReplies = [];++ let cursor = null;++ do {++ const page = await comments.get(viewer, thread.thread.access_id, { parent: expandedId, after: cursor, limit: 100 });++ expandedReplies.push(...page.comments);++ cursor = page.next_cursor;++ } while (cursor);++ }return {status: 200, slug: paste.slug, canonical: paste.canonical_slug || paste.slug,-- body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after, share }),++ body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after, replyId: query.reply, expandedId, expandedReplies, share }),};}@@@ -656,7 +668,7 @@ async function createApp(opts = {}) {// No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.// The thread opens on the first comment; the comment service decides the rest (visibility, lock, blocks, limits).-- app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), wrap(async (req, res, next) => {++ app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), commentForm.parse, wrap(async (req, res, next) => {const { slug } = req.params;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);@@@ -670,8 +682,8 @@ async function createApp(opts = {}) {back = `/p/${encodeURIComponent(found.canonical_slug || stable)}`;if (!req.viewer || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);const { thread } = await comments.resolve(req.viewer, { ref: pasteRef(stable) });-- const b = req.body || {};-- const out = await comments.add(req.viewer, thread.id, { message: String(b.message || '').slice(0, 5000), parent_id: b.parent_id || null });++ const body = await commentForm.bodyFor(comments, req.viewer, pasteRef(stable), req);++ const out = await comments.add(req.viewer, thread.id, body);return res.redirect(303, `${back}#comment-${out.comment.id}`);} catch (err) {if (!(err instanceof v1.ApiError) && !(err && err.name === 'PasteError')) return next(err);@@@ -681,6 +693,17 @@ async function createApp(opts = {}) {}}));++ app.post('/p/:slug/comments/delete', withUser, express.urlencoded({ extended: false, limit: '4kb' }), wrap(async (req, res, next) => {++ if (req.get('origin') && req.get('origin') !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);++ try {++ const found = await source.getPaste(req.params.slug, { ...ctxOf(req), noView: true });++ const id = String((req.body || {}).comment_id || '');++ if (!/^\d{1,15}$/.test(id)) throw new v1.ApiError(400, 'comment.invalid', 'Invalid comment');++ await comments.remove(req.viewer, id, pasteRef(found.slug));++ return res.redirect(303, `/p/${encodeURIComponent(found.canonical_slug || found.slug)}#comments`);++ } catch (err) { return next(err); }++ }));++// Raw text is served here, the screenshot link is the stored Media URL.const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');app.get('/p/:slug/raw', withUser, async (req, res) => {nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++++const multer = require('multer');++const { ApiError } = require('../http/v1');++++const TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);++const upload = multer({ storage: multer.memoryStorage(), limits: { files: 4, fileSize: 16 * 1024 * 1024, fields: 5 } }).array('files', 4);++++function parse(req, res, next) {++ if (!req.is('multipart/form-data')) return next();++ return upload(req, res, (err) => next(err ? new ApiError(err.code === 'LIMIT_FILE_SIZE' ? 413 : 400,++ 'comment.attachment_invalid', err.code === 'LIMIT_FILE_SIZE' ? 'A file can be up to 16 MB' : 'Attach up to four files') : null));++}++++async function bodyFor(comments, viewer, ref, req) {++ const body = req.body || {};++ const message = String(body.message || '').trim();++ if (!message || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'A comment is 1 to 2,000 characters');++ const parent = String(body.parent_id || '');++ if (parent && !/^\d{1,15}$/.test(parent)) throw new ApiError(400, 'comment.parent_invalid', 'That reply target is not valid');++ const files = req.files || [];++ const attachments = [];++ for (const file of files) {++ if (!TYPES.has(file.mimetype)) throw new ApiError(415, 'comment.file_type', 'Attach JPEG, PNG, GIF, WebP, PDF or text files');++ const out = await comments.uploadAttachment(viewer, ref, file);++ attachments.push(out.attachment);++ }++ return { message, parent_id: parent || null, attachments };++}++++module.exports = { parse, bodyFor };@@@ -15,6 +15,7 @@ const express = require('express');const pages = require('../render/pages');const { threadPage } = require('../render/comments');const { ApiError } = require('../http/v1');++const commentForm = require('./form');const cache = require('openvibe-shared/cache-policy');const { setRobotsHeader } = require('../render/layout');@@@ -40,9 +41,21 @@ function createCommentPages({ comments, viewers, config }) {const notFound = () => new ApiError(404, 'thread.not_found', 'Comment thread not found');async function render(req, res, { status = 200, error = null, draft = '' } = {}) {-- const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;-- const page = await comments.get(req.viewer, req.params.accessId, { sort: 'new', after, limit: 30 });-- html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, error, draft }), status);++ const after = /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;++ const sort = req.query.sort === 'new' ? 'new' : 'top';++ const page = await comments.get(req.viewer, req.params.accessId, { sort, after, limit: 30 });++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;++ let expandedReplies = null;++ if (expandedId && page.comments.some((c) => String(c.id) === expandedId)) {++ expandedReplies = [];++ let cursor = null;++ do {++ const more = await comments.get(req.viewer, req.params.accessId, { parent: expandedId, after: cursor, limit: 100 });++ expandedReplies.push(...more.comments);++ cursor = more.next_cursor;++ } while (cursor);++ }++ html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, sort, replyId: req.query.reply, expandedId, expandedReplies, error, draft }), status);}router.get('/c/:accessId', withViewer, wrap(async (req, res, next) => {@@@ -52,7 +65,7 @@ function createCommentPages({ comments, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.post('/c/:accessId', withViewer, form, wrap(async (req, res, next) => {++ router.post('/c/:accessId', withViewer, form, commentForm.parse, wrap(async (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);const draft = String((req.body || {}).message || '').slice(0, 5000);@@@ -60,7 +73,9 @@ function createCommentPages({ comments, viewers, config }) {if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();// Signed-in people only on this page, whatever the thread allows through the API.if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to comment');-- const out = await comments.add(req.viewer, req.params.accessId, { message: draft });++ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'top', limit: 1 });++ const body = await commentForm.bodyFor(comments, req.viewer, page.thread.ref, req);++ const out = await comments.add(req.viewer, req.params.accessId, body);res.redirect(303, `/c/${req.params.accessId}#comment-${out.comment.id}`);} catch (err) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);@@@ -68,6 +83,19 @@ function createCommentPages({ comments, viewers, config }) {}}));++ router.post('/c/:accessId/delete', withViewer, form, wrap(async (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);++ try {++ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();++ const id = String((req.body || {}).comment_id || '');++ if (!/^\d{1,15}$/.test(id)) throw notFound();++ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'top', limit: 1 });++ await comments.remove(req.viewer, id, page.thread.ref);++ return res.redirect(303, `/c/${req.params.accessId}#comments`);++ } catch (err) { return failPage(req, res, err, next); }++ }));++return router;}@@@ -96,7 +96,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };}-- function shapeComment(c, v, projections, votes, threadId, targets) {++ function shapeComment(c, v, projections, votes, threadId, targets, ownerSubject) {const deleted = !!c.deleted_at;const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);const target = c.reply_to_id ? targets.get(c.reply_to_id) : null;@@@ -121,10 +121,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =updated_at: isoTime(c.updated_at),edited_at: deleted ? null : isoTime(c.edited_at),can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),-- can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),++ can_delete: !deleted && (moderator(v) || !!(v && v.subject && (c.author_subject === v.subject || ownerSubject === v.subject))),attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),};-- if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId, targets));++ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId, targets, ownerSubject));return out;}@@@ -134,7 +134,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =const targets = await store.replyTargets(db, all.map((c) => c.reply_to_id));const projections = await authors.projectionsFor(all.map((c) => c.author_subject).concat([...targets.values()].map((c) => c.author_subject)));const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);-- return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v), targets));++ const ownerSubject = await entityOwner(t);++ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v), targets, ownerSubject));}/** A thread by its access id, the only id anyone addresses a thread by (C-14). */@@@ -247,7 +248,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to attach a file');const paste = ref && ref.service === 'community' && ref.type === 'paste';const owner = ref && ((ref.service === 'live' && ['vod', 'clip'].includes(ref.type) && v.service === 'svc:live')-- || (ref.service === 'pics' && ref.type === 'picture' && v.service === 'svc:media-hub'));++ || (ref.service === 'pics' && ref.type === 'picture' && v.service === 'svc:media-hub')++ || (ref.service === 'media-hub' && ref.type === 'video' && v.service === 'svc:media-hub'));const picture = ref && ref.service === 'pics' && ref.type === 'picture' && v.kind === 'user';if (!paste && !picture && !(owner && v.kind === 'service' && checkCapability(v.claims, 'community.comment.write').allowed))fail(403, 'attachment.owner_service', 'Upload this attachment through the service that owns the item');@@@ -401,11 +403,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =},/** DELETE /comments/:id — the author or a moderator. */-- async remove(v, commentId) {-- const { comment: c } = await visibleComment(v, commentId);++ async remove(v, commentId, expectedRef = null) {++ const { comment: c, thread: t } = await visibleComment(v, commentId);++ if (expectedRef && (t.ref_service !== expectedRef.service || t.ref_type !== expectedRef.type || t.ref_id !== String(expectedRef.id)))++ fail(404, 'comment.not_found', 'Comment not found');if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);-- if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');++ const isOwner = !!(v.subject && v.subject === await entityOwner(t));++ if (!isAuthor && !isOwner && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');await store.softDeleteComment(db, c.id, v.subject || v.service || null);for (const a of c.attachments || []) {if (media && typeof media.deleteObject === 'function') {@@@ -7,7 +7,7 @@* is a plain form post. Messages are plain text, always escaped.*/const config = require('../config');--const { renderPage } = require('./layout');++const { renderPage, asset } = require('./layout');const { escapeHtml: esc } = require('./highlight');const { timeTag, fmtDate, num } = require('./pages');const { who } = require('./forum');@@@ -71,49 +71,19 @@ function repliesHtml(c) {* sorting links, where sign-in returns), action: where the form posts. sort: 'new' or 'old' when the* page offers both orders (null: newest first, no sort links). note: HTML after "Commenting as …".*/--function commentPanel({ page, user, base, action = base, after = null, sort = null, error = null, draft = '', note = '' }) {-- const thread = page ? page.thread : null;-- const comments = page ? page.comments : [];-- const next = page ? page.next_cursor : null;-- const viewer = page ? page.viewer : { can_comment: !!user, can_moderate: false };-- const href = (o = {}) => {-- const q = new URLSearchParams();-- if (o.sort === 'old') q.set('sort', 'old');-- if (o.after) q.set('after', o.after);-- const qs = q.toString();-- return qs ? `${base}?${qs}` : base;-- };---- let form = '';-- if (thread && thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';-- else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;-- else if (viewer.can_comment) {-- form = `<form class="paste-form reply-form" method="post" action="${esc(action)}" id="comment">-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>.${note ? ` ${note}` : ''}</span></div>-- </form>`;-- }---- const tabs = sort && thread && thread.comment_count > 1-- ? `<nav class="tabs sort-tabs" aria-label="Sort comments">${[['new', 'Newest'], ['old', 'Oldest']].map(([id, label]) => `<a class="tab${id === sort ? ' active' : ''}" href="${esc(href({ sort: id }))}"${id === sort ? ' aria-current="page"' : ''}>${label}</a>`).join('')}</nav>`-- : '';-- const list = comments.length-- ? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`-- : `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;-- const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(href({ sort, after: next }))}">${sort === 'old' ? 'More' : 'Older'} comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';---- return `${after ? '' : form}-- ${after ? `<p><a href="${esc(href({ sort }))}">${sort === 'old' ? 'First' : 'Newest'} comments</a></p>` : ''}-- ${tabs}-- ${list}-- ${older}`;++function commentPanel({ page, user, base, action = base, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {++ return require('./inline-thread').renderThread({++ answer: page || { thread: null, comments: [], viewer: { can_comment: true } },++ base, post: action, remove: `${action}/delete`, signedIn: !!user && (!page || page.viewer.can_comment !== false),++ attachmentsAllowed: !page || ['community/paste', 'pics/picture'].includes(`${page.thread.ref.service}/${page.thread.ref.type}`),++ sort, replyId, expandedId, expandedReplies, error, draft,++ });}/*** page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).*/--function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {++function threadPage({ accessId, page, user, after = null, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {const { thread } = page;const ref = thread.ref;const what = refName(ref);@@@ -121,7 +91,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =const label = ref.label || `a ${what} on ${product}`;const src = sourceUrl(ref);const base = `/c/${accessId}`;-- const panel = commentPanel({ page, user, base, after, error, draft, note: `It shows on ${esc(product)} too.` });++ const panel = commentPanel({ page, user, base, action: base, sort, replyId, expandedId, expandedReplies, error, draft });const body = `<article class="thread comment-thread">@@@ -140,7 +110,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =robots: 'noindex,nofollow',active: 'comments',footerVariant: 'compact',-- body,++ body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`, styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],});}nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++++/** Plain, server-rendered Community thread for the Video and Pics item pages. */++const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');++const { escapeHtml: esc } = require('./highlight');++++const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const USERNAME = /^[A-Za-z0-9_]{1,32}$/;++const ID = /^\d{1,15}$/;++const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };++const author = (c) => c.author || c;++const username = (c) => author(c).username || c.username || '';++const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';++const date = (iso) => {++ const ms = Date.parse(String(iso || ''));++ if (!Number.isFinite(ms)) return { short: '', full: '' };++ const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC';++ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));++ const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)]++ : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)]++ : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)];++ return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full };++};++++function query(base, sort, extra = {}, anchor = 'comments') {++ const q = new URLSearchParams({ sort, ...extra });++ return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`;++}++++function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {++ const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];++ const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');++ const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);++ const validReply = ID.test(String(replyId || '')) ? String(replyId) : null;++ const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null;++ const signIn = `<p class="ovc-signin">Sign in to comment. <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;++ const composer = (parent = null) => signedIn ? thread.composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,++ sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},++ attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null,++ note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',++ }) : signIn;++ const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {++ if (!MEDIA_ID.test(String(a.id || ''))) return '';++ const url = `https://openvibe.media/o/${a.id}`;++ const title = esc(a.name || 'Attached file');++ return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/')++ ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`;++ }).join('');++ const comment = (c, root = null) => {++ const id = String(c.id);++ if (!ID.test(id)) return '';++ const gone = !!(c.deleted || c.is_deleted);++ const user = username(c);++ const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null;++ const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null);++ const when = date(c.created_at);++ const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;++ const topId = root || id;++ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}">↪ @${esc(c.reply_to.display_name || 'Member')}</a>` : '';++ const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';++ let replies = '';++ if (!root) {++ const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []);++ const more = Math.max(0, Number(c.reply_count || 0) - rows.length);++ replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;++ }++ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer(id)}</div>` : ''}${replies}</article>`;++ };++ const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));++ const order = `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`;++ const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : '';++ return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : kit.empty({ title: 'Be the first to comment' })}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`;++}++++module.exports = { renderThread, MEDIA_ID, ID };@@@ -8,7 +8,7 @@const config = require('../config');const ovServe = require('openvibe-shared/serve');const ld = require('./jsonld');--const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');++const { renderPage, asset, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const kit = require('openvibe-shared/kit');@@@ -444,7 +444,7 @@ function withoutTitle(content, title) {return m && bare(m[1]) && bare(m[1]) === bare(title) ? text.slice(m[0].length) : text;}--function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null, share = null }) {++function pastePage({ paste: p, related, user, comments = null, commentSort = 'top', commentsAfter = null, replyId = null, expandedId = null, expandedReplies = null, share = null }) {const isShot = p.type === 'screenshot';// A locked paste (Locker design 2026-11-06, the pilot): Community holds an envelope and never the plaintext. The// title is here only if its owner chose to show it; the page opens the envelope in the browser.@@@ -541,10 +541,9 @@ ${offer}<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p></footer></article>--${p.burn_after_read ? '' : `<section class="section" id="comments">++${p.burn_after_read ? '' : `<section class="section">${locked ? '<p class="muted small">Comments are not locked: anyone who can open this paste can read them, and so can OpenVibe.</p>' : ''}-- <div class="section-head"><h2>Comments${comments && comments.thread.comment_count ? ` <span class="muted small">${num(comments.thread.comment_count)}</span>` : ''}</h2></div>-- ${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, after: commentsAfter, sort: commentSort })}++ ${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, sort: commentSort, replyId, expandedId, expandedReplies })}</section>`}${!locked && related && related.length ? `<section class="section" id="related"><div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>@@@ -621,7 +620,8 @@ window.addEventListener('DOMContentLoaded', function () {ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),],-- body,++ styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],++ body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`,});}@@@ -20,6 +20,6 @@ assert.equal(output.storage.key, input.storage.key);const html = commentPanel({ page: { thread: { ref: { service: 'community', type: 'paste', id: 'paste-1' }, comment_count: 1 },comments: [{ id: 1, message: 'Picture', created_at: null, author: { display_name: 'Owner' }, attachments: [output] }],viewer: { can_comment: false } }, user: null, base: '/p/paste-1' });--assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy">`));++assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy" width="120">`));assert.ok(html.includes('rel="noopener noreferrer"'));assert.equal(typeof createCommentsApi, 'function');@@@ -450,6 +450,16 @@ const { checkCapability } = require('../server/identity/capabilities');const id = opened.json().thread.id;assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'drive-by' } })).status, 401);assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'hi' }, cookie: alexJwt })).status, 201);++ const file = new FormData();++ file.set('ref', JSON.stringify(ref));++ file.set('file', new Blob([Buffer.from('video comment image')], { type: 'image/png' }), 'video.png');++ const token = svc([WRITE], { sub: 'svc:media-hub' });++ const staged = await t.get('/api/v1/comments/attachments', { method: 'POST',++ headers: { authorization: `Bearer ${token}`, 'x-ov-subject': alex.subject_id }, body: file });++ assert.strictEqual(staged.status, 201, staged.text);++ const attached = await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', token,++ headers: { 'x-ov-subject': alex.subject_id }, json: { message: 'with attachment', attachments: [staged.json().attachment] } });++ assert.strictEqual(attached.status, 201, attached.text);});await check('Live stages a file for its VOD; the author claims it once and removal retires it', async () => {@@@ -518,7 +528,7 @@ const { checkCapability } = require('../server/identity/capabilities');});await check('the thread\'s own page /c/:accessId shows the same comments as the API; people comment there; hidden and guessed ids are 404', async () => {-- const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new&limit=100`, asLive())).json();++ const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=top&limit=100`, asLive())).json();const page = await callL(`/c/${liveVod.access_id}`);assert.strictEqual(page.status, 200, page.text.slice(0, 300));assert.match(page.headers.get('content-type'), /text\/html/);@@@ -529,13 +539,13 @@ const { checkCapability } = require('../server/identity/capabilities');assert.ok(live.length >= 3);for (const m of live) assert.ok(page.text.includes(m), `page shows "${m}"`);const order = live.map((m) => page.text.indexOf(m));-- assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'newest first, like the API with sort=new');-- assert.match(page.text, /Sign in with your OpenVibe account/, 'anonymous visitors are asked to sign in');++ assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'Top order matches the API');++ assert.match(page.text, /Sign in to comment/, 'anonymous visitors are asked to sign in');const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });assert.strictEqual(empty.status, 400);-- assert.match(empty.text, /class="alert alert-error"[^>]*>Comment cannot be empty/, 'the page again, with the error');++ assert.match(empty.text, /A comment is 1 to 2,000 characters/, 'the page explains the error');const made = await form('message=said+on+Community', { cookie: samJwt });assert.strictEqual(made.status, 303, made.text.slice(0, 200));assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));@@@ -77,18 +77,35 @@ const MIGRATION = path.join(__dirname, '..', 'migrations', '0004_paste_comments_const pageHtml = await call(`/p/${pubUrl}`, { cookie: samJwt });assert.strictEqual(pageHtml.status, 200);for (const want of ['from the page form', 'from the paste API', 'from Live', `id="comment-${m[2]}"`, `id="comment-${viaApi.json().comment.id}"`,-- `action="/p/${pubUrl}/comments"`, `href="/p/${pubUrl}?sort=old"`]) assert.ok(pageHtml.text.includes(want), want);-- assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'newest first');-- const oldest = await call(`/p/${pubUrl}?sort=old`);-- assert.ok(oldest.text.indexOf('from the page form') < oldest.text.indexOf('from the paste API'), '?sort=old: oldest first');-- assert.ok(oldest.text.includes(`/auth/login?next=${encodeURIComponent(`/p/${pubUrl}`)}`) && !oldest.text.includes(`action="/p/${pubUrl}/comments"`), 'signed out: a sign-in link, no form');++ `action="/p/${pubUrl}/comments"`, `href="/p/${pubUrl}?sort=new#comments"`, 'data-ovt-composer', 'ovc-order']) assert.ok(pageHtml.text.includes(want), want);++ assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'Top breaks equal scores by newest first');++ const newest = await call(`/p/${pubUrl}?sort=new`);++ assert.ok(newest.text.indexOf('from the paste API') < newest.text.indexOf('from the page form'), '?sort=new: newest first');++ assert.ok(newest.text.includes('Sign in to comment') && !newest.text.includes(`action="/p/${pubUrl}/comments"`), 'signed out: a sign-in link, no form');const older = await call(`/p/${pubUrl}?sort=new&after=${viaApi.json().comment.id}`);assert.ok(older.text.includes('from the page form') && !older.text.includes('from the paste API'), 'paged with ?after=');++ const reply = new FormData();++ reply.set('message', 'from the shared composer');++ reply.set('parent_id', m[2]);++ const sent = await t.get(`/p/${pubUrl}/comments`, { method: 'POST', body: reply, cookies: [`ov_token=${samJwt}`] });++ assert.strictEqual(sent.status, 303, sent.text);++ const rendered = await call(`/p/${pubUrl}`);++ assert.match(rendered.text, /from the shared composer/);++ assert.match(rendered.text, /class="ovc-target"/);++ assert.match(rendered.text, /class="ovc-replies"/);++// Deleting a comment twice succeeds both times, as before.const del = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'short-lived' } });for (let i = 0; i < 2; i++) assert.strictEqual((await call(`/api/pastes/${pub}/comments/${del.json().comment.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);assert.ok(!(await call(`/p/${pubUrl}`)).text.includes('short-lived'));++++ const other = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'owner may moderate' } });++ const otherId = other.json().comment.id;++ assert.ok((await call(`/p/${pubUrl}`, { cookie: alexJwt })).text.includes(`name="comment_id" value="${otherId}"`));++ const removedByOwner = await call(`/p/${pubUrl}/comments/delete`, { method: 'POST', cookie: alexJwt, form: { comment_id: otherId } });++ assert.strictEqual(removedByOwner.status, 303, removedByOwner.text);++ assert.ok(!(await call(`/p/${pubUrl}`)).text.includes('owner may moderate'));});await check('the form: signed in only, never from another site', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+.ovc-thread { margin: 24px 0; }
+.ovc-head, .ovc-order, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.ovc-head { justify-content: space-between; }
+.ovc-comment { padding: 12px 0; border-bottom: 1px solid var(--border, #5555); }
+.ovc-comment header img { border-radius: 50%; object-fit: cover; }
+.ovc-comment time, .ovc-community { font-size: .875em; opacity: .8; }
+.ovc-replies { margin-left: 24px; }
+.ovc-actions form { display: inline; }
+.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }
+.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }
+.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }
+.ovc-order { display: inline-flex; gap: 0; border: 1px solid currentColor; border-radius: 8px; overflow: hidden; }
+.ovc-order a { border: 0; border-radius: 0; }
+.ovc-order a + a { border-left: 1px solid currentColor; }
+.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 12%, transparent); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+(function () {
+ 'use strict';
+ var thread = document.querySelector('[data-ovc-thread]');
+ if (!thread) return;
+ var allowed = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
+ function read(html) { return new DOMParser().parseFromString(html, 'text/html').querySelector('[data-ovc-thread]'); }
+ function replace(next, url) {
+ if (!next) return false;
+ thread.replaceWith(next); thread = next;
+ if (url) history.pushState(null, '', url);
+ return true;
+ }
+ document.addEventListener('click', async function (event) {
+ var reply = event.target.closest('[data-ovc-reply]');
+ if (reply && thread.contains(reply)) {
+ var form = thread.querySelector('[data-ovt-composer]');
+ var item = reply.closest('.ovc-comment');
+ if (!form || !item) return;
+ event.preventDefault();
+ var parent = form.querySelector('[name="parent_id"]');
+ if (!parent) { parent = document.createElement('input'); parent.type = 'hidden'; parent.name = 'parent_id'; form.appendChild(parent); }
+ parent.value = reply.dataset.ovcReply;
+ var slot = item.querySelector(':scope > [data-ovc-composer-slot]');
+ if (!slot) { slot = document.createElement('div'); slot.dataset.ovcComposerSlot = ''; item.insertBefore(slot, item.querySelector(':scope > .ovc-replies')); }
+ slot.appendChild(form);
+ form.querySelector('textarea').focus();
+ history.replaceState(null, '', reply.href);
+ return;
+ }
+ var more = event.target.closest('[data-ovc-more]');
+ if (more && thread.contains(more)) {
+ event.preventDefault();
+ more.setAttribute('aria-busy', 'true');
+ try {
+ var response = await fetch(more.href, { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('Replies could not be loaded.');
+ var next = read(await response.text());
+ var currentReplies = more.closest('[data-ovc-replies]');
+ var loadedReplies = next && next.querySelector('[data-ovc-replies="' + more.dataset.ovcMore + '"]');
+ if (!loadedReplies) throw new Error('Replies could not be loaded.');
+ currentReplies.replaceWith(loadedReplies);
+ history.pushState(null, '', more.href);
+ } catch (error) { more.textContent = error.message; more.removeAttribute('aria-busy'); }
+ return;
+ }
+ var order = event.target.closest('[data-ovc-order]');
+ if (order && thread.contains(order)) {
+ event.preventDefault();
+ order.setAttribute('aria-busy', 'true');
+ try {
+ var page = await fetch(order.href, { credentials: 'same-origin' });
+ if (!page.ok || !replace(read(await page.text()), order.href)) throw new Error('Comments could not be refreshed.');
+ } catch (error) { order.textContent = error.message; order.removeAttribute('aria-busy'); }
+ }
+ });
+ document.addEventListener('submit', async function (event) {
+ var form = event.target;
+ if (!thread.contains(form) || (!form.matches('[data-ovt-composer]') && !form.closest('.ovc-actions'))) return;
+ var files = form.querySelector('[data-ovt-files]');
+ var note = form.querySelector('[data-ovt-note]');
+ if (files && files.files.length) {
+ var problem = files.files.length > 4 ? 'Attach up to four files.' : '';
+ for (var file of files.files) {
+ if (file.size > 16 * 1024 * 1024) problem = 'A file can be up to 16 MB.';
+ if (!allowed.has(file.type)) problem = 'Attach JPEG, PNG, GIF, WebP, PDF or text files.';
+ }
+ if (problem) { event.preventDefault(); if (note) note.textContent = problem; return; }
+ }
+ event.preventDefault();
+ var button = form.querySelector('[type="submit"]');
+ if (button) button.disabled = true;
+ if (note) note.textContent = 'Sending…';
+ try {
+ var body = form.matches('[data-ovt-composer]') ? new FormData(form) : new URLSearchParams(new FormData(form));
+ var response = await fetch(form.action, { method: 'POST', body: body, credentials: 'same-origin' });
+ var html = await response.text();
+ var next = read(html);
+ if (!response.ok || !replace(next, response.url)) throw new Error('The comment could not be saved. Try again.');
+ } catch (error) { if (note) note.textContent = error.message; if (button) button.disabled = false; }
+ });
+})();
@@ -46,6 +46,7 @@ const v1 = require('./http/v1');
@@ -46,6 +46,7 @@ const v1 = require('./http/v1');
const { createCommentService } = require('./comments/service');
const { createCommentService } = require('./comments/service');
const { createSharing, describeRef } = require('./sharing');
const { createSharing, describeRef } = require('./sharing');
const { createCommentsApi } = require('./comments/api');
const { createCommentsApi } = require('./comments/api');
+const commentForm = require('./comments/form');
const { createCommentPages } = require('./comments/routes');
const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
const { createForumService } = require('./forum/service');
const { createVipGate } = require('./vip');
const { createVipGate } = require('./vip');
@@ -630,12 +631,23 @@ async function createApp(opts = {}) {
@@ -630,12 +631,23 @@ async function createApp(opts = {}) {
// comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while
// comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while
// nobody has commented. The comment service applies the paste's visibility again.
// nobody has commented. The comment service applies the paste's visibility again.
// A burn-after-read paste is gone once shown, so it has no comment panel.
// A burn-after-read paste is gone once shown, so it has no comment panel.
- const after = /^\d{1,15}$/.test(String(query.after || '')) ? String(query.after) : null;
- const sort = query.sort === 'old' ? 'old' : 'new';
+ const after = /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(query.after || '')) ? String(query.after) : null;
+ const sort = query.sort === 'new' ? 'new' : 'top';
const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
+ const expandedId = /^\d{1,15}$/.test(String(query.replies || '')) ? String(query.replies) : null;
+ let expandedReplies = null;
+ if (thread && expandedId && thread.comments.some((c) => String(c.id) === expandedId)) {
+ expandedReplies = [];
+ let cursor = null;
+ do {
+ const page = await comments.get(viewer, thread.thread.access_id, { parent: expandedId, after: cursor, limit: 100 });
+ expandedReplies.push(...page.comments);
+ cursor = page.next_cursor;
+ } while (cursor);
+ }
return {
return {
status: 200, slug: paste.slug, canonical: paste.canonical_slug || paste.slug,
status: 200, slug: paste.slug, canonical: paste.canonical_slug || paste.slug,
- body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after, share }),
+ body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after, replyId: query.reply, expandedId, expandedReplies, share }),
};
};
}
}
@@ -656,7 +668,7 @@ async function createApp(opts = {}) {
@@ -656,7 +668,7 @@ async function createApp(opts = {}) {
// No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.
// No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.
// The thread opens on the first comment; the comment service decides the rest (visibility, lock, blocks, limits).
// The thread opens on the first comment; the comment service decides the rest (visibility, lock, blocks, limits).
- app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), wrap(async (req, res, next) => {
+ app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), commentForm.parse, wrap(async (req, res, next) => {
const { slug } = req.params;
const { slug } = req.params;
const origin = req.get('origin');
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);
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);
@@ -670,8 +682,8 @@ async function createApp(opts = {}) {
@@ -670,8 +682,8 @@ async function createApp(opts = {}) {
back = `/p/${encodeURIComponent(found.canonical_slug || stable)}`;
back = `/p/${encodeURIComponent(found.canonical_slug || stable)}`;
if (!req.viewer || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);
if (!req.viewer || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);
const { thread } = await comments.resolve(req.viewer, { ref: pasteRef(stable) });
const { thread } = await comments.resolve(req.viewer, { ref: pasteRef(stable) });
- const b = req.body || {};
- const out = await comments.add(req.viewer, thread.id, { message: String(b.message || '').slice(0, 5000), parent_id: b.parent_id || null });
+ const body = await commentForm.bodyFor(comments, req.viewer, pasteRef(stable), req);
+ const out = await comments.add(req.viewer, thread.id, body);
return res.redirect(303, `${back}#comment-${out.comment.id}`);
return res.redirect(303, `${back}#comment-${out.comment.id}`);
} catch (err) {
} catch (err) {
if (!(err instanceof v1.ApiError) && !(err && err.name === 'PasteError')) return next(err);
if (!(err instanceof v1.ApiError) && !(err && err.name === 'PasteError')) return next(err);
@@ -681,6 +693,17 @@ async function createApp(opts = {}) {
@@ -681,6 +693,17 @@ async function createApp(opts = {}) {
}
}
}));
}));
+ app.post('/p/:slug/comments/delete', withUser, express.urlencoded({ extended: false, limit: '4kb' }), wrap(async (req, res, next) => {
+ if (req.get('origin') && req.get('origin') !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
+ try {
+ const found = await source.getPaste(req.params.slug, { ...ctxOf(req), noView: true });
+ const id = String((req.body || {}).comment_id || '');
+ if (!/^\d{1,15}$/.test(id)) throw new v1.ApiError(400, 'comment.invalid', 'Invalid comment');
+ await comments.remove(req.viewer, id, pasteRef(found.slug));
+ return res.redirect(303, `/p/${encodeURIComponent(found.canonical_slug || found.slug)}#comments`);
+ } catch (err) { return next(err); }
+ }));
+
// Raw text is served here, the screenshot link is the stored Media URL.
// Raw text is served here, the screenshot link is the stored Media URL.
const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
app.get('/p/:slug/raw', withUser, async (req, res) => {
app.get('/p/:slug/raw', withUser, async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+
+const multer = require('multer');
+const { ApiError } = require('../http/v1');
+
+const TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
+const upload = multer({ storage: multer.memoryStorage(), limits: { files: 4, fileSize: 16 * 1024 * 1024, fields: 5 } }).array('files', 4);
+
+function parse(req, res, next) {
+ if (!req.is('multipart/form-data')) return next();
+ return upload(req, res, (err) => next(err ? new ApiError(err.code === 'LIMIT_FILE_SIZE' ? 413 : 400,
+ 'comment.attachment_invalid', err.code === 'LIMIT_FILE_SIZE' ? 'A file can be up to 16 MB' : 'Attach up to four files') : null));
+}
+
+async function bodyFor(comments, viewer, ref, req) {
+ const body = req.body || {};
+ const message = String(body.message || '').trim();
+ if (!message || message.length > 2000) throw new ApiError(400, 'comment.message_invalid', 'A comment is 1 to 2,000 characters');
+ const parent = String(body.parent_id || '');
+ if (parent && !/^\d{1,15}$/.test(parent)) throw new ApiError(400, 'comment.parent_invalid', 'That reply target is not valid');
+ const files = req.files || [];
+ const attachments = [];
+ for (const file of files) {
+ if (!TYPES.has(file.mimetype)) throw new ApiError(415, 'comment.file_type', 'Attach JPEG, PNG, GIF, WebP, PDF or text files');
+ const out = await comments.uploadAttachment(viewer, ref, file);
+ attachments.push(out.attachment);
+ }
+ return { message, parent_id: parent || null, attachments };
+}
+
+module.exports = { parse, bodyFor };
@@ -15,6 +15,7 @@ const express = require('express');
@@ -15,6 +15,7 @@ const express = require('express');
const pages = require('../render/pages');
const pages = require('../render/pages');
const { threadPage } = require('../render/comments');
const { threadPage } = require('../render/comments');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
+const commentForm = require('./form');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { setRobotsHeader } = require('../render/layout');
const { setRobotsHeader } = require('../render/layout');
@@ -40,9 +41,21 @@ function createCommentPages({ comments, viewers, config }) {
@@ -40,9 +41,21 @@ function createCommentPages({ comments, viewers, config }) {
const notFound = () => new ApiError(404, 'thread.not_found', 'Comment thread not found');
const notFound = () => new ApiError(404, 'thread.not_found', 'Comment thread not found');
async function render(req, res, { status = 200, error = null, draft = '' } = {}) {
async function render(req, res, { status = 200, error = null, draft = '' } = {}) {
- const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;
- const page = await comments.get(req.viewer, req.params.accessId, { sort: 'new', after, limit: 30 });
- html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, error, draft }), status);
+ const after = /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;
+ const sort = req.query.sort === 'new' ? 'new' : 'top';
+ const page = await comments.get(req.viewer, req.params.accessId, { sort, after, limit: 30 });
+ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
+ let expandedReplies = null;
+ if (expandedId && page.comments.some((c) => String(c.id) === expandedId)) {
+ expandedReplies = [];
+ let cursor = null;
+ do {
+ const more = await comments.get(req.viewer, req.params.accessId, { parent: expandedId, after: cursor, limit: 100 });
+ expandedReplies.push(...more.comments);
+ cursor = more.next_cursor;
+ } while (cursor);
+ }
+ html(res, threadPage({ accessId: req.params.accessId, page, user: req.user, after, sort, replyId: req.query.reply, expandedId, expandedReplies, error, draft }), status);
}
}
router.get('/c/:accessId', withViewer, wrap(async (req, res, next) => {
router.get('/c/:accessId', withViewer, wrap(async (req, res, next) => {
@@ -52,7 +65,7 @@ function createCommentPages({ comments, viewers, config }) {
@@ -52,7 +65,7 @@ function createCommentPages({ comments, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/c/:accessId', withViewer, form, wrap(async (req, res, next) => {
+ router.post('/c/:accessId', withViewer, form, commentForm.parse, wrap(async (req, res, next) => {
const origin = req.get('origin');
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);
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);
const draft = String((req.body || {}).message || '').slice(0, 5000);
const draft = String((req.body || {}).message || '').slice(0, 5000);
@@ -60,7 +73,9 @@ function createCommentPages({ comments, viewers, config }) {
@@ -60,7 +73,9 @@ function createCommentPages({ comments, viewers, config }) {
if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();
if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();
// Signed-in people only on this page, whatever the thread allows through the API.
// Signed-in people only on this page, whatever the thread allows through the API.
if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to comment');
if (!req.viewer || !req.viewer.subject) throw new ApiError(401, 'auth.required', 'Sign in to comment');
- const out = await comments.add(req.viewer, req.params.accessId, { message: draft });
+ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'top', limit: 1 });
+ const body = await commentForm.bodyFor(comments, req.viewer, page.thread.ref, req);
+ const out = await comments.add(req.viewer, req.params.accessId, body);
res.redirect(303, `/c/${req.params.accessId}#comment-${out.comment.id}`);
res.redirect(303, `/c/${req.params.accessId}#comment-${out.comment.id}`);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
@@ -68,6 +83,19 @@ function createCommentPages({ comments, viewers, config }) {
@@ -68,6 +83,19 @@ function createCommentPages({ comments, viewers, config }) {
}
}
}));
}));
+ router.post('/c/:accessId/delete', withViewer, form, wrap(async (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);
+ try {
+ if (!ACCESS_ID_RE.test(req.params.accessId)) throw notFound();
+ const id = String((req.body || {}).comment_id || '');
+ if (!/^\d{1,15}$/.test(id)) throw notFound();
+ const page = await comments.get(req.viewer, req.params.accessId, { sort: 'top', limit: 1 });
+ await comments.remove(req.viewer, id, page.thread.ref);
+ return res.redirect(303, `/c/${req.params.accessId}#comments`);
+ } catch (err) { return failPage(req, res, err, next); }
+ }));
+
return router;
return router;
}
}
@@ -96,7 +96,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -96,7 +96,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
}
- function shapeComment(c, v, projections, votes, threadId, targets) {
+ function shapeComment(c, v, projections, votes, threadId, targets, ownerSubject) {
const deleted = !!c.deleted_at;
const deleted = !!c.deleted_at;
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
const target = c.reply_to_id ? targets.get(c.reply_to_id) : null;
const target = c.reply_to_id ? targets.get(c.reply_to_id) : null;
@@ -121,10 +121,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -121,10 +121,10 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
updated_at: isoTime(c.updated_at),
updated_at: isoTime(c.updated_at),
edited_at: deleted ? null : isoTime(c.edited_at),
edited_at: deleted ? null : isoTime(c.edited_at),
can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),
can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),
- can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
+ can_delete: !deleted && (moderator(v) || !!(v && v.subject && (c.author_subject === v.subject || ownerSubject === v.subject))),
attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),
attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),
};
};
- if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId, targets));
+ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId, targets, ownerSubject));
return out;
return out;
}
}
@@ -134,7 +134,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -134,7 +134,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
const targets = await store.replyTargets(db, all.map((c) => c.reply_to_id));
const targets = await store.replyTargets(db, all.map((c) => c.reply_to_id));
const projections = await authors.projectionsFor(all.map((c) => c.author_subject).concat([...targets.values()].map((c) => c.author_subject)));
const projections = await authors.projectionsFor(all.map((c) => c.author_subject).concat([...targets.values()].map((c) => c.author_subject)));
const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
- return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v), targets));
+ const ownerSubject = await entityOwner(t);
+ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v), targets, ownerSubject));
}
}
/** A thread by its access id, the only id anyone addresses a thread by (C-14). */
/** A thread by its access id, the only id anyone addresses a thread by (C-14). */
@@ -247,7 +248,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -247,7 +248,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to attach a file');
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to attach a file');
const paste = ref && ref.service === 'community' && ref.type === 'paste';
const paste = ref && ref.service === 'community' && ref.type === 'paste';
const owner = ref && ((ref.service === 'live' && ['vod', 'clip'].includes(ref.type) && v.service === 'svc:live')
const owner = ref && ((ref.service === 'live' && ['vod', 'clip'].includes(ref.type) && v.service === 'svc:live')
- || (ref.service === 'pics' && ref.type === 'picture' && v.service === 'svc:media-hub'));
+ || (ref.service === 'pics' && ref.type === 'picture' && v.service === 'svc:media-hub')
+ || (ref.service === 'media-hub' && ref.type === 'video' && v.service === 'svc:media-hub'));
const picture = ref && ref.service === 'pics' && ref.type === 'picture' && v.kind === 'user';
const picture = ref && ref.service === 'pics' && ref.type === 'picture' && v.kind === 'user';
if (!paste && !picture && !(owner && v.kind === 'service' && checkCapability(v.claims, 'community.comment.write').allowed))
if (!paste && !picture && !(owner && v.kind === 'service' && checkCapability(v.claims, 'community.comment.write').allowed))
fail(403, 'attachment.owner_service', 'Upload this attachment through the service that owns the item');
fail(403, 'attachment.owner_service', 'Upload this attachment through the service that owns the item');
@@ -401,11 +403,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -401,11 +403,14 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
},
},
/** DELETE /comments/:id — the author or a moderator. */
/** DELETE /comments/:id — the author or a moderator. */
- async remove(v, commentId) {
- const { comment: c } = await visibleComment(v, commentId);
+ async remove(v, commentId, expectedRef = null) {
+ const { comment: c, thread: t } = await visibleComment(v, commentId);
+ if (expectedRef && (t.ref_service !== expectedRef.service || t.ref_type !== expectedRef.type || t.ref_id !== String(expectedRef.id)))
+ fail(404, 'comment.not_found', 'Comment not found');
if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');
if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
- if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
+ const isOwner = !!(v.subject && v.subject === await entityOwner(t));
+ if (!isAuthor && !isOwner && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
await store.softDeleteComment(db, c.id, v.subject || v.service || null);
await store.softDeleteComment(db, c.id, v.subject || v.service || null);
for (const a of c.attachments || []) {
for (const a of c.attachments || []) {
if (media && typeof media.deleteObject === 'function') {
if (media && typeof media.deleteObject === 'function') {
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* is a plain form post. Messages are plain text, always escaped.
* is a plain form post. Messages are plain text, always escaped.
*/
*/
const config = require('../config');
const config = require('../config');
-const { renderPage } = require('./layout');
+const { renderPage, asset } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { timeTag, fmtDate, num } = require('./pages');
const { timeTag, fmtDate, num } = require('./pages');
const { who } = require('./forum');
const { who } = require('./forum');
@@ -71,49 +71,19 @@ function repliesHtml(c) {
@@ -71,49 +71,19 @@ function repliesHtml(c) {
* sorting links, where sign-in returns), action: where the form posts. sort: 'new' or 'old' when the
* sorting links, where sign-in returns), action: where the form posts. sort: 'new' or 'old' when the
* page offers both orders (null: newest first, no sort links). note: HTML after "Commenting as …".
* page offers both orders (null: newest first, no sort links). note: HTML after "Commenting as …".
*/
*/
-function commentPanel({ page, user, base, action = base, after = null, sort = null, error = null, draft = '', note = '' }) {
- const thread = page ? page.thread : null;
- const comments = page ? page.comments : [];
- const next = page ? page.next_cursor : null;
- const viewer = page ? page.viewer : { can_comment: !!user, can_moderate: false };
- const href = (o = {}) => {
- const q = new URLSearchParams();
- if (o.sort === 'old') q.set('sort', 'old');
- if (o.after) q.set('after', o.after);
- const qs = q.toString();
- return qs ? `${base}?${qs}` : base;
- };
-
- let form = '';
- if (thread && thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';
- else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;
- else if (viewer.can_comment) {
- form = `<form class="paste-form reply-form" method="post" action="${esc(action)}" id="comment">
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>.${note ? ` ${note}` : ''}</span></div>
- </form>`;
- }
-
- const tabs = sort && thread && thread.comment_count > 1
- ? `<nav class="tabs sort-tabs" aria-label="Sort comments">${[['new', 'Newest'], ['old', 'Oldest']].map(([id, label]) => `<a class="tab${id === sort ? ' active' : ''}" href="${esc(href({ sort: id }))}"${id === sort ? ' aria-current="page"' : ''}>${label}</a>`).join('')}</nav>`
- : '';
- const list = comments.length
- ? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`
- : `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;
- const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(href({ sort, after: next }))}">${sort === 'old' ? 'More' : 'Older'} comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';
-
- return `${after ? '' : form}
- ${after ? `<p><a href="${esc(href({ sort }))}">${sort === 'old' ? 'First' : 'Newest'} comments</a></p>` : ''}
- ${tabs}
- ${list}
- ${older}`;
+function commentPanel({ page, user, base, action = base, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {
+ return require('./inline-thread').renderThread({
+ answer: page || { thread: null, comments: [], viewer: { can_comment: true } },
+ base, post: action, remove: `${action}/delete`, signedIn: !!user && (!page || page.viewer.can_comment !== false),
+ attachmentsAllowed: !page || ['community/paste', 'pics/picture'].includes(`${page.thread.ref.service}/${page.thread.ref.type}`),
+ sort, replyId, expandedId, expandedReplies, error, draft,
+ });
}
}
/**
/**
* page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).
* page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).
*/
*/
-function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {
+function threadPage({ accessId, page, user, after = null, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {
const { thread } = page;
const { thread } = page;
const ref = thread.ref;
const ref = thread.ref;
const what = refName(ref);
const what = refName(ref);
@@ -121,7 +91,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
@@ -121,7 +91,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
const label = ref.label || `a ${what} on ${product}`;
const label = ref.label || `a ${what} on ${product}`;
const src = sourceUrl(ref);
const src = sourceUrl(ref);
const base = `/c/${accessId}`;
const base = `/c/${accessId}`;
- const panel = commentPanel({ page, user, base, after, error, draft, note: `It shows on ${esc(product)} too.` });
+ const panel = commentPanel({ page, user, base, action: base, sort, replyId, expandedId, expandedReplies, error, draft });
const body = `
const body = `
<article class="thread comment-thread">
<article class="thread comment-thread">
@@ -140,7 +110,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
@@ -140,7 +110,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
robots: 'noindex,nofollow',
robots: 'noindex,nofollow',
active: 'comments',
active: 'comments',
footerVariant: 'compact',
footerVariant: 'compact',
- body,
+ body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`, styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+
+/** Plain, server-rendered Community thread for the Video and Pics item pages. */
+const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
+const { escapeHtml: esc } = require('./highlight');
+
+const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
+const ID = /^\d{1,15}$/;
+const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };
+const author = (c) => c.author || c;
+const username = (c) => author(c).username || c.username || '';
+const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
+const date = (iso) => {
+ const ms = Date.parse(String(iso || ''));
+ if (!Number.isFinite(ms)) return { short: '', full: '' };
+ const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC';
+ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
+ const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)]
+ : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)]
+ : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)];
+ return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full };
+};
+
+function query(base, sort, extra = {}, anchor = 'comments') {
+ const q = new URLSearchParams({ sort, ...extra });
+ return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`;
+}
+
+function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {
+ const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];
+ const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
+ const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);
+ const validReply = ID.test(String(replyId || '')) ? String(replyId) : null;
+ const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null;
+ const signIn = `<p class="ovc-signin">Sign in to comment. <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
+ const composer = (parent = null) => signedIn ? thread.composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,
+ sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
+ attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null,
+ note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',
+ }) : signIn;
+ const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
+ if (!MEDIA_ID.test(String(a.id || ''))) return '';
+ const url = `https://openvibe.media/o/${a.id}`;
+ const title = esc(a.name || 'Attached file');
+ return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/')
+ ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`;
+ }).join('');
+ const comment = (c, root = null) => {
+ const id = String(c.id);
+ if (!ID.test(id)) return '';
+ const gone = !!(c.deleted || c.is_deleted);
+ const user = username(c);
+ const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null;
+ const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null);
+ const when = date(c.created_at);
+ const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;
+ const topId = root || id;
+ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}">↪ @${esc(c.reply_to.display_name || 'Member')}</a>` : '';
+ const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';
+ let replies = '';
+ if (!root) {
+ const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []);
+ const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
+ replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
+ }
+ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer(id)}</div>` : ''}${replies}</article>`;
+ };
+ const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
+ const order = `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`;
+ const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : '';
+ return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : kit.empty({ title: 'Be the first to comment' })}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`;
+}
+
+module.exports = { renderThread, MEDIA_ID, ID };
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const config = require('../config');
const config = require('../config');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const ld = require('./jsonld');
const ld = require('./jsonld');
-const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
+const { renderPage, asset, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
@@ -444,7 +444,7 @@ function withoutTitle(content, title) {
@@ -444,7 +444,7 @@ function withoutTitle(content, title) {
return m && bare(m[1]) && bare(m[1]) === bare(title) ? text.slice(m[0].length) : text;
return m && bare(m[1]) && bare(m[1]) === bare(title) ? text.slice(m[0].length) : text;
}
}
-function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null, share = null }) {
+function pastePage({ paste: p, related, user, comments = null, commentSort = 'top', commentsAfter = null, replyId = null, expandedId = null, expandedReplies = null, share = null }) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
// A locked paste (Locker design 2026-11-06, the pilot): Community holds an envelope and never the plaintext. The
// A locked paste (Locker design 2026-11-06, the pilot): Community holds an envelope and never the plaintext. The
// title is here only if its owner chose to show it; the page opens the envelope in the browser.
// title is here only if its owner chose to show it; the page opens the envelope in the browser.
@@ -541,10 +541,9 @@ ${offer}
@@ -541,10 +541,9 @@ ${offer}
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
</footer>
</footer>
</article>
</article>
-${p.burn_after_read ? '' : `<section class="section" id="comments">
+${p.burn_after_read ? '' : `<section class="section">
${locked ? '<p class="muted small">Comments are not locked: anyone who can open this paste can read them, and so can OpenVibe.</p>' : ''}
${locked ? '<p class="muted small">Comments are not locked: anyone who can open this paste can read them, and so can OpenVibe.</p>' : ''}
- <div class="section-head"><h2>Comments${comments && comments.thread.comment_count ? ` <span class="muted small">${num(comments.thread.comment_count)}</span>` : ''}</h2></div>
- ${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, after: commentsAfter, sort: commentSort })}
+ ${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, sort: commentSort, replyId, expandedId, expandedReplies })}
</section>`}
</section>`}
${!locked && related && related.length ? `<section class="section" id="related">
${!locked && related && related.length ? `<section class="section" id="related">
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
@@ -621,7 +620,8 @@ window.addEventListener('DOMContentLoaded', function () {
@@ -621,7 +620,8 @@ window.addEventListener('DOMContentLoaded', function () {
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),
],
],
- body,
+ styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],
+ body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`,
});
});
}
}
@@ -20,6 +20,6 @@ assert.equal(output.storage.key, input.storage.key);
@@ -20,6 +20,6 @@ assert.equal(output.storage.key, input.storage.key);
const html = commentPanel({ page: { thread: { ref: { service: 'community', type: 'paste', id: 'paste-1' }, comment_count: 1 },
const html = commentPanel({ page: { thread: { ref: { service: 'community', type: 'paste', id: 'paste-1' }, comment_count: 1 },
comments: [{ id: 1, message: 'Picture', created_at: null, author: { display_name: 'Owner' }, attachments: [output] }],
comments: [{ id: 1, message: 'Picture', created_at: null, author: { display_name: 'Owner' }, attachments: [output] }],
viewer: { can_comment: false } }, user: null, base: '/p/paste-1' });
viewer: { can_comment: false } }, user: null, base: '/p/paste-1' });
-assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy">`));
+assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy" width="120">`));
assert.ok(html.includes('rel="noopener noreferrer"'));
assert.ok(html.includes('rel="noopener noreferrer"'));
assert.equal(typeof createCommentsApi, 'function');
assert.equal(typeof createCommentsApi, 'function');
@@ -450,6 +450,16 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -450,6 +450,16 @@ const { checkCapability } = require('../server/identity/capabilities');
const id = opened.json().thread.id;
const id = opened.json().thread.id;
assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'drive-by' } })).status, 401);
assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'drive-by' } })).status, 401);
assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'hi' }, cookie: alexJwt })).status, 201);
assert.strictEqual((await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json: { message: 'hi' }, cookie: alexJwt })).status, 201);
+ const file = new FormData();
+ file.set('ref', JSON.stringify(ref));
+ file.set('file', new Blob([Buffer.from('video comment image')], { type: 'image/png' }), 'video.png');
+ const token = svc([WRITE], { sub: 'svc:media-hub' });
+ const staged = await t.get('/api/v1/comments/attachments', { method: 'POST',
+ headers: { authorization: `Bearer ${token}`, 'x-ov-subject': alex.subject_id }, body: file });
+ assert.strictEqual(staged.status, 201, staged.text);
+ const attached = await callL(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', token,
+ headers: { 'x-ov-subject': alex.subject_id }, json: { message: 'with attachment', attachments: [staged.json().attachment] } });
+ assert.strictEqual(attached.status, 201, attached.text);
});
});
await check('Live stages a file for its VOD; the author claims it once and removal retires it', async () => {
await check('Live stages a file for its VOD; the author claims it once and removal retires it', async () => {
@@ -518,7 +528,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -518,7 +528,7 @@ const { checkCapability } = require('../server/identity/capabilities');
});
});
await check('the thread\'s own page /c/:accessId shows the same comments as the API; people comment there; hidden and guessed ids are 404', async () => {
await check('the thread\'s own page /c/:accessId shows the same comments as the API; people comment there; hidden and guessed ids are 404', async () => {
- const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=new&limit=100`, asLive())).json();
+ const api = (await callL(`/api/v1/comments/threads/${liveVod.id}?sort=top&limit=100`, asLive())).json();
const page = await callL(`/c/${liveVod.access_id}`);
const page = await callL(`/c/${liveVod.access_id}`);
assert.strictEqual(page.status, 200, page.text.slice(0, 300));
assert.strictEqual(page.status, 200, page.text.slice(0, 300));
assert.match(page.headers.get('content-type'), /text\/html/);
assert.match(page.headers.get('content-type'), /text\/html/);
@@ -529,13 +539,13 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -529,13 +539,13 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.ok(live.length >= 3);
assert.ok(live.length >= 3);
for (const m of live) assert.ok(page.text.includes(m), `page shows "${m}"`);
for (const m of live) assert.ok(page.text.includes(m), `page shows "${m}"`);
const order = live.map((m) => page.text.indexOf(m));
const order = live.map((m) => page.text.indexOf(m));
- assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'newest first, like the API with sort=new');
- assert.match(page.text, /Sign in with your OpenVibe account/, 'anonymous visitors are asked to sign in');
+ assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'Top order matches the API');
+ assert.match(page.text, /Sign in to comment/, 'anonymous visitors are asked to sign in');
const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });
const form = (body, who) => t.get(`/c/${liveVod.access_id}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip2(), ...(who.origin ? { origin: who.origin } : {}) }, body, cookies: who.cookie ? [`ov_token=${who.cookie}`] : [] });
const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });
const empty = await form('message=+', { cookie: samJwt, origin: 'https://openvibe.community' });
assert.strictEqual(empty.status, 400);
assert.strictEqual(empty.status, 400);
- assert.match(empty.text, /class="alert alert-error"[^>]*>Comment cannot be empty/, 'the page again, with the error');
+ assert.match(empty.text, /A comment is 1 to 2,000 characters/, 'the page explains the error');
const made = await form('message=said+on+Community', { cookie: samJwt });
const made = await form('message=said+on+Community', { cookie: samJwt });
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));
assert.match(made.headers.get('location'), new RegExp(`^/c/${liveVod.access_id}#comment-\\d+$`));
@@ -77,18 +77,35 @@ const MIGRATION = path.join(__dirname, '..', 'migrations', '0004_paste_comments_
@@ -77,18 +77,35 @@ const MIGRATION = path.join(__dirname, '..', 'migrations', '0004_paste_comments_
const pageHtml = await call(`/p/${pubUrl}`, { cookie: samJwt });
const pageHtml = await call(`/p/${pubUrl}`, { cookie: samJwt });
assert.strictEqual(pageHtml.status, 200);
assert.strictEqual(pageHtml.status, 200);
for (const want of ['from the page form', 'from the paste API', 'from Live', `id="comment-${m[2]}"`, `id="comment-${viaApi.json().comment.id}"`,
for (const want of ['from the page form', 'from the paste API', 'from Live', `id="comment-${m[2]}"`, `id="comment-${viaApi.json().comment.id}"`,
- `action="/p/${pubUrl}/comments"`, `href="/p/${pubUrl}?sort=old"`]) assert.ok(pageHtml.text.includes(want), want);
- assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'newest first');
- const oldest = await call(`/p/${pubUrl}?sort=old`);
- assert.ok(oldest.text.indexOf('from the page form') < oldest.text.indexOf('from the paste API'), '?sort=old: oldest first');
- assert.ok(oldest.text.includes(`/auth/login?next=${encodeURIComponent(`/p/${pubUrl}`)}`) && !oldest.text.includes(`action="/p/${pubUrl}/comments"`), 'signed out: a sign-in link, no form');
+ `action="/p/${pubUrl}/comments"`, `href="/p/${pubUrl}?sort=new#comments"`, 'data-ovt-composer', 'ovc-order']) assert.ok(pageHtml.text.includes(want), want);
+ assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'Top breaks equal scores by newest first');
+ const newest = await call(`/p/${pubUrl}?sort=new`);
+ assert.ok(newest.text.indexOf('from the paste API') < newest.text.indexOf('from the page form'), '?sort=new: newest first');
+ assert.ok(newest.text.includes('Sign in to comment') && !newest.text.includes(`action="/p/${pubUrl}/comments"`), 'signed out: a sign-in link, no form');
const older = await call(`/p/${pubUrl}?sort=new&after=${viaApi.json().comment.id}`);
const older = await call(`/p/${pubUrl}?sort=new&after=${viaApi.json().comment.id}`);
assert.ok(older.text.includes('from the page form') && !older.text.includes('from the paste API'), 'paged with ?after=');
assert.ok(older.text.includes('from the page form') && !older.text.includes('from the paste API'), 'paged with ?after=');
+ const reply = new FormData();
+ reply.set('message', 'from the shared composer');
+ reply.set('parent_id', m[2]);
+ const sent = await t.get(`/p/${pubUrl}/comments`, { method: 'POST', body: reply, cookies: [`ov_token=${samJwt}`] });
+ assert.strictEqual(sent.status, 303, sent.text);
+ const rendered = await call(`/p/${pubUrl}`);
+ assert.match(rendered.text, /from the shared composer/);
+ assert.match(rendered.text, /class="ovc-target"/);
+ assert.match(rendered.text, /class="ovc-replies"/);
+
// Deleting a comment twice succeeds both times, as before.
// Deleting a comment twice succeeds both times, as before.
const del = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'short-lived' } });
const del = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'short-lived' } });
for (let i = 0; i < 2; i++) assert.strictEqual((await call(`/api/pastes/${pub}/comments/${del.json().comment.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);
for (let i = 0; i < 2; i++) assert.strictEqual((await call(`/api/pastes/${pub}/comments/${del.json().comment.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);
assert.ok(!(await call(`/p/${pubUrl}`)).text.includes('short-lived'));
assert.ok(!(await call(`/p/${pubUrl}`)).text.includes('short-lived'));
+
+ const other = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'owner may moderate' } });
+ const otherId = other.json().comment.id;
+ assert.ok((await call(`/p/${pubUrl}`, { cookie: alexJwt })).text.includes(`name="comment_id" value="${otherId}"`));
+ const removedByOwner = await call(`/p/${pubUrl}/comments/delete`, { method: 'POST', cookie: alexJwt, form: { comment_id: otherId } });
+ assert.strictEqual(removedByOwner.status, 303, removedByOwner.text);
+ assert.ok(!(await call(`/p/${pubUrl}`)).text.includes('owner may moderate'));
});
});
await check('the form: signed in only, never from another site', async () => {
await check('the form: signed in only, never from another site', async () => {