openvibers/openvibe.community Commits
The public paste API no longer serves an uploader's browser, file name or page; profile pictures leave the home's latest pastes; home headings match the showcase (#127)
Changes
@@@ -51,6 +51,9 @@ img { max-width: 100%; height: auto; }.section { padding: 28px 0 8px; }.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }.section-head h2 { margin: 0; }++/* The home's sections take the showcase's heading size (openvibe-shared/showcase.css .sc-sec), like "What lives here" between++ them and the other OpenVibe homes; a paste page's "Comments" and "More like this" keep the compact size. */++.home-sec > .section-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; }.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }.lang-cloud { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }.lang-cloud a { background: var(--bg-card); border: 1px solid var(--border); border-radius: 999px; padding: 4px 12px; font-size: .85rem; color: var(--text-primary); }@@@ -29,6 +29,15 @@ const FROM = { people: 'people', streams: 'ai', all: undefined };function _textOnlyPublic(rows) {return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));}++// A profile picture is a public paste when its owner leaves "public" on (Live's avatar upload), but it is not++// something they shared: the home's latest and most viewed leave them out (three of the four latest were avatars,++// 2026-10-24). The paste lists and the person's own page still show them.++function _metaKind(p) {++ const m = p && p.metadata;++ if (!m) return null;++ try { return (typeof m === 'string' ? JSON.parse(m) : m).kind || null; } catch { return null; }++}++const _shared = (rows) => rows.filter((p) => _metaKind(p) !== 'avatar');/** The latest public pastes of one type and origin (cached). Never throws: an unavailable store yields the stale list or []. */async function windowOf({ type = null, from = 'all' } = {}) {@@@ -54,8 +63,8 @@ function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }const fromOf = (v) => (Object.prototype.hasOwnProperty.call(FROM, v) ? v : 'all');--async function latest(n = 12, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byNewest).slice(0, n); }--async function trending(n = 8, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byViews).slice(0, n); }++async function latest(n = 12, { from = 'all' } = {}) { return _shared(await windowOf({ from: fromOf(from) })).sort(byNewest).slice(0, n); }++async function trending(n = 8, { from = 'all' } = {}) { return _shared(await windowOf({ from: fromOf(from) })).sort(byViews).slice(0, n); }/** Pastes in the same language first, then the newest, never the paste itself. */async function related(paste, n = 6) {@@@ -59,6 +59,20 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }++/**++ * What a paste's metadata shows to everyone but its owner and services: never the uploader's browser (user_agent), the++ * file's original name or the page it came from. Every screenshot recorded all three and the public API served them++ * (2026-10-24). The rest (an AI moment's links, an avatar's kind, size and type) stays.++ */++const PRIVATE_META = ['user_agent', 'original_name', 'page_url'];++function metadataFor(raw, full) {++ if (!raw || full) return raw || null;++ const obj = typeof raw === 'string' ? parseJson(raw) : { ...raw };++ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;++ for (const k of PRIVATE_META) delete obj[k];++ if (!Object.keys(obj).length) return null;++ return typeof raw === 'string' ? JSON.stringify(obj) : obj;++}/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */function sinceParam(value) {if (value == null || value === '') return null;@@@ -147,7 +161,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limstream_id: streamIdOf(ref),stream_ref: ref,screenshot_url: p.screenshot_url || null,-- metadata: p.metadata || null,++ metadata: metadataFor(p.metadata, isOwner(v, p) || isService(v)),burn_after_read: !!p.burn_after_read,forked_from: p.forked_from || null,pinned: !!p.pinned,@@@ -151,7 +151,7 @@ function homeStats(c = {}) {function forumShelf(spaces) {if (!spaces || !spaces.length) return '';-- return `<section class="section" id="forum" aria-labelledby="h-forum">++ return `<section class="section home-sec" id="forum" aria-labelledby="h-forum"><div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div><ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul></section>`;@@@ -159,7 +159,7 @@ function forumShelf(spaces) {function roadmapShelf(items) {if (!items || !items.length) return '';-- return `<section class="section" id="roadmap" aria-labelledby="h-roadmap">++ return `<section class="section home-sec" id="roadmap" aria-labelledby="h-roadmap"><div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div><ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul></section>`;@@@ -167,7 +167,7 @@ function roadmapShelf(items) {function pulseShelf(items) {if (!items || !items.length) return '';-- return `<section class="section" id="pulse" aria-labelledby="h-pulse">++ return `<section class="section home-sec" id="pulse" aria-labelledby="h-pulse"><div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div><ul class="cm-list" role="list">${items.map((it) => {const local = it.source.service === 'community';@@@ -177,7 +177,7 @@ function pulseShelf(items) {}function homeFaq() {-- return `<section class="section" id="questions" aria-labelledby="h-faq">++ return `<section class="section home-sec" id="questions" aria-labelledby="h-faq"><div class="section-head"><h2 id="h-faq">Questions</h2></div><div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;@@@ -196,18 +196,18 @@ function homePage({ latest, trending, fromStreams = [], languages, user, spacesnote: 'Run by the people using it and moderated in the open. Say it, own it.',aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },}) + homeStats(counts) + `--<section class="section" id="latest">++<section class="section home-sec" id="latest"><div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>${cardGrid(latest, 'No pastes yet — be the first.')}${languages && languages.length ? `<p class="lang-cloud" aria-label="Browse by language">${languages.slice(0, 12).map((l) => `<a href="/pastes?lang=${encodeURIComponent(l.language)}">${esc(languageLabel(l.language))} <small>${l.count}</small></a>`).join(' ')}</p>` : ''}</section>--<section class="section" id="trending">++<section class="section home-sec" id="trending"><div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>${cardGrid(trending, 'Nothing trending yet.')}</section>${fromStreams.length ? `--<section class="section" id="from-streams">++<section class="section home-sec" id="from-streams"><div class="section-head"><h2>From streams</h2><a class="more" href="/pastes?from=streams">All from streams <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div><p class="muted">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p>${cardGrid(fromStreams, '')}@@@ -15,6 +15,8 @@ const text = [{ slug: 'bash-2', type: 'paste', language: 'bash', visibility: 'public', views: 3, created_at: at(13) },{ slug: 'secret', type: 'paste', language: 'bash', visibility: 'unlisted', views: 999, created_at: at(14) },];++// The newest and most viewed image is someone's profile picture, public by Live's avatar upload default.++shots[199].metadata = JSON.stringify({ kind: 'avatar', mime_type: 'image/png' });const all = [...shots, ...text].sort((a, b) => b.created_at.localeCompare(a.created_at));const asked = [];source.use({@@@ -53,5 +55,10 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; consoassert.deepStrictEqual(rel.map((p) => p.slug), ['bash-2', 'txt-1', 'js-1'], 'its language first, then the newest');assert.ok(asked.includes('paste'), 'asked the store for text pastes, not the shared window');});++ await check('the home\'s latest and most viewed leave profile pictures out; the lists keep them', async () => {++ assert.strictEqual((await catalog.latest(3))[0].slug, 'shot-198', 'latest: the avatar is skipped');++ assert.strictEqual((await catalog.trending(3))[0].slug, 'shot-198', 'most viewed: the avatar is skipped');++ assert.strictEqual((await catalog.browse({ type: 'images', sort: 'views' })).pastes[0].slug, 'shot-199', 'the image list still has it');++ });console.log(`paste catalog: ${passed} checks passed`);})().catch((err) => { console.error(err); process.exit(1); });@@@ -362,6 +362,20 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(malformed.status, 400);});++ await check('a screenshot\'s metadata: the owner and services see all of it; everyone else never the uploader\'s browser, file name or page', async () => {++ const meta = (r) => JSON.parse(r.json().paste.metadata);++ const anon = meta(await call(`/api/pastes/${shotSlug}`));++ for (const k of ['user_agent', 'original_name', 'page_url']) assert.ok(!(k in anon), `anonymous: no ${k}`);++ assert.strictEqual(anon.mime_type, 'image/jpeg', 'the rest stays');++ const other = meta(await call(`/api/pastes/${shotSlug}`, { cookie: samJwt }));++ assert.ok(!('original_name' in other) && !('page_url' in other), 'another person: the same');++ const own = meta(await call(`/api/pastes/${shotSlug}`, { cookie: alexJwt }));++ assert.strictEqual(own.original_name, 'desk.jpg');++ assert.strictEqual(own.page_url, 'https://example.com/x');++ const listed = (await call('/api/pastes?limit=50')).json().pastes.find((x) => x.slug === shotSlug);++ assert.ok(!listed || !listed.metadata || !/user_agent|original_name|page_url/.test(listed.metadata), 'nor in the public list');++ });++await t.close();done();})();
@@ -51,6 +51,9 @@ img { max-width: 100%; height: auto; }
@@ -51,6 +51,9 @@ img { max-width: 100%; height: auto; }
.section { padding: 28px 0 8px; }
.section { padding: 28px 0 8px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head h2 { margin: 0; }
.section-head h2 { margin: 0; }
+/* The home's sections take the showcase's heading size (openvibe-shared/showcase.css .sc-sec), like "What lives here" between
+ them and the other OpenVibe homes; a paste page's "Comments" and "More like this" keep the compact size. */
+.home-sec > .section-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; }
.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }
.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }
.lang-cloud { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }
.lang-cloud { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }
.lang-cloud a { background: var(--bg-card); border: 1px solid var(--border); border-radius: 999px; padding: 4px 12px; font-size: .85rem; color: var(--text-primary); }
.lang-cloud a { background: var(--bg-card); border: 1px solid var(--border); border-radius: 999px; padding: 4px 12px; font-size: .85rem; color: var(--text-primary); }
@@ -29,6 +29,15 @@ const FROM = { people: 'people', streams: 'ai', all: undefined };
@@ -29,6 +29,15 @@ const FROM = { people: 'people', streams: 'ai', all: undefined };
function _textOnlyPublic(rows) {
function _textOnlyPublic(rows) {
return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
}
}
+// A profile picture is a public paste when its owner leaves "public" on (Live's avatar upload), but it is not
+// something they shared: the home's latest and most viewed leave them out (three of the four latest were avatars,
+// 2026-10-24). The paste lists and the person's own page still show them.
+function _metaKind(p) {
+ const m = p && p.metadata;
+ if (!m) return null;
+ try { return (typeof m === 'string' ? JSON.parse(m) : m).kind || null; } catch { return null; }
+}
+const _shared = (rows) => rows.filter((p) => _metaKind(p) !== 'avatar');
/** The latest public pastes of one type and origin (cached). Never throws: an unavailable store yields the stale list or []. */
/** The latest public pastes of one type and origin (cached). Never throws: an unavailable store yields the stale list or []. */
async function windowOf({ type = null, from = 'all' } = {}) {
async function windowOf({ type = null, from = 'all' } = {}) {
@@ -54,8 +63,8 @@ function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0
@@ -54,8 +63,8 @@ function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0
function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }
function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }
const fromOf = (v) => (Object.prototype.hasOwnProperty.call(FROM, v) ? v : 'all');
const fromOf = (v) => (Object.prototype.hasOwnProperty.call(FROM, v) ? v : 'all');
-async function latest(n = 12, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byNewest).slice(0, n); }
-async function trending(n = 8, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byViews).slice(0, n); }
+async function latest(n = 12, { from = 'all' } = {}) { return _shared(await windowOf({ from: fromOf(from) })).sort(byNewest).slice(0, n); }
+async function trending(n = 8, { from = 'all' } = {}) { return _shared(await windowOf({ from: fromOf(from) })).sort(byViews).slice(0, n); }
/** Pastes in the same language first, then the newest, never the paste itself. */
/** Pastes in the same language first, then the newest, never the paste itself. */
async function related(paste, n = 6) {
async function related(paste, n = 6) {
@@ -59,6 +59,20 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==
@@ -59,6 +59,20 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
+/**
+ * What a paste's metadata shows to everyone but its owner and services: never the uploader's browser (user_agent), the
+ * file's original name or the page it came from. Every screenshot recorded all three and the public API served them
+ * (2026-10-24). The rest (an AI moment's links, an avatar's kind, size and type) stays.
+ */
+const PRIVATE_META = ['user_agent', 'original_name', 'page_url'];
+function metadataFor(raw, full) {
+ if (!raw || full) return raw || null;
+ const obj = typeof raw === 'string' ? parseJson(raw) : { ...raw };
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
+ for (const k of PRIVATE_META) delete obj[k];
+ if (!Object.keys(obj).length) return null;
+ return typeof raw === 'string' ? JSON.stringify(obj) : obj;
+}
/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */
/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */
function sinceParam(value) {
function sinceParam(value) {
if (value == null || value === '') return null;
if (value == null || value === '') return null;
@@ -147,7 +161,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -147,7 +161,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
stream_id: streamIdOf(ref),
stream_id: streamIdOf(ref),
stream_ref: ref,
stream_ref: ref,
screenshot_url: p.screenshot_url || null,
screenshot_url: p.screenshot_url || null,
- metadata: p.metadata || null,
+ metadata: metadataFor(p.metadata, isOwner(v, p) || isService(v)),
burn_after_read: !!p.burn_after_read,
burn_after_read: !!p.burn_after_read,
forked_from: p.forked_from || null,
forked_from: p.forked_from || null,
pinned: !!p.pinned,
pinned: !!p.pinned,
@@ -151,7 +151,7 @@ function homeStats(c = {}) {
@@ -151,7 +151,7 @@ function homeStats(c = {}) {
function forumShelf(spaces) {
function forumShelf(spaces) {
if (!spaces || !spaces.length) return '';
if (!spaces || !spaces.length) return '';
- return `<section class="section" id="forum" aria-labelledby="h-forum">
+ return `<section class="section home-sec" id="forum" aria-labelledby="h-forum">
<div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul>
<ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul>
</section>`;
</section>`;
@@ -159,7 +159,7 @@ function forumShelf(spaces) {
@@ -159,7 +159,7 @@ function forumShelf(spaces) {
function roadmapShelf(items) {
function roadmapShelf(items) {
if (!items || !items.length) return '';
if (!items || !items.length) return '';
- return `<section class="section" id="roadmap" aria-labelledby="h-roadmap">
+ return `<section class="section home-sec" id="roadmap" aria-labelledby="h-roadmap">
<div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul>
<ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul>
</section>`;
</section>`;
@@ -167,7 +167,7 @@ function roadmapShelf(items) {
@@ -167,7 +167,7 @@ function roadmapShelf(items) {
function pulseShelf(items) {
function pulseShelf(items) {
if (!items || !items.length) return '';
if (!items || !items.length) return '';
- return `<section class="section" id="pulse" aria-labelledby="h-pulse">
+ return `<section class="section home-sec" id="pulse" aria-labelledby="h-pulse">
<div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<ul class="cm-list" role="list">${items.map((it) => {
<ul class="cm-list" role="list">${items.map((it) => {
const local = it.source.service === 'community';
const local = it.source.service === 'community';
@@ -177,7 +177,7 @@ function pulseShelf(items) {
@@ -177,7 +177,7 @@ function pulseShelf(items) {
}
}
function homeFaq() {
function homeFaq() {
- return `<section class="section" id="questions" aria-labelledby="h-faq">
+ return `<section class="section home-sec" id="questions" aria-labelledby="h-faq">
<div class="section-head"><h2 id="h-faq">Questions</h2></div>
<div class="section-head"><h2 id="h-faq">Questions</h2></div>
<div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
<div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
</section>`;
</section>`;
@@ -196,18 +196,18 @@ function homePage({ latest, trending, fromStreams = [], languages, user, spaces
@@ -196,18 +196,18 @@ function homePage({ latest, trending, fromStreams = [], languages, user, spaces
note: 'Run by the people using it and moderated in the open. Say it, own it.',
note: 'Run by the people using it and moderated in the open. Say it, own it.',
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
}) + homeStats(counts) + `
}) + homeStats(counts) + `
-<section class="section" id="latest">
+<section class="section home-sec" id="latest">
<div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(latest, 'No pastes yet — be the first.')}
${cardGrid(latest, 'No pastes yet — be the first.')}
${languages && languages.length ? `<p class="lang-cloud" aria-label="Browse by language">${languages.slice(0, 12).map((l) => `<a href="/pastes?lang=${encodeURIComponent(l.language)}">${esc(languageLabel(l.language))} <small>${l.count}</small></a>`).join(' ')}</p>` : ''}
${languages && languages.length ? `<p class="lang-cloud" aria-label="Browse by language">${languages.slice(0, 12).map((l) => `<a href="/pastes?lang=${encodeURIComponent(l.language)}">${esc(languageLabel(l.language))} <small>${l.count}</small></a>`).join(' ')}</p>` : ''}
</section>
</section>
-<section class="section" id="trending">
+<section class="section home-sec" id="trending">
<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(trending, 'Nothing trending yet.')}
${cardGrid(trending, 'Nothing trending yet.')}
</section>
</section>
${fromStreams.length ? `
${fromStreams.length ? `
-<section class="section" id="from-streams">
+<section class="section home-sec" id="from-streams">
<div class="section-head"><h2>From streams</h2><a class="more" href="/pastes?from=streams">All from streams <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>From streams</h2><a class="more" href="/pastes?from=streams">All from streams <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<p class="muted">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p>
<p class="muted">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p>
${cardGrid(fromStreams, '')}
${cardGrid(fromStreams, '')}
@@ -15,6 +15,8 @@ const text = [
@@ -15,6 +15,8 @@ const text = [
{ slug: 'bash-2', type: 'paste', language: 'bash', visibility: 'public', views: 3, created_at: at(13) },
{ slug: 'bash-2', type: 'paste', language: 'bash', visibility: 'public', views: 3, created_at: at(13) },
{ slug: 'secret', type: 'paste', language: 'bash', visibility: 'unlisted', views: 999, created_at: at(14) },
{ slug: 'secret', type: 'paste', language: 'bash', visibility: 'unlisted', views: 999, created_at: at(14) },
];
];
+// The newest and most viewed image is someone's profile picture, public by Live's avatar upload default.
+shots[199].metadata = JSON.stringify({ kind: 'avatar', mime_type: 'image/png' });
const all = [...shots, ...text].sort((a, b) => b.created_at.localeCompare(a.created_at));
const all = [...shots, ...text].sort((a, b) => b.created_at.localeCompare(a.created_at));
const asked = [];
const asked = [];
source.use({
source.use({
@@ -53,5 +55,10 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
@@ -53,5 +55,10 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
assert.deepStrictEqual(rel.map((p) => p.slug), ['bash-2', 'txt-1', 'js-1'], 'its language first, then the newest');
assert.deepStrictEqual(rel.map((p) => p.slug), ['bash-2', 'txt-1', 'js-1'], 'its language first, then the newest');
assert.ok(asked.includes('paste'), 'asked the store for text pastes, not the shared window');
assert.ok(asked.includes('paste'), 'asked the store for text pastes, not the shared window');
});
});
+ await check('the home\'s latest and most viewed leave profile pictures out; the lists keep them', async () => {
+ assert.strictEqual((await catalog.latest(3))[0].slug, 'shot-198', 'latest: the avatar is skipped');
+ assert.strictEqual((await catalog.trending(3))[0].slug, 'shot-198', 'most viewed: the avatar is skipped');
+ assert.strictEqual((await catalog.browse({ type: 'images', sort: 'views' })).pastes[0].slug, 'shot-199', 'the image list still has it');
+ });
console.log(`paste catalog: ${passed} checks passed`);
console.log(`paste catalog: ${passed} checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -362,6 +362,20 @@ const { boot, check, done } = require('./helpers/app');
@@ -362,6 +362,20 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(malformed.status, 400);
assert.strictEqual(malformed.status, 400);
});
});
+ await check('a screenshot\'s metadata: the owner and services see all of it; everyone else never the uploader\'s browser, file name or page', async () => {
+ const meta = (r) => JSON.parse(r.json().paste.metadata);
+ const anon = meta(await call(`/api/pastes/${shotSlug}`));
+ for (const k of ['user_agent', 'original_name', 'page_url']) assert.ok(!(k in anon), `anonymous: no ${k}`);
+ assert.strictEqual(anon.mime_type, 'image/jpeg', 'the rest stays');
+ const other = meta(await call(`/api/pastes/${shotSlug}`, { cookie: samJwt }));
+ assert.ok(!('original_name' in other) && !('page_url' in other), 'another person: the same');
+ const own = meta(await call(`/api/pastes/${shotSlug}`, { cookie: alexJwt }));
+ assert.strictEqual(own.original_name, 'desk.jpg');
+ assert.strictEqual(own.page_url, 'https://example.com/x');
+ const listed = (await call('/api/pastes?limit=50')).json().pastes.find((x) => x.slug === shotSlug);
+ assert.ok(!listed || !listed.metadata || !/user_agent|original_name|page_url/.test(listed.metadata), 'nor in the public list');
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();