openvibers/openvibe.community Commits
Community: names wear what their people wear — author names carry their subject, and openvibe-shared items.js draws their name effect (loaded only on pages that show someone) (#29)
Changes
@@@ -172,6 +172,28 @@window.addEventListener('pagehide', function () { es.close(); }, { signal });}}++ // ── What people wear ───────────────────────────────────────++ // Names marked data-ov-subject wear their owner's name effect (openvibe-shared items.js, OpenVibe.Inventory; plan++ // T21). The script and its stylesheet load only on a page that shows someone, then every page move decorates again.++ function wearItems() {++ var meta = document.querySelector('meta[name="ov-items"]');++ if (!meta || !document.querySelector('[data-ov-subject]')) return;++ if (window.OpenVibeItems) { window.OpenVibeItems.decorate(document); return; }++ if (document.getElementById('ov-items-js')) return;++ var urls = (meta.getAttribute('content') || '').split(' ');++ if (urls.length !== 2) return;++ var css = document.createElement('link');++ css.rel = 'stylesheet';++ css.href = urls[1];++ document.head.appendChild(css);++ var s = document.createElement('script');++ s.id = 'ov-items-js';++ s.src = urls[0];++ s.onload = function () { if (window.OpenVibeItems) window.OpenVibeItems.decorate(document); };++ document.head.appendChild(s);++ }++wirePage();-- document.addEventListener('ov:boost:load', wirePage);++ wearItems();++ document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); });})();@@@ -98,7 +98,7 @@ async function createApp(opts = {}) {fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],// Screenshots serve from openvibe.media (which may 302 to object storage); avatars from Live/Network.imgSrc: ["'self'", 'data:', 'blob:', 'https:'],-- connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://cloudflareinsights.com'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://inventory.openvibe.network', 'https://cloudflareinsights.com'], // inventory: what people wear (items.js)// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'self'"],@@@ -12,7 +12,7 @@ const ld = require('./jsonld');const { renderPage } = require('./layout');const { escapeHtml: esc } = require('./highlight');const { markdownToText } = require('./markdown');--const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');++const { timeTag, fmtDate, num, avatarUrl, authorHtml, wearName } = require('./pages');const f = require('./forum');const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };@@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;function boardRow(sp) {const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';const last = sp.last_post-- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`++ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`: '<span class="muted small">No posts yet</span>';const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';return `<tr>@@@ -82,7 +82,7 @@ function topicRow(t, space) {const base = `/s/${space.slug}/t/${t.slug}`;const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';-- const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');++ const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');return `<tr class="${t.pinned ? 'sticky' : ''}"><td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td><td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}@@@ -180,7 +180,7 @@ function authorPanel(p) {const st = p.author_stats;return `<aside class="postbit-author">${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}-- <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>++ <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${wearName(a, name)}</a>` : wearName(a, name)}</span>${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}@@@ -138,6 +138,8 @@ function renderPage(o) {`<link rel="stylesheet" href="${asset('css/community.css')}">`,'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',`<script src="${asset('js/community.js')}" defer></script>`,++ // openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.++ `<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,].filter(Boolean).join('\n'),@@@ -53,6 +53,15 @@ function avatarUrl(p, size = 96) {if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;}++// A person's Network subject: their name then wears what they wear (openvibe-shared items.js, loaded by community.js).++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++function wearerOf(p) {++ if (!p || p.is_ai || p.is_relay || p.is_system || p.origin === 'ai') return null;++ const s = p.subject || p.owner_subject || p.author_subject || null;++ return SUBJECT_RE.test(String(s || '')) ? s : null;++}++/** A name as escaped text, inside a span marked with its person's subject when it has one (for items.js). */++const wearName = (p, text) => { const w = wearerOf(p); return w ? `<span data-ov-subject="${esc(w)}">${esc(text)}</span>` : esc(text); };function authorHtml(p, { link = true } = {}) {const name = authorName(p);const initial = esc(name.trim()[0] || '?').toUpperCase();@@@ -63,7 +72,8 @@ function authorHtml(p, { link = true } = {}) {const avatar = (avatarSrc || netAvatar)? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`: `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;-- const inner = `${avatar}<span>${esc(name)}</span>`;++ const wearer = wearerOf(p);++ const inner = `${avatar}${wearer ? wearName(p, name) : `<span>${esc(name)}</span>`}`;if (!link || !p.username) return `<span class="author">${inner}</span>`;return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;}@@@ -436,4 +446,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });}--module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl };++module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl, wearName };@@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(row.views, 2, 'one view per viewer per half hour');assert.strictEqual(row.last_post.author.username, 'sam');const page = (await call('/s/general')).text;-- assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));++ assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');assert.ok(!page.includes('class="thread-score"'), 'no score column');});nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * What people wear, on Community (plan T21 "equip everywhere"): a person's name carries their Network subject++ * (data-ov-subject) so openvibe-shared items.js gives it their name effect; AI, relayed and system authors never do.++ * Every page names items.js + items.css in <meta name="ov-items"> (community.js loads them only when a page shows++ * someone), and the CSP lets the page read OpenVibe.Inventory's public equipped sets.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++const { authorHtml } = require('../server/render/pages');++++const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });++++(async () => {++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++++ await check('a person\'s name carries their subject; AI, relayed and system names do not', async () => {++ const S = 'usr_01JZ0000000000000000000AAA';++ assert.match(authorHtml({ subject: S, username: 'ana', display_name: 'Ana' }), new RegExp(`<span data-ov-subject="${S}">Ana</span>`));++ assert.match(authorHtml({ owner_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a paste\'s owner');++ assert.match(authorHtml({ author_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a comment\'s author');++ for (const p of [{ subject: S, display_name: 'OpenVibe AI', is_ai: true }, { owner_subject: S, display_name: 'AI', origin: 'ai' }, { subject: S, display_name: 'x', is_relay: true }, { subject: S, display_name: 'OpenVibe', is_system: true }, { subject: 'not-a-subject', display_name: 'y' }]) {++ assert.ok(!/data-ov-subject/.test(authorHtml(p, { link: false })), JSON.stringify(p));++ }++ });++++ await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {++ const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });++ assert.strictEqual(ok.status, 303, ok.text);++ const r = await t.get(ok.headers.get('location'));++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes(`<span data-ov-subject="${alex.subject_id}">Alex</span>`), `the author name is marked`);++ assert.match(r.text, /<meta name="ov-items" content="\/shared\/items\.js\?v=[A-Za-z0-9_-]+ \/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);++ assert.match(r.headers.get('content-security-policy') || '', /connect-src[^;]*https:\/\/inventory\.openvibe\.network/);++ const js = await t.get('/shared/items.js');++ assert.strictEqual(js.status, 200, 'the site serves openvibe-shared items.js');++ assert.strictEqual((await t.get('/shared/items.css')).status, 200);++ });++++ await check('community.js loads items.js only when a page shows someone, and again after each page move', async () => {++ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'community.js'), 'utf8');++ assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);++ assert.match(src, /OpenVibeItems\.decorate\(document\)/);++ assert.match(src, /ov:boost:load', function \(\) \{ wirePage\(\); wearItems\(\); \}/);++ });++++ await t.close();++ done();++})();
@@ -172,6 +172,28 @@
@@ -172,6 +172,28 @@
window.addEventListener('pagehide', function () { es.close(); }, { signal });
window.addEventListener('pagehide', function () { es.close(); }, { signal });
}
}
}
}
+ // ── What people wear ───────────────────────────────────────
+ // Names marked data-ov-subject wear their owner's name effect (openvibe-shared items.js, OpenVibe.Inventory; plan
+ // T21). The script and its stylesheet load only on a page that shows someone, then every page move decorates again.
+ function wearItems() {
+ var meta = document.querySelector('meta[name="ov-items"]');
+ if (!meta || !document.querySelector('[data-ov-subject]')) return;
+ if (window.OpenVibeItems) { window.OpenVibeItems.decorate(document); return; }
+ if (document.getElementById('ov-items-js')) return;
+ var urls = (meta.getAttribute('content') || '').split(' ');
+ if (urls.length !== 2) return;
+ var css = document.createElement('link');
+ css.rel = 'stylesheet';
+ css.href = urls[1];
+ document.head.appendChild(css);
+ var s = document.createElement('script');
+ s.id = 'ov-items-js';
+ s.src = urls[0];
+ s.onload = function () { if (window.OpenVibeItems) window.OpenVibeItems.decorate(document); };
+ document.head.appendChild(s);
+ }
+
wirePage();
wirePage();
- document.addEventListener('ov:boost:load', wirePage);
+ wearItems();
+ document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); });
})();
})();
@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],
// Screenshots serve from openvibe.media (which may 302 to object storage); avatars from Live/Network.
// Screenshots serve from openvibe.media (which may 302 to object storage); avatars from Live/Network.
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
- connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://cloudflareinsights.com'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://inventory.openvibe.network', 'https://cloudflareinsights.com'], // inventory: what people wear (items.js)
// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.
// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
frameAncestors: ["'self'"],
@@ -12,7 +12,7 @@ const ld = require('./jsonld');
@@ -12,7 +12,7 @@ const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
-const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');
+const { timeTag, fmtDate, num, avatarUrl, authorHtml, wearName } = require('./pages');
const f = require('./forum');
const f = require('./forum');
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
function boardRow(sp) {
function boardRow(sp) {
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const last = sp.last_post
const last = sp.last_post
- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
+ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
: '<span class="muted small">No posts yet</span>';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
return `<tr>
@@ -82,7 +82,7 @@ function topicRow(t, space) {
@@ -82,7 +82,7 @@ function topicRow(t, space) {
const base = `/s/${space.slug}/t/${t.slug}`;
const base = `/s/${space.slug}/t/${t.slug}`;
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
- const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');
+ const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
return `<tr class="${t.pinned ? 'sticky' : ''}">
return `<tr class="${t.pinned ? 'sticky' : ''}">
<td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
<td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
<td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
<td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
@@ -180,7 +180,7 @@ function authorPanel(p) {
@@ -180,7 +180,7 @@ function authorPanel(p) {
const st = p.author_stats;
const st = p.author_stats;
return `<aside class="postbit-author">
return `<aside class="postbit-author">
${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}
${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}
- <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>
+ <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${wearName(a, name)}</a>` : wearName(a, name)}</span>
${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}
${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}
${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>
${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>
${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}
${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}
@@ -138,6 +138,8 @@ function renderPage(o) {
@@ -138,6 +138,8 @@ function renderPage(o) {
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
`<script src="${asset('js/community.js')}" defer></script>`,
`<script src="${asset('js/community.js')}" defer></script>`,
+ // openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
+ `<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,
`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].filter(Boolean).join('\n'),
].filter(Boolean).join('\n'),
@@ -53,6 +53,15 @@ function avatarUrl(p, size = 96) {
@@ -53,6 +53,15 @@ function avatarUrl(p, size = 96) {
if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;
if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;
return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;
return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;
}
}
+// A person's Network subject: their name then wears what they wear (openvibe-shared items.js, loaded by community.js).
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+function wearerOf(p) {
+ if (!p || p.is_ai || p.is_relay || p.is_system || p.origin === 'ai') return null;
+ const s = p.subject || p.owner_subject || p.author_subject || null;
+ return SUBJECT_RE.test(String(s || '')) ? s : null;
+}
+/** A name as escaped text, inside a span marked with its person's subject when it has one (for items.js). */
+const wearName = (p, text) => { const w = wearerOf(p); return w ? `<span data-ov-subject="${esc(w)}">${esc(text)}</span>` : esc(text); };
function authorHtml(p, { link = true } = {}) {
function authorHtml(p, { link = true } = {}) {
const name = authorName(p);
const name = authorName(p);
const initial = esc(name.trim()[0] || '?').toUpperCase();
const initial = esc(name.trim()[0] || '?').toUpperCase();
@@ -63,7 +72,8 @@ function authorHtml(p, { link = true } = {}) {
@@ -63,7 +72,8 @@ function authorHtml(p, { link = true } = {}) {
const avatar = (avatarSrc || netAvatar)
const avatar = (avatarSrc || netAvatar)
? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`
? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`
: `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;
: `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;
- const inner = `${avatar}<span>${esc(name)}</span>`;
+ const wearer = wearerOf(p);
+ const inner = `${avatar}${wearer ? wearName(p, name) : `<span>${esc(name)}</span>`}`;
if (!link || !p.username) return `<span class="author">${inner}</span>`;
if (!link || !p.username) return `<span class="author">${inner}</span>`;
return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
}
}
@@ -436,4 +446,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
@@ -436,4 +446,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
}
-module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl };
+module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl, wearName };
@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.last_post.author.username, 'sam');
assert.strictEqual(row.last_post.author.username, 'sam');
const page = (await call('/s/general')).text;
const page = (await call('/s/general')).text;
- assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));
+ assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * What people wear, on Community (plan T21 "equip everywhere"): a person's name carries their Network subject
+ * (data-ov-subject) so openvibe-shared items.js gives it their name effect; AI, relayed and system authors never do.
+ * Every page names items.js + items.css in <meta name="ov-items"> (community.js loads them only when a page shows
+ * someone), and the CSP lets the page read OpenVibe.Inventory's public equipped sets.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+const { authorHtml } = require('../server/render/pages');
+
+const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+
+(async () => {
+ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+
+ await check('a person\'s name carries their subject; AI, relayed and system names do not', async () => {
+ const S = 'usr_01JZ0000000000000000000AAA';
+ assert.match(authorHtml({ subject: S, username: 'ana', display_name: 'Ana' }), new RegExp(`<span data-ov-subject="${S}">Ana</span>`));
+ assert.match(authorHtml({ owner_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a paste\'s owner');
+ assert.match(authorHtml({ author_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a comment\'s author');
+ for (const p of [{ subject: S, display_name: 'OpenVibe AI', is_ai: true }, { owner_subject: S, display_name: 'AI', origin: 'ai' }, { subject: S, display_name: 'x', is_relay: true }, { subject: S, display_name: 'OpenVibe', is_system: true }, { subject: 'not-a-subject', display_name: 'y' }]) {
+ assert.ok(!/data-ov-subject/.test(authorHtml(p, { link: false })), JSON.stringify(p));
+ }
+ });
+
+ await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {
+ const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
+ assert.strictEqual(ok.status, 303, ok.text);
+ const r = await t.get(ok.headers.get('location'));
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes(`<span data-ov-subject="${alex.subject_id}">Alex</span>`), `the author name is marked`);
+ assert.match(r.text, /<meta name="ov-items" content="\/shared\/items\.js\?v=[A-Za-z0-9_-]+ \/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);
+ assert.match(r.headers.get('content-security-policy') || '', /connect-src[^;]*https:\/\/inventory\.openvibe\.network/);
+ const js = await t.get('/shared/items.js');
+ assert.strictEqual(js.status, 200, 'the site serves openvibe-shared items.js');
+ assert.strictEqual((await t.get('/shared/items.css')).status, 200);
+ });
+
+ await check('community.js loads items.js only when a page shows someone, and again after each page move', async () => {
+ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'community.js'), 'utf8');
+ assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);
+ assert.match(src, /OpenVibeItems\.decorate\(document\)/);
+ assert.match(src, /ov:boost:load', function \(\) \{ wirePage\(\); wearItems\(\); \}/);
+ });
+
+ await t.close();
+ done();
+})();