openvibers/openvibe.network Commits

The network's front door (plan T11, D93): the home page starts from what you came to do. A search box ('What do you want to do?') lights the intent it names and opens it, anything else is a tool search (the form's own action, so it works without JavaScript); eight intents are real links to where each thing starts (go live from a browser tab with no OBS and no follower minimum, tools, chat, a paste, play, write, the wiki, build on OpenVibe); the open sites form a constellation around you, the ones opening next faint on the outer ring. 'Right now on OpenVibe' shows who is live (with their current frame, falling back to the avatar), the newest public pastes and the newest posts, read from each service's public API over loopback, cached a minute, never delaying the page, with an honest empty state. The CSP allows openvibe.live thumbnails. test/home-front-door.test.js; the SSRF ratchet reviews the new call site; home budgets unchanged and green.

OP OpenVibers committed 29 Sept 2026 · 20dd7ff02e · 6 files, +325 −20


Changes

ddiff --git a/public/index.html b/public/index.html
iindex 757ce639..ae60b086 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -60,7 +60,7 @@ a{color:inherit}
}
/* Hero */
--.hero{max-width:1080px;margin:0 auto;padding:96px 24px 64px;text-align:center}
++.hero{max-width:1080px;margin:0 auto;padding:80px 24px 40px;text-align:center}
.hero-badge{
display:inline-flex;align-items:center;gap:8px;padding:6px 14px;border-radius:999px;
border:1px solid var(--border-light);background:var(--bg-secondary);
@@@ -69,7 +69,6 @@ a{color:inherit}
.hero h1{font-size:clamp(34px,6vw,58px);font-weight:800;letter-spacing:-1.5px;line-height:1.1}
.hero h1 .grad{background:linear-gradient(120deg,var(--accent-light),var(--accent),var(--secondary));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p.sub{margin:20px auto 0;max-width:620px;font-size:17px;line-height:1.6;color:var(--text-secondary)}
--.hero-ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:36px}
.btn{
display:inline-flex;align-items:center;gap:9px;padding:13px 26px;border-radius:12px;
font-size:15px;font-weight:700;text-decoration:none;border:1px solid transparent;
@@@ -80,6 +79,45 @@ a{color:inherit}
.btn-ghost{background:transparent;color:var(--text-primary);border-color:var(--border-light)}
.btn-ghost:hover{background:var(--bg-hover);border-color:var(--accent)}
#hero-account-cta{display:none}
++.hero-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:28px;align-items:center;text-align:left}
++.hero-grid .hero-badge{margin-bottom:22px}
++.hero-grid h1{font-size:clamp(34px,4.3vw,52px)}
++.hero-grid p.sub{margin:18px 0 0}
++.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
++.intent-bar{display:flex;align-items:center;gap:10px;margin-top:28px;padding:6px 6px 6px 16px;border-radius:14px;border:1px solid var(--border-light);background:var(--bg-secondary);box-shadow:0 10px 30px rgba(0,0,0,.25);transition:border-color .15s,box-shadow .15s}
++.intent-bar:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent),0 10px 30px rgba(0,0,0,.25)}
++.intent-bar>i{color:var(--text-muted)}
++.intent-bar input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--text-primary);font:inherit;font-size:16px;padding:10px 0}
++.intent-bar input::placeholder{color:var(--text-muted)}
++.intent-bar button{border:0;border-radius:10px;padding:11px 20px;font:inherit;font-weight:700;font-size:15px;cursor:pointer;color:var(--on-accent,#fff);background:linear-gradient(135deg,var(--accent),var(--accent-dark))}
++.intent-bar button:hover{filter:brightness(1.08)}
++.intents{list-style:none;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:14px}
++.intent{display:flex;gap:12px;align-items:flex-start;padding:11px 12px;border-radius:12px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 80%,transparent);text-decoration:none;color:inherit;transition:border-color .15s,background .15s,opacity .15s,transform .15s;height:100%}
++.intent>i{width:34px;height:34px;flex:none;display:grid;place-items:center;border-radius:10px;font-size:15px;color:var(--accent-light);background:color-mix(in srgb,var(--accent) 14%,transparent)}
++.intent b{display:block;font-size:14.5px;letter-spacing:-.1px}.intent small{display:block;color:var(--text-secondary);font-size:12.5px;line-height:1.4;margin-top:1px}
++.intent:hover,.intent:focus-visible{border-color:var(--accent);background:var(--bg-hover);outline:0;transform:translateY(-1px)}
++.intent.match{border-color:var(--accent);background:var(--bg-hover)}
++.intent.dim{opacity:.38}
++.hero-account{margin-top:16px;font-size:13.5px;color:var(--text-secondary)}
++.hero-account a{color:var(--accent-light);font-weight:600;text-decoration:none}.hero-account a:hover{text-decoration:underline}
++.hero-sky{position:relative}
++.constellation{width:100%;height:auto;display:block;overflow:visible}
++.constellation .cs-orbit{fill:none;stroke:var(--border-light);stroke-dasharray:2 6;opacity:.7}.constellation .cs-far{opacity:.4}
++.constellation .cs-spoke{stroke:var(--accent);stroke-opacity:.18;stroke-width:1}
++.constellation .cs-arc{stroke:var(--secondary);stroke-opacity:.14;stroke-width:1}
++.constellation .cs-core{fill:var(--bg-secondary);stroke:var(--accent-light);stroke-width:1.5}
++.constellation .cs-core-t{fill:var(--accent-light);font-size:12px;font-weight:800;letter-spacing:.5px}
++.constellation text{fill:var(--text-secondary);font-size:12px;font-weight:600}
++.constellation .cs-node{cursor:pointer}
++.constellation .cs-star{fill:var(--accent-light)}
++.constellation .cs-halo{fill:var(--accent);opacity:.16;transform-box:fill-box;transform-origin:center;animation:cs-twinkle 4.2s ease-in-out infinite;animation-delay:var(--d,0s)}
++.constellation .cs-node:hover .cs-halo,.constellation .cs-node:focus .cs-halo{opacity:.45}
++.constellation .cs-node:hover text,.constellation .cs-node:focus text{fill:var(--text-primary)}
++.constellation .cs-soon .cs-star{fill:var(--text-muted)}.constellation .cs-soon text{fill:var(--text-muted);font-size:10.5px;font-weight:500}
++@keyframes cs-twinkle{0%,100%{transform:scale(1);opacity:.16}50%{transform:scale(1.35);opacity:.3}}
++@media (prefers-reduced-motion:reduce){.constellation .cs-halo{animation:none}.intent{transition:none}}
++@media (max-width:900px){.hero-grid{grid-template-columns:1fr;text-align:center}.hero-grid p.sub{margin:18px auto 0}.hero-sky{display:none}.intent{text-align:left}}
++@media (max-width:520px){.intents{grid-template-columns:1fr 1fr;gap:6px}.intent small{display:none}.intent{align-items:center;gap:9px;padding:9px 10px}.intent>i{width:30px;height:30px;font-size:14px}.intent b{font-size:13.5px}.intent-bar button{padding:11px 14px}}
/* Services */
.services{max-width:1080px;margin:0 auto;padding:24px 24px 80px}
@@@ -140,7 +178,6 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@media (max-width:600px){
.hero{padding-top:64px}
-- .hero-ctas .btn{width:100%;justify-content:center}
}
</style>
<script src="/shared/theme-loader.js"></script>
@@@ -154,17 +191,24 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<div class="page">
-- <!-- Hero -->
++ <!-- Hero: the front door (plan T11, D93) — what you came to do, one line to start it -->
<section class="hero">
-- <div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
-- <h1>The open network<br><span class="grad">for what you do online.</span></h1>
-- <p class="sub">Live streaming, <!--OV:COUNT--> online tools, chat, games, community pastes, a wiki, a blog and a shared media library. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p>
-- <div class="hero-ctas" id="hero-guest-ctas">
-- <a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
-- <a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
-- </div>
-- <div class="hero-ctas" id="hero-account-cta">
-- <a class="btn btn-primary" href="/my"><i class="fa-solid fa-user"></i> Go to My Account</a>
++ <div class="hero-grid">
++ <div class="hero-main">
++ <div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
++ <h1>The open network<br><span class="grad">for what you do online.</span></h1>
++ <p class="sub">Live streaming, <!--OV:COUNT--> online tools, chat, games, community pastes, a wiki, a blog and a shared media library. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p>
++ <form class="intent-bar" action="https://openvibe.tools/search" method="get" role="search">
++ <label for="intent-q" class="sr-only">What do you want to do?</label>
++ <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
++ <input id="intent-q" name="q" type="search" placeholder="What do you want to do?" title="Try “go live”, “merge pdf” or “chat”" autocomplete="off" enterkeyhint="go">
++ <button type="submit">Start</button>
++ </form>
++ <!--OV:INTENTS-->
++ <p class="hero-account" id="hero-guest-ctas">Everything here works without an account. <a href="/login">Sign in</a> or <a href="/login?tab=register">create one</a> to keep your history, themes and VODs.</p>
++ <p class="hero-account" id="hero-account-cta">You are signed in on every OpenVibe site. <a href="/my">Go to My Account</a></p>
++ </div>
++ <div class="hero-sky"><!--OV:CONSTELLATION--></div>
</div>
</section>
@@@ -210,7 +254,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
}
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
-- document.getElementById('hero-account-cta').style.display = 'flex';
++ document.getElementById('hero-account-cta').style.display = 'block';
}
}
@@@ -222,5 +266,29 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
.catch(function () { initNavbar(null); });
})();
</script>
++<script>
++// The hero's search: typing lights the intents it names; Enter opens the one it names clearly, and anything else is a
++// tool search on openvibe.tools (the form's own action, so it works without this script too).
++(function () {
++ var form = document.querySelector('.intent-bar'), q = document.getElementById('intent-q');
++ if (!form || !q) return;
++ var items = [].slice.call(document.querySelectorAll('.intent'));
++ function words(v) { return v.toLowerCase().split(/[^a-z0-9]+/).filter(function (w) { return w.length > 1; }); }
++ function score(el, ws) {
++ var keys = (el.getAttribute('data-k') || '').split(' '), s = 0;
++ ws.forEach(function (w) { if (keys.indexOf(w) >= 0) s += 2; else if (w.length > 2 && keys.some(function (k) { return k.indexOf(w) === 0; })) s += 1; });
++ return s;
++ }
++ function best(ws) { var top = null, ts = 0; items.forEach(function (el) { var s = score(el, ws); if (s > ts) { ts = s; top = el; } }); return { el: top, s: ts }; }
++ q.addEventListener('input', function () {
++ var ws = words(q.value), b = best(ws);
++ items.forEach(function (el) { var s = ws.length ? score(el, ws) : 1; el.classList.toggle('dim', ws.length > 0 && s === 0 && !!b.el); el.classList.toggle('match', !!b.el && el === b.el && b.s > 0); });
++ });
++ form.addEventListener('submit', function (e) {
++ var b = best(words(q.value));
++ if (b.el && b.s >= 2 && b.el.getAttribute('data-id') !== 'tools') { e.preventDefault(); location.href = b.el.href; }
++ });
++})();
++</script>
</body>
</html>
ddiff --git a/server/home/activity.js b/server/home/activity.js
nnew file mode 100644
iindex 00000000..9d024f0c
---- /dev/null
++++ b/server/home/activity.js
@@@ -0,0 +1,103 @@
++'use strict';
++// What is happening on the network right now, for the front door (plan T11, D93): who is live on OpenVibe.Live, the
++// newest public pastes on OpenVibe.Community and the newest posts on OpenVibe.Blog. Each comes from the service's own
++// public API over loopback (fixed first-party addresses, never a URL anyone can choose), and the home page never waits
++// for them: it renders what is known, and a read older than a minute starts one refresh in the background. A service
++// that does not answer within two seconds simply contributes nothing this round.
++const TTL_MS = 60_000;
++const TIMEOUT_MS = 2000;
++
++const SOURCES = {
++ live: process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',
++ community: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
++ blog: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810',
++};
++const PUBLIC = { live: 'https://openvibe.live', community: 'https://openvibe.community', blog: 'https://openvibe.blog' };
++
++let state = { at: 0, version: 0, live: [], pastes: [], posts: [] };
++let inflight = null;
++// Tests switch the reads off; the page then shows the "nothing live" state.
++let enabled = process.env.HOME_ACTIVITY !== 'off' && process.env.NODE_ENV !== 'test';
++
++const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t; };
++const abs = (base, u) => { try { const x = new URL(String(u || ''), base); return /^https:$/.test(x.protocol) ? x.href : null; } catch { return null; } };
++
++async function getJson(url, fetchImpl) {
++ try {
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ return res.ok ? await res.json() : null;
++ } catch { return null; }
++}
++
++function liveStreams(body) {
++ const rows = body && Array.isArray(body.streams) ? body.streams : [];
++ return rows
++ .filter((s) => s && s.is_live && !s.is_nsfw && s.username)
++ .map((s) => ({
++ name: clip(s.display_name || s.username, 40),
++ title: clip(s.ai_title || s.title || `${s.username} is live`, 90),
++ url: `${PUBLIC.live}/@${encodeURIComponent(s.username)}`,
++ thumb: abs(PUBLIC.live, s.thumbnail_url),
++ avatar: abs(PUBLIC.live, s.avatar_url),
++ viewers: Math.max(0, Number(s.total_viewer_count ?? s.viewer_count) || 0),
++ category: clip(s.category || '', 24),
++ }))
++ .sort((a, b) => b.viewers - a.viewers)
++ .slice(0, 4);
++}
++
++function publicPastes(body) {
++ const rows = body && Array.isArray(body.pastes) ? body.pastes : [];
++ return rows
++ .filter((p) => p && p.visibility === 'public' && !p.is_nsfw && !p.burn_after_read && p.slug)
++ .map((p) => ({
++ title: clip(p.title || 'Untitled paste', 70),
++ url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,
++ kind: p.type === 'screenshot' ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),
++ summary: clip(p.ai_summary || '', 110),
++ }))
++ .slice(0, 4);
++}
++
++function blogPosts(body) {
++ const rows = body && Array.isArray(body.items) ? body.items : [];
++ return rows
++ .filter((i) => i && i.url && abs(PUBLIC.blog, i.url))
++ .map((i) => ({ title: clip(i.title || 'Untitled', 80), url: abs(PUBLIC.blog, i.url), summary: clip(i.summary || '', 120), date: i.date_published || null }))
++ .slice(0, 3);
++}
++
++/** One refresh of every source; a source that fails keeps its last good list. */
++async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
++ if (inflight) return inflight;
++ inflight = (async () => {
++ const [live, pastes, posts] = await Promise.all([
++ getJson(`${SOURCES.live}/api/streams`, fetchImpl),
++ getJson(`${SOURCES.community}/api/pastes?limit=12`, fetchImpl),
++ getJson(`${SOURCES.blog}/feed.json`, fetchImpl),
++ ]);
++ state = {
++ at: Date.now(),
++ version: state.version + 1,
++ live: live ? liveStreams(live) : state.live,
++ pastes: pastes ? publicPastes(pastes) : state.pastes,
++ posts: posts ? blogPosts(posts) : state.posts,
++ };
++ return state;
++ })().finally(() => { inflight = null; });
++ return inflight;
++}
++
++/** What is known now; starts a background refresh when it is older than a minute. */
++function peek() {
++ if (enabled && Date.now() - state.at > TTL_MS && !inflight) refresh().catch(() => {}); // floating-ok: refresh catches its own reads
++ return state;
++}
++
++module.exports = {
++ peek, refresh, liveStreams, publicPastes, blogPosts,
++ /** Tests: switch the background reads on or off. */
++ setEnabled(on) { enabled = Boolean(on); },
++ /** Tests: replace the snapshot. */
++ _set(s) { state = { at: Date.now(), version: state.version + 1, live: [], pastes: [], posts: [], ...s }; },
++};
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 69a4ea19..b40cfe65 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -8,6 +8,7 @@ const crypto = require('crypto');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
const toolsCatalog = require('../domains/catalog');
++const activity = require('./activity');
const SHELL = path.join(__dirname, '..', '..', 'public', 'index.html');
const esc = seo.esc;
@@@ -32,6 +33,59 @@ const ACCOUNT = [
];
const POPULAR = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress', 'fancy', 'ssl', 'logo', 'regex'];
++// The front door (plan T11, D93): what people come to do, each a real link to where it starts. `k` holds the words
++// the hero's search box matches (public/index.html); a query that names no intent strongly goes to the tool search.
++const INTENTS = [
++ ['live', 'fa-tower-broadcast', 'Go live', 'From a browser tab. No OBS, no follower minimum.', 'https://openvibe.live/broadcast', 'go live stream streaming broadcast camera webcam screen obs whip rtmp twitch'],
++ ['tools', 'fa-screwdriver-wrench', 'Get something done', '', 'https://openvibe.tools/', 'tool tools convert pdf mp3 mp4 video audio image compress resize dns whois json regex download youtube'],
++ ['chat', 'fa-comments', 'Chat', 'One room for the whole network, plus rooms and messages.', 'https://openvibe.chat/', 'chat talk message messages dm room rooms friends'],
++ ['paste', 'fa-paste', 'Share a paste', 'Text, code or a screenshot behind one short link.', 'https://openvibe.community/new', 'paste share code snippet text link screenshot pastebin gist'],
++ ['games', 'fa-gamepad', 'Play', 'Browser games that already know you.', 'https://openvibe.games/', 'play game games gaming multiplayer'],
++ ['blog', 'fa-pen-nib', 'Write', 'A blog of your own, with drafts, scheduling and feeds.', 'https://openvibe.blog/write', 'write blog post posts article publish newsletter'],
++ ['wiki', 'fa-book-open', 'Look it up', 'Wiki pages with sources and history.', 'https://openvibe.wiki/', 'wiki learn read knowledge research look'],
++ ['codes', 'fa-code', 'Build on OpenVibe', 'API, SDK, OAuth and webhooks for your app.', 'https://openvibe.codes/', 'build api sdk developer developers oauth webhook webhooks app integrate'],
++];
++
++function intents(catalog) {
++ const toolsLine = catalog.tools.length ? `Convert, compress, look up or download: ${catalog.tools.length} tools that just open.` : 'Convert, compress, look up or download: free tools that just open.';
++ return `<ul class="intents" aria-label="What you can do here">${INTENTS.map(([id, fa, verb, line, href, k]) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small></span></a></li>`).join('')}</ul>`;
++}
++
++// The network as a constellation: open sites on the inner ring, the ones opening next faint on the outer ring.
++const SHORT = (site) => (/^OpenRe\./.test(site.name) ? 'OpenRe' : site.name.replace(/^OpenVibe\./, ''));
++function constellation() {
++ const ring = (list, r, start) => list.map((site, i) => {
++ const a = start + (i / list.length) * Math.PI * 2;
++ return { site, x: Math.round(Math.cos(a) * r), y: Math.round(Math.sin(a) * r) };
++ });
++ const inner = ring(OPEN, 118, -Math.PI / 2);
++ const outer = ring(SOON, 176, -Math.PI / 2 + Math.PI / SOON.length);
++ const lines = inner.map((n, i) => {
++ const m = inner[(i + 1) % inner.length];
++ return `<line class="cs-spoke" x1="0" y1="0" x2="${n.x}" y2="${n.y}"/><line class="cs-arc" x1="${n.x}" y1="${n.y}" x2="${m.x}" y2="${m.y}"/>`;
++ }).join('');
++ const label = (n, r) => { const dx = n.x / (Math.hypot(n.x, n.y) || 1), dy = n.y / (Math.hypot(n.x, n.y) || 1); return { x: Math.round(n.x + dx * r), y: Math.round(n.y + dy * r) + 4, anchor: Math.abs(dx) < 0.25 ? 'middle' : (dx > 0 ? 'start' : 'end') }; };
++ const openNodes = inner.map((n, i) => { const l = label(n, 17); return `<a href="https://${esc(n.site.host)}/" class="cs-node" style="--d:${(i * 0.37).toFixed(2)}s"><title>${esc(siteName(n.site))}: ${esc(n.site.tagline)}</title><circle class="cs-halo" cx="${n.x}" cy="${n.y}" r="11"/><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="5"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');
++ const soonNodes = outer.map((n) => { const l = label(n, 9); return `<a href="https://${esc(n.site.host)}/" class="cs-node cs-soon"><title>${esc(siteName(n.site))} — opening next</title><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="2.6"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');
++ return `<svg class="constellation" viewBox="-230 -215 460 430" role="img" aria-labelledby="cs-t"><title id="cs-t">The OpenVibe network: ${OPEN.length} open sites around one account, ${SOON.length} more opening next</title>
++<defs><radialGradient id="cs-core"><stop offset="0" stop-color="var(--accent-light,#60a5fa)"/><stop offset="1" stop-color="var(--accent,#3b82f6)" stop-opacity="0"/></radialGradient></defs>
++<circle class="cs-orbit" r="118"/><circle class="cs-orbit cs-far" r="176"/>${lines}
++<circle r="46" fill="url(#cs-core)" opacity=".35"/><circle class="cs-core" r="24"/><text class="cs-core-t" y="4" text-anchor="middle">you</text>
++${openNodes}${soonNodes}</svg>`;
++}
++
++const fmtViewers = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
++function rightNow(a) {
++ const live = a.live.length
++ ? a.live.map((s) => `<a class="now-stream" href="${esc(s.url)}">${s.thumb || s.avatar ? `<img src="${esc(s.thumb || s.avatar)}" alt="" loading="lazy" decoding="async" width="320" height="180"${s.thumb && s.avatar ? ` data-fallback="${esc(s.avatar)}" onerror="this.onerror=null;this.src=this.dataset.fallback"` : ''}>` : '<span class="now-noimg"></span>'}<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span><b>${esc(s.title)}</b><small>${esc(s.name)}${s.category ? ` · ${esc(s.category)}` : ''}</small></a>`).join('')
++ : `<a class="now-stream now-empty" href="https://openvibe.live/broadcast"><span class="now-noimg"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i></span><b>Nobody is live right now</b><small>Be the first: go live from your browser, no OBS and no follower minimum.</small></a>`;
++ const list = (items, render, empty) => (items.length ? `<ul class="now-list">${items.map(render).join('')}</ul>` : `<p class="now-quiet">${empty}</p>`);
++ return `<section class="home-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
++<div class="now-grid"><div class="now-col now-live"><h3>Live</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels →</a></div>
++<div class="now-col"><h3>New pastes</h3>${list(a.pastes, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'Nothing new in the last while. <a href="https://openvibe.community/new">Share one</a>.')}<a class="now-more" href="https://openvibe.community/">OpenVibe.Community →</a></div>
++<div class="now-col"><h3>From the blog</h3>${list(a.posts, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'No posts yet.')}<a class="now-more" href="https://openvibe.blog/">OpenVibe.Blog →</a></div></div></section>`;
++}
++
const CSS = `
.home-sec{max-width:1080px;margin:56px auto 0;padding:0 24px}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}
.home-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
@@@ -47,19 +101,39 @@ const CSS = `
.home-chips>li>a:hover{border-color:var(--accent,#3b82f6)}
.home-dev{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.home-dev div{padding:16px;border-radius:16px;border:1px dashed var(--border,rgba(255,255,255,.14))}.home-dev b{display:block;margin-bottom:4px}.home-dev p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13.5px}.home-dev code{font-size:12.5px}
--@media (prefers-reduced-motion:reduce){.home-card{transition:none}}
++.now>h2{display:flex;align-items:center;gap:10px}.now-dot{width:10px;height:10px;border-radius:50%;background:var(--live-red,#ef4444);box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:now-pulse 2s infinite}
++@keyframes now-pulse{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
++.now-grid{display:grid;gap:16px;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:14px}
++.now-col{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);min-width:0}
++.now-col h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}
++.now-streams{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
++.now-stream{position:relative;display:flex;flex-direction:column;gap:3px;color:inherit;text-decoration:none;min-width:0}
++.now-stream img,.now-noimg{width:100%;aspect-ratio:16/9;height:auto;object-fit:cover;border-radius:10px;background:var(--bg-hover,#1c2a44);display:grid;place-items:center;font-size:26px;color:var(--accent-light,#60a5fa)}
++.now-stream b{font-size:14px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.now-stream small{color:var(--text-secondary,#a8b3c4);font-size:12.5px}
++.now-stream:hover b,.now-list a:hover b{color:var(--accent-light,#60a5fa)}
++.now-badge{position:absolute;top:8px;left:8px;display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:6px;background:var(--live-red,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.4px}
++.now-badge i{width:6px;height:6px;border-radius:50%;background:#fff}
++.now-empty .now-noimg{border:1px dashed var(--border-light,#2c3d5c);background:transparent}
++.now-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
++.now-list a{display:block;color:inherit;text-decoration:none}.now-list b{display:block;font-size:14px;line-height:1.35}
++.now-list small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--text-secondary,#a8b3c4);font-size:12.5px;line-height:1.4;margin-top:2px}
++.now-quiet{color:var(--text-muted,#7d8aa0);font-size:13.5px;margin:0}.now-quiet a{color:var(--accent-light,#60a5fa)}
++.now-more{margin-top:auto;font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}
++@media (max-width:860px){.now-grid{grid-template-columns:1fr}}
++@media (prefers-reduced-motion:reduce){.home-card{transition:none}.now-dot{animation:none}}
${icons.CSS}`;
// The sites section's heading; the JSON-LD ItemList of the same sites carries it as its name, so the
// structured data names something a visitor can see (browser check, WS-Q task 3).
const sitesHeading = () => `${inWords(OPEN.length)} sites, one front door`;
--function body(catalog) {
++function body(catalog, act) {
const byId = new Map(catalog.tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = catalog.families.filter(f => f.url);
const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;
return `<style>${CSS}</style>
++${rightNow(act)}
<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
<div class="home-grid">${OPEN.map((site) => `<a class="home-card" href="https://${esc(site.host)}/">${icon(site.icon, 48)}<span><b>${esc(siteName(site))}</b><em>${esc(site.tagline)}</em><small>${esc(site.what)}</small></span></a>`).join('')}</div></section>
<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length ? `${catalog.tools.length} tools` : 'Tools'} that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
@@@ -78,11 +152,12 @@ ${SOON.length ? `<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-so
let cache = { key: '', html: '', etag: '' };
function render() {
const { catalog } = toolsCatalog.peek();
++ const act = activity.peek();
const stat = fs.statSync(SHELL);
-- const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
++ const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}`;
if (cache.key === key) return cache;
const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));
-- const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
++ const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', constellation()).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return cache;
}
@@@ -105,4 +180,4 @@ function llmsTxt() {
{ title: 'Machine-readable', links: [{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' }, { title: 'Tools llms.txt', url: 'https://openvibe.tools/llms.txt' }, { title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' }] }].filter((section) => section.links.length) });
}
--module.exports = { sendHome, render, llmsTxt };
++module.exports = { sendHome, render, llmsTxt, INTENTS };
ddiff --git a/server/index.js b/server/index.js
iindex 90c72f3f..834db65c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -132,7 +132,7 @@ app.use(helmet({
scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "fonts.gstatic.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
-- imgSrc: ["'self'", "data:", "blob:", "https://openvibe.media"], // avatars and media live on openvibe.media
++ imgSrc: ["'self'", "data:", "blob:", "https://openvibe.media", "https://openvibe.live"], // avatars and media live on openvibe.media; live-stream thumbnails on the home page come from openvibe.live
connectSrc: [
"'self'",
"https://openvibe.network",
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
nnew file mode 100644
iindex 00000000..47290c34
---- /dev/null
++++ b/test/home-front-door.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// The front door (plan T11, D93): the hero's intents are real links, the constellation names the open sites, and
++// "Right now on OpenVibe" shows only public, safe-for-work items from each service's public API, with an honest empty
++// state. Rendered in-process; the activity reads are switched off and the snapshot is set directly.
++const assert = require('assert');
++const activity = require('../server/home/activity');
++const home = require('../server/home/render');
++const sites = require('../server/frame/sites').SITES;
++
++activity.setEnabled(false);
++
++// Mapping from each service's public answer.
++const live = activity.liveStreams({ streams: [
++ { is_live: 1, is_nsfw: 0, username: 'alice', display_name: 'Alice', title: 'Morning coding', thumbnail_url: '/api/thumbnails/a.jpg', viewer_count: 3, category: 'tech', avatar_url: 'https://openvibe.media/avatar/alice' },
++ { is_live: 1, is_nsfw: 1, username: 'hidden', title: 'nsfw' },
++ { is_live: 0, username: 'offline', title: 'ended' },
++ { is_live: 1, is_nsfw: 0, username: 'bob', title: 'Big stream', thumbnail_url: 'javascript:alert(1)', total_viewer_count: 1200 },
++] });
++assert.deepStrictEqual(live.map((s) => s.name), ['bob', 'Alice'], 'live and SFW only, most watched first');
++assert.strictEqual(live[1].thumb, 'https://openvibe.live/api/thumbnails/a.jpg', 'a relative thumbnail becomes the public https URL');
++assert.strictEqual(live[0].thumb, null, 'a non-https thumbnail is dropped');
++assert.strictEqual(live[1].url, 'https://openvibe.live/@alice');
++
++const pastes = activity.publicPastes({ pastes: [
++ { slug: 'one', title: 'Public one', visibility: 'public', type: 'code', language: 'js' },
++ { slug: 'two', title: 'Unlisted', visibility: 'unlisted' },
++ { slug: 'three', title: 'Burns', visibility: 'public', burn_after_read: true },
++ { slug: 'four', title: 'NSFW', visibility: 'public', is_nsfw: true },
++ { slug: 'five', title: 'Shot', visibility: 'public', type: 'screenshot', ai_summary: 'a desk' },
++] });
++assert.deepStrictEqual(pastes.map((p) => p.url), ['https://openvibe.community/p/one', 'https://openvibe.community/p/five'], 'public, lasting and SFW only');
++assert.deepStrictEqual([pastes[0].kind, pastes[1].kind], ['js', 'Screenshot']);
++
++const posts = activity.blogPosts({ items: [{ url: 'https://openvibe.blog/@a/b', title: 'Post', summary: 'x' }, { url: 'http://evil.example/', title: 'bad' }] });
++assert.deepStrictEqual(posts.map((p) => p.url), ['https://openvibe.blog/@a/b'], 'only https links');
++
++// The page with nothing live: the empty state invites a browser go-live.
++activity._set({ live: [], pastes: [], posts: [] });
++let { html } = home.render();
++assert.ok(/<form class="intent-bar" action="https:\/\/openvibe.tools\/search" method="get"/.test(html), 'the search works without JavaScript (a tool search)');
++for (const [id, , verb, , href] of home.INTENTS) {
++ assert.ok(html.includes(`href="${href}" data-id="${id}"`), `intent ${id} links to ${href}`);
++ assert.ok(html.includes(`<b>${verb}</b>`), `intent ${id} says "${verb}"`);
++}
++assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
++assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');
++const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
++assert.ok(html.includes('<svg class="constellation"'), 'the constellation is drawn');
++for (const s of open) assert.ok(html.includes(`<a href="https://${s.host}/" class="cs-node"`), `the constellation links ${s.host}`);
++assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');
++
++// With activity: the items render, escaped.
++activity._set({ live: [{ name: 'Al<b>ice', title: 'Hi & bye', url: 'https://openvibe.live/@alice', thumb: 'https://openvibe.live/t.jpg', avatar: 'https://openvibe.media/a', viewers: 1500, category: '' }], pastes, posts });
++({ html } = home.render());
++assert.ok(html.includes('LIVE · 1.5k') && html.includes('Al&lt;b&gt;ice') && html.includes('Hi &amp; bye'), 'viewers, names and titles render escaped');
++assert.ok(html.includes('data-fallback="https://openvibe.media/a"'), 'a broken thumbnail falls back to the avatar');
++assert.ok(html.includes('href="https://openvibe.community/p/one"') && html.includes('href="https://openvibe.blog/@a/b"'));
++console.log('home front door: all checks passed');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 932d9bed..afa5c8fd 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -110,6 +110,7 @@ const PATTERNS = {
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
++ 'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/admin/analytics-routes.js fetch': [1, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],