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)

OP OpenVibers committed 20 days ago · f14c619161 · 6 files, +57 −10

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 37ec5be..1ea60a1 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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); }
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex 0b81aec..31c860b 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -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 || 0
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');
--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) {
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex df99808..7edad45 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
stream_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,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 5bbac7a..615f660 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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, spaces
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>` },
}) + 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, '')}
ddiff --git a/test/paste-catalog.test.js b/test/paste-catalog.test.js
iindex 33256c9..532a401 100644
---- a/test/paste-catalog.test.js
++++ b/test/paste-catalog.test.js
@@@ -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++; conso
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');
});
++ 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); });
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 2b411f0..9fae50d 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -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();
})();