openvibers/openvibe.community Commits

Pastes: Live proxy, server-rendered pages with syntax highlighting, SEO

OP OpenVibers committed 18 Sept 2026 · f3232e200c · 13 files, +1582 −0

/api/pastes/* is proxied byte-for-byte to OpenVibe.Live's /api/pastes/* with the visitor's
Network JWT as a Bearer header and the real client address — Live maps the JWT onto its
own account ids before it talks to Media, so creation, listing and permission semantics
are identical to Live's (the Tools gateway does the same, for the same reason).

Pages are server-rendered: home (hero, latest, most viewed, start-a-paste CTA, what is
coming), /pastes (search, new/views sort, language filter, pagination), /p/:slug
(highlight.js on the server, raw/download/copy/fork/share, screenshot, related), /new
(signed-in or anonymous; JS posts to the API, the plain form post still works) and /my.
Every page carries canonical, robots, OG/Twitter and JSON-LD; unlisted/private pastes are
noindex. robots.txt, a dynamic sitemap and an RSS feed round it out. The stylesheet uses
the shared theme tokens with the blue Vibe fallbacks, and the shared navbar/footer are
initialised with the community links, history and silent-login settings.

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
nnew file mode 100644
iindex 0000000..2f57aa5
---- /dev/null
++++ b/public/css/community.css
@@@ -0,0 +1,164 @@
++/* OpenVibe.Community — small, theme-following stylesheet.
++ Colors come from the shared theme tokens set by theme-loader.js; the fallbacks are the
++ network's default (blue "vibe") palette so the page looks right before it loads. */
++:root {
++ --bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;
++ --text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;
++ --accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;
++ --border: #1f2d47; --border-light: #2c3d5c;
++ --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;
++ --shadow: 0 2px 12px rgba(0,0,0,0.45); --shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
++ --radius: 12px; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
++}
++* { box-sizing: border-box; }
++html { color-scheme: var(--color-scheme, dark); scroll-behavior: smooth; }
++body { margin: 0; background: var(--bg-primary); color: var(--text-primary); font: 15px/1.55 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
++a { color: var(--accent-light); text-decoration: none; }
++a:hover { text-decoration: underline; }
++h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 .4em; }
++h1 { font-size: clamp(1.7rem, 3.5vw, 2.4rem); }
++h2 { font-size: 1.35rem; }
++h3 { font-size: 1.05rem; }
++img { max-width: 100%; height: auto; }
++.page { max-width: 1120px; margin: 0 auto; padding: 24px 16px 48px; }
++.muted { color: var(--text-secondary); }
++.small { font-size: .85rem; }
++.empty { color: var(--text-muted); padding: 32px 0; text-align: center; }
++.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .75rem; font-weight: 700; color: var(--accent-light); margin: 0 0 8px; }
++.lede { font-size: 1.1rem; color: var(--text-secondary); max-width: 46rem; }
++
++/* Buttons */
++.btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-card); color: var(--text-primary); font: inherit; font-weight: 600; cursor: pointer; transition: border-color .15s, background .15s, transform .1s; }
++.btn:hover { border-color: var(--accent); text-decoration: none; background: var(--bg-hover); }
++.btn:active { transform: translateY(1px); }
++.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
++.btn-primary:hover { background: var(--accent-dark, var(--accent)); border-color: var(--accent-dark, var(--accent)); }
++.btn-ghost { background: transparent; }
++.btn-danger { border-color: var(--danger); color: var(--danger); }
++.btn-danger:hover { background: var(--danger); color: #fff; }
++.btn-sm { padding: 6px 11px; font-size: .85rem; border-radius: 7px; }
++.btn[disabled] { opacity: .6; cursor: wait; }
++
++/* Hero */
++.hero { padding: 40px 0 24px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
++.hero h1 { font-size: clamp(2.1rem, 5vw, 3.2rem); }
++.hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 18px; }
++.hero-points { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px 24px; color: var(--text-secondary); font-size: .92rem; }
++.hero-points i { color: var(--accent-light); width: 18px; text-align: center; margin-right: 4px; }
++
++/* Sections */
++.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; }
++.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }
++.cta { margin-top: 20px; }
++.cta-inner { background: linear-gradient(135deg, rgba(var(--accent-rgb), .18), rgba(var(--accent-rgb), .04)); border: 1px solid rgba(var(--accent-rgb), .35); border-radius: var(--radius); padding: 28px; }
++.cta-inner p { max-width: 52rem; color: var(--text-secondary); }
++.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 small { color: var(--text-muted); margin-left: 4px; }
++.coming-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin: 12px 0 14px; }
++.coming-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
++.coming-item i { font-size: 1.4rem; color: var(--accent-light); margin-bottom: 8px; display: block; }
++.coming-item p { margin: 0; color: var(--text-secondary); font-size: .93rem; }
++
++/* Cards */
++.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
++.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; transition: border-color .15s, transform .15s, box-shadow .15s; }
++.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); }
++.card-link { color: inherit; display: block; }
++.card-link:hover { text-decoration: none; }
++.card-code { margin: 0; height: 118px; overflow: hidden; padding: 12px 14px; font: 12px/1.45 var(--mono); color: var(--text-secondary); background: var(--bg-input); white-space: pre-wrap; word-break: break-word; border-bottom: 1px solid var(--border); -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
++.card-shot { height: 150px; background: var(--bg-input); border-bottom: 1px solid var(--border); }
++.card-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.card-nsfw .card-shot img, .card-nsfw .card-code { filter: blur(10px); }
++.card-title { margin: 0; padding: 12px 14px 4px; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.card-title i { color: var(--accent-light); font-size: .8em; }
++.card-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 14px 12px; font-size: .8rem; color: var(--text-secondary); margin-top: auto; }
++.card-right { display: inline-flex; align-items: center; gap: 10px; white-space: nowrap; }
++.author { display: inline-flex; align-items: center; gap: 6px; color: inherit; min-width: 0; }
++.author span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: none; }
++.avatar-letter { display: inline-grid; place-items: center; background: var(--accent); color: var(--on-accent); font-size: .7rem; font-weight: 700; }
++.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; border: 1px solid var(--border-light); color: var(--text-secondary); }
++a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-light); }
++.badge-nsfw { border-color: var(--danger); color: var(--danger); }
++.badge-burn { border-color: var(--warning); color: var(--warning); }
++.stat { display: inline-flex; align-items: center; gap: 4px; }
++
++/* Browse */
++.page-head { padding: 16px 0 8px; }
++.page-head h1 { margin-bottom: 4px; }
++.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; margin: 12px 0 20px; }
++.filters label { display: flex; flex-direction: column; gap: 4px; font-size: .8rem; color: var(--text-secondary); }
++.filters .search { flex: 1 1 260px; flex-direction: row; align-items: center; background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 0 12px; }
++.filters .search input { flex: 1; background: none; border: 0; color: var(--text-primary); padding: 9px 8px; font: inherit; outline: none; min-width: 0; }
++.filters select { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 8px 10px; font: inherit; }
++.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 22px 0 0; }
++.pager a { font-weight: 600; }
++.pager-info { color: var(--text-muted); font-size: .9rem; }
++[data-results].loading { opacity: .5; pointer-events: none; }
++
++/* Paste page */
++.crumbs { font-size: .85rem; color: var(--text-muted); margin: 4px 0 14px; }
++.paste-head h1 { word-break: break-word; }
++.paste-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; color: var(--text-secondary); font-size: .9rem; margin: 0 0 12px; }
++.sep { color: var(--text-muted); }
++.summary { background: var(--bg-card); border-left: 3px solid var(--accent); padding: 10px 14px; border-radius: 0 8px 8px 0; color: var(--text-secondary); }
++.summary i { color: var(--accent-light); margin-right: 6px; }
++.actions, .share { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
++.code-wrap { display: grid; grid-template-columns: auto 1fr; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); overflow: auto; margin: 12px 0 18px; max-height: 80vh; }
++.code-wrap pre { margin: 0; font: 13px/1.55 var(--mono); tab-size: 4; }
++.gutter { padding: 14px 10px 14px 14px; text-align: right; color: var(--text-muted); user-select: none; border-right: 1px solid var(--border); position: sticky; left: 0; background: var(--bg-input); }
++.code { padding: 14px 16px; }
++.code code { display: block; white-space: pre; }
++.code-wrap.wrap .code code { white-space: pre-wrap; word-break: break-word; }
++.shot { margin: 12px 0 18px; text-align: center; }
++.shot img { border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-input); max-height: 80vh; }
++.shot.nsfw img { filter: blur(20px); transition: filter .2s; }
++.shot.nsfw:hover img, .shot.nsfw.revealed img { filter: none; }
++.shot figcaption { color: var(--text-secondary); margin-top: 8px; white-space: pre-wrap; }
++.paste-foot { border-top: 1px solid var(--border); padding-top: 14px; }
++
++/* Syntax colors follow the theme instead of shipping a fixed palette. */
++.hljs { color: var(--text-primary); }
++.hljs-comment, .hljs-quote, .hljs-meta { color: var(--text-muted); font-style: italic; }
++.hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-section, .hljs-name { color: var(--accent-light); font-weight: 600; }
++.hljs-string, .hljs-regexp, .hljs-addition, .hljs-template-tag { color: var(--success); }
++.hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--warning); }
++.hljs-title, .hljs-title.function_, .hljs-title.class_, .hljs-type, .hljs-built_in, .hljs-selector-id, .hljs-selector-class { color: var(--info); }
++.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-property, .hljs-params { color: var(--text-secondary); }
++.hljs-deletion { color: var(--danger); }
++.hljs-emphasis { font-style: italic; }
++.hljs-strong { font-weight: 700; }
++
++/* Forms */
++.paste-form { display: flex; flex-direction: column; gap: 14px; max-width: 900px; }
++.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }
++.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }
++.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }
++.field textarea { font-family: var(--mono); font-size: 13px; line-height: 1.5; min-height: 160px; resize: vertical; }
++.field :focus { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 0; border-color: var(--accent); }
++.field-row { display: flex; flex-wrap: wrap; gap: 14px; }
++.check { display: inline-flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: .9rem; }
++.tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--border); }
++.tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-secondary); padding: 8px 12px; font: inherit; font-weight: 600; cursor: pointer; }
++.tab.active { color: var(--accent-light); border-bottom-color: var(--accent); }
++.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
++.form-status { min-height: 1.2em; margin: 0; color: var(--text-secondary); }
++.form-status.error { color: var(--danger); }
++.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
++.alert-error { border-color: var(--danger); color: var(--danger); }
++
++/* Errors + footer host */
++.error { text-align: center; padding: 60px 0; }
++.error .btn { margin: 4px; }
++.ov-footer { margin-top: 24px; }
++
++@media (max-width: 640px) {
++ .page { padding: 16px 16px 40px; }
++ .card-meta { flex-wrap: wrap; }
++ .code-wrap { max-height: none; }
++ .gutter { display: none; }
++ .code-wrap { grid-template-columns: 1fr; }
++}
ddiff --git a/public/favicon.svg b/public/favicon.svg
nnew file mode 100644
iindex 0000000..5846803
---- /dev/null
++++ b/public/favicon.svg
@@@ -0,0 +1,8 @@
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
++ <rect width="64" height="64" rx="16" fill="#0a0f1c"/>
++ <circle cx="32" cy="32" r="22" fill="none" stroke="#3b82f6" stroke-width="5"/>
++ <circle cx="32" cy="32" r="6" fill="#60a5fa"/>
++ <circle cx="20" cy="40" r="4" fill="#60a5fa"/>
++ <circle cx="44" cy="40" r="4" fill="#60a5fa"/>
++ <circle cx="32" cy="18" r="4" fill="#60a5fa"/>
++</svg>
ddiff --git a/public/js/community.js b/public/js/community.js
nnew file mode 100644
iindex 0000000..ca5856b
---- /dev/null
++++ b/public/js/community.js
@@@ -0,0 +1,141 @@
++/* OpenVibe.Community — progressive enhancement. Every page works without this file;
++ it adds copy buttons, in-place browsing, deletes, and the create form's upload path. */
++(function () {
++ 'use strict';
++ var $ = function (s, r) { return (r || document).querySelector(s); };
++ var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
++
++ // A silent sign-in that found no session leaves ?sso=none behind — tidy the address bar.
++ if (/[?&]sso=none\b/.test(location.search) && history.replaceState) {
++ var u = new URL(location.href); u.searchParams.delete('sso');
++ history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
++ }
++
++ // A dead avatar URL (old Live avatars, offline Media) falls back to the letter badge.
++ document.addEventListener('error', function (e) {
++ var img = e.target;
++ if (!img || img.tagName !== 'IMG' || !img.classList.contains('avatar')) return;
++ var name = (img.parentNode && img.parentNode.textContent || '?').trim();
++ var span = document.createElement('span'); span.className = 'avatar avatar-letter'; span.setAttribute('aria-hidden', 'true'); span.textContent = (name[0] || '?').toUpperCase();
++ img.replaceWith(span);
++ }, true);
++
++ function flash(btn, text) {
++ if (!btn) return;
++ var old = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i> ' + text;
++ setTimeout(function () { btn.innerHTML = old; }, 1600);
++ }
++ function copyText(text) {
++ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
++ return new Promise(function (resolve, reject) {
++ var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '-1000px';
++ document.body.appendChild(ta); ta.select();
++ try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); } catch (e) { reject(e); }
++ document.body.removeChild(ta);
++ });
++ }
++ function api(path, opts) {
++ opts = opts || {};
++ var headers = opts.headers || {};
++ if (opts.json !== undefined) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.json); }
++ return fetch(path, { method: opts.method || 'GET', headers: headers, body: opts.body, credentials: 'same-origin' })
++ .then(function (r) { return r.text().then(function (t) { var d = null; try { d = t ? JSON.parse(t) : null; } catch (e) { d = { error: t.slice(0, 200) }; } if (!r.ok) { var err = new Error((d && d.error) || ('Request failed (' + r.status + ')')); err.status = r.status; err.data = d; throw err; } return d; }); });
++ }
++
++ // ── Paste page ──────────────────────────────────────────────
++ document.addEventListener('click', function (e) {
++ var t = e.target.closest ? e.target.closest('[data-copy-content],[data-copy-link],[data-delete]') : null;
++ if (!t) return;
++ if (t.hasAttribute('data-copy-content')) {
++ var code = $('#paste-content');
++ copyText(code ? code.textContent : '').then(function () { flash(t, 'Copied'); api('/api/pastes/' + encodeURIComponent(t.getAttribute('data-copy-content')) + '/copy', { method: 'POST', json: {} }).catch(function () {}); }).catch(function () { flash(t, 'Select and copy'); });
++ } else if (t.hasAttribute('data-copy-link')) {
++ copyText(t.getAttribute('data-copy-link')).then(function () { flash(t, 'Link copied'); }).catch(function () {});
++ } else if (t.hasAttribute('data-delete')) {
++ if (!confirm('Delete this paste? There is no undo.')) return;
++ t.disabled = true;
++ api('/api/pastes/' + encodeURIComponent(t.getAttribute('data-delete')), { method: 'DELETE' })
++ .then(function () { location.href = '/my'; })
++ .catch(function (err) { t.disabled = false; alert(err.message); });
++ }
++ });
++ var shot = $('.shot.nsfw');
++ if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } });
++ var codeWrap = $('[data-code]');
++ if (codeWrap) {
++ var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';
++ wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); });
++ var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
++ }
++
++ // ── Browse: filters + pagination without a full reload ──────
++ var browseForm = $('form[data-browse]');
++ if (browseForm && window.fetch && history.pushState) {
++ var results = $('[data-results]');
++ function load(url, push) {
++ results.classList.add('loading');
++ fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
++ var doc = new DOMParser().parseFromString(html, 'text/html');
++ var next = doc.querySelector('[data-results]');
++ if (next) results.innerHTML = next.innerHTML;
++ var head = doc.querySelector('.page-head'); var mine = $('.page-head'); if (head && mine) mine.innerHTML = head.innerHTML;
++ document.title = doc.title;
++ if (push) history.pushState({ browse: url }, '', url);
++ results.classList.remove('loading');
++ }).catch(function () { location.href = url; });
++ }
++ browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); });
++ $$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }); });
++ results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); });
++ window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); });
++ }
++
++ // ── New paste: tabs + API submit (text → JSON, screenshot → multipart) ──
++ var form = $('form[data-new-paste]');
++ if (form && window.fetch && window.FormData) {
++ var status = $('[data-status]', form);
++ var tabs = $$('.tab', form); var mode = 'text';
++ tabs.forEach(function (t) { t.hidden = false; });
++ function setMode(m) {
++ mode = m;
++ tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });
++ $$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });
++ var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;
++ if (content) content.required = m === 'text';
++ if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }
++ if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
++ }
++ tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }); });
++ setMode('text');
++ // Dropping or pasting an image anywhere on the form switches to the screenshot mode.
++ function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
++ form.addEventListener('dragover', function (e) { e.preventDefault(); });
++ form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } });
++ document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } });
++
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';
++ var el = form.elements; var p;
++ var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
++ if (mode === 'image') {
++ var fd = new FormData();
++ fd.append('screenshot', el.screenshot.files[0]);
++ fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
++ if (common.is_nsfw) fd.append('is_nsfw', '1');
++ p = api('/api/pastes/screenshot', { method: 'POST', body: fd });
++ } else {
++ p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
++ }
++ p.then(function (out) {
++ var slug = out && (out.slug || (out.paste && out.paste.slug));
++ if (!slug) throw new Error('No paste came back');
++ status.textContent = 'Done — opening your paste…';
++ location.href = '/p/' + encodeURIComponent(slug);
++ }).catch(function (err) {
++ btn.disabled = false; status.className = 'form-status error';
++ status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
++ });
++ });
++ }
++})();
ddiff --git a/public/og-default.png b/public/og-default.png
nnew file mode 100644
iindex 0000000..f92d1c2
BBinary files /dev/null and b/public/og-default.png differ
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..0e75e6f
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,221 @@
++'use strict';
++
++/**
++ * OpenVibe.Community — the people of OpenVibe.
++ *
++ * Express app factory (server/index.js listens; tests build their own instance).
++ *
++ * Pages (server-rendered) API / machine
++ * GET / home ALL /api/pastes/* → OpenVibe.Live proxy
++ * GET /pastes browse GET /api/health, /api/ready
++ * GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml
++ * GET /p/:slug/raw → Media /auth/login|callback|logout|me|refresh
++ * GET /p/:slug/screenshot → Media
++ * GET /p/:slug/download
++ * GET|POST /new create
++ * GET /my signed-in user's pastes
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++
++const config = require('./config');
++const live = require('./live-client');
++const catalog = require('./pastes/catalog');
++const seo = require('./seo');
++const pages = require('./render/pages');
++const { assetVersion } = require('./render/layout');
++const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
++const { createPastesProxy } = require('./pastes/proxy');
++const { extensionFor } = require('./render/highlight');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
++const VERSION = require('../package.json').version;
++
++function createApp(opts = {}) {
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // Shared chrome (theme-loader, navbar, footer, history, ov-mark) comes from the Network.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://cdnjs.cloudflare.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://openvibe.network'],
++ 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'],
++ frameAncestors: ["'self'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ scriptSrcAttr: ["'unsafe-inline'"],
++ },
++ },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'cross-origin' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
++ const auth = opts.auth || createAuthClient(config);
++ app.locals.auth = auth;
++ app.locals.config = config;
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', createAuthRoutes(config, auth));
++
++ // ── /api/pastes → OpenVibe.Live (before any body parser: bodies stream through) ──
++ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
++ app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
++
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
++ app.get('/api/ready', (_req, res) => res.json({ ready: true }));
++
++ // ── Static assets (content-hashed ?v= → immutable) ───────
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, etag: true, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ if (v && v === assetVersion(rel)) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
++ else if (/\.(svg|png|ico|webmanifest)$/.test(rel)) res.setHeader('Cache-Control', 'public, max-age=86400');
++ else res.setHeader('Cache-Control', 'no-cache');
++ },
++ }));
++
++ // ── Machine endpoints ────────────────────────────────────
++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt()));
++ app.get('/sitemap.xml', seo.sitemapHandler);
++ app.get('/feed.xml', wrap(seo.feedHandler));
++
++ // ── Pages ────────────────────────────────────────────────
++ const withUser = optionalAuth(auth);
++ const ctxOf = (req) => ({ token: req.token, ip: req.ip });
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++
++ app.get('/', withUser, wrap(async (req, res) => {
++ const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);
++ html(res, pages.homePage({ latest, trending, languages, user: req.user }));
++ }));
++
++ app.get('/pastes', withUser, wrap(async (req, res) => {
++ const [result, languages] = await Promise.all([catalog.browse(req.query), catalog.languages()]);
++ html(res, pages.browsePage(result, languages));
++ }));
++
++ // Old Live-style /pastes/<slug> links and bare slugs land on the paste page.
++ app.get('/pastes/:slug', (req, res) => res.redirect(301, `/p/${encodeURIComponent(req.params.slug)}`));
++
++ app.get('/p/:slug', withUser, wrap(async (req, res) => {
++ const { slug } = req.params;
++ if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);
++ let paste;
++ try {
++ paste = await live.getPaste(slug, ctxOf(req));
++ } catch (err) {
++ if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
++ if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);
++ throw err;
++ }
++ const related = await catalog.related(paste, 6);
++ html(res, pages.pastePage({ paste, related, user: req.user }));
++ }));
++
++ // Raw text and screenshots are public on OpenVibe.Media — bounce there.
++ app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
++ app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
++
++ app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
++ const { slug } = req.params;
++ if (!SLUG_RE.test(slug)) return res.status(404).type('text/plain').send('Not found');
++ let paste;
++ try { paste = await live.getPaste(slug, { ...ctxOf(req), noView: true }); }
++ catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
++ if (paste.type === 'screenshot') return res.redirect(302, live.screenshotUrl(slug));
++ res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
++ res.set('Cache-Control', 'private, no-cache');
++ res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
++ }));
++
++ app.get('/new', withUser, wrap(async (req, res) => {
++ let fork = null;
++ if (req.query.fork && SLUG_RE.test(String(req.query.fork))) {
++ try { const p = await live.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }
++ }
++ html(res, pages.newPage({ user: req.user, fork }));
++ }));
++
++ // No-JS fallback: a plain form post becomes the same JSON create the API path uses.
++ app.post('/new', withUser, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
++ const b = req.body || {};
++ const values = {
++ title: String(b.title || '').slice(0, 200),
++ language: String(b.language || 'auto'),
++ content: String(b.content || ''),
++ visibility: ['public', 'unlisted', 'private'].includes(b.visibility) ? b.visibility : 'public',
++ burn_after_read: !!b.burn_after_read,
++ is_nsfw: !!b.is_nsfw,
++ };
++ if (values.visibility === 'private' && !req.user) values.visibility = 'unlisted';
++ if (!values.content.trim()) return html(res, pages.newPage({ user: req.user, values, error: 'Paste something first — the content is empty.' }), 400);
++ try {
++ const out = await live.createPaste({
++ title: values.title, content: values.content, language: values.language, visibility: values.visibility,
++ burn_after_read: values.burn_after_read, is_nsfw: values.is_nsfw,
++ }, ctxOf(req));
++ const slug = (out && (out.slug || (out.paste && out.paste.slug))) || null;
++ if (!slug) throw new Error('Paste service returned no slug');
++ catalog.reset();
++ return res.redirect(303, `/p/${encodeURIComponent(slug)}`);
++ } catch (err) {
++ const msg = (err.body && err.body.error) || (err.status ? `The paste service said no (${err.status}).` : 'The paste service is unavailable right now — try again in a moment.');
++ return html(res, pages.newPage({ user: req.user, values, error: msg }), err.status && err.status < 500 ? err.status : 502);
++ }
++ }));
++
++ app.get('/my', withUser, wrap(async (req, res) => {
++ if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
++ let pastes = [], total = 0, error = null;
++ try {
++ const out = await live.listByUser(req.user.username, { limit: 100 }, ctxOf(req));
++ pastes = (out && out.pastes) || [];
++ total = Number(out && out.total) || pastes.length;
++ } catch (err) {
++ if (err.status !== 404) error = 'Your pastes could not be loaded right now.';
++ }
++ html(res, pages.myPage({ user: req.user, pastes, total, error }));
++ }));
++
++ // ── 404 / errors ─────────────────────────────────────────
++ app.use((req, res) => {
++ if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
++ html(res, pages.errorPage({ status: 404, title: 'Page not found', message: 'Nothing lives at that address.' }), 404);
++ });
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ const upstream = err && err.name === 'LiveApiError';
++ if (!upstream) console.error('[App]', err && err.stack ? err.stack : err);
++ else console.warn('[App] upstream:', err.status, err.message);
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) return res.status(upstream ? 502 : 500).json({ error: upstream ? 'Paste service unavailable' : 'Internal error' });
++ html(res, pages.errorPage({
++ status: upstream ? 502 : 500,
++ title: upstream ? 'The paste service is taking a break' : 'Something went wrong',
++ message: upstream ? 'OpenVibe.Live did not answer. Pastes come back the moment it does — try again in a minute.' : 'This one is on us. Please try again.',
++ }), upstream ? 502 : 500);
++ });
++
++ return app;
++}
++
++/** Async route wrapper — rejections reach the error handler. */
++function wrap(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); }
++
++module.exports = { createApp };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..e856634
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * OpenVibe.Community — process entry. `node server/index.js`
++ * Listens on PORT (4200) behind nginx; see deploy/ for the unit and vhost.
++ */
++const config = require('./config');
++const { createApp } = require('./app');
++
++const app = createApp();
++const server = app.listen(config.port, config.host, () => {
++ console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
++ console.log(`[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
++});
++server.keepAliveTimeout = 65_000;
++
++function shutdown(signal) {
++ console.log(`[Community] ${signal} — closing`);
++ server.close(() => process.exit(0));
++ setTimeout(() => process.exit(0), 5000).unref();
++}
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++process.on('SIGINT', () => shutdown('SIGINT'));
ddiff --git a/server/live-client.js b/server/live-client.js
nnew file mode 100644
iindex 0000000..c1082ed
---- /dev/null
++++ b/server/live-client.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * OpenVibe.Live client — server-side reads of the paste API for rendered pages.
++ *
++ * Everything paste-shaped goes through Live's /api/pastes/* (see config.liveInternalUrl for
++ * why). The visitor's Network JWT and real client address are forwarded on every call, so a
++ * signed-in visitor sees their own private/unlisted pastes and Live's per-address rate limits
++ * and view counting see the visitor, not this proxy.
++ */
++const config = require('./config');
++
++class LiveApiError extends Error {
++ constructor(status, body) {
++ super((body && body.error) || `Live API ${status}`);
++ this.name = 'LiveApiError';
++ this.status = status;
++ this.body = body;
++ }
++}
++
++function _qs(query) {
++ const params = new URLSearchParams();
++ for (const [k, v] of Object.entries(query || {})) {
++ if (v === undefined || v === null || v === '') continue;
++ params.set(k, String(v));
++ }
++ const s = params.toString();
++ return s ? `?${s}` : '';
++}
++
++/**
++ * @param {string} apiPath e.g. '/pastes?limit=10' (relative to /api)
++ * @param {object} [opts] { method, query, body, token, ip, timeoutMs }
++ */
++async function request(apiPath, { method = 'GET', query, body, token, ip, timeoutMs = 10_000 } = {}) {
++ const url = `${config.liveInternalUrl}/api${apiPath}${_qs(query)}`;
++ const headers = { Accept: 'application/json' };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ if (ip) headers['X-Forwarded-For'] = ip;
++ const opts = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
++ if (body !== undefined) {
++ headers['Content-Type'] = 'application/json';
++ opts.body = JSON.stringify(body);
++ }
++ let res, text;
++ try {
++ res = await fetch(url, opts);
++ text = await res.text();
++ } catch (err) {
++ // Unreachable / timed out: surface as an upstream error so pages show the outage
++ // message instead of a generic 500.
++ throw new LiveApiError(503, { error: err.name === 'TimeoutError' ? 'Paste service timed out' : 'Paste service unreachable' });
++ }
++ let data = null;
++ try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
++ if (!res.ok) throw new LiveApiError(res.status, data);
++ return data;
++}
++
++// ── Pastes ───────────────────────────────────────────────────
++/** Public listing: { pastes, total, limit, offset }. */
++function listPastes(query, ctx = {}) {
++ return request('/pastes', { query, token: ctx.token, ip: ctx.ip });
++}
++
++/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */
++async function getPaste(slug, ctx = {}) {
++ const out = await request(`/pastes/${encodeURIComponent(slug)}`, {
++ query: ctx.noView ? { no_view: 1 } : undefined, token: ctx.token, ip: ctx.ip,
++ });
++ return out && out.paste ? out.paste : out;
++}
++
++/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */
++function listByUser(username, query, ctx = {}) {
++ return request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });
++}
++
++/** Create a text paste on the visitor's behalf (no-JS form fallback). */
++function createPaste(body, ctx = {}) {
++ return request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });
++}
++
++// ── Public URL builders (OpenVibe.Media serves the bytes) ────
++function rawUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/raw`; }
++function screenshotUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
++/** Absolute-ize a Media-relative URL (screenshot_url from the API). */
++function mediaPublicUrl(u) {
++ if (!u) return null;
++ if (/^https?:\/\//i.test(u)) return u;
++ return `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`;
++}
++
++module.exports = { LiveApiError, request, listPastes, getPaste, listByUser, createPaste, rawUrl, screenshotUrl, mediaPublicUrl };
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
nnew file mode 100644
iindex 0000000..533d831
---- /dev/null
++++ b/server/pastes/catalog.js
@@@ -0,0 +1,103 @@
++'use strict';
++
++/**
++ * Catalog — the site's view over recent PUBLIC pastes.
++ *
++ * Live's list endpoint (Media behind it) knows newest/oldest, a text search and a per-user
++ * filter; it does not sort by views or filter by language. This keeps the latest window of
++ * public pastes (Media's page cap) in memory for a short while and answers trending,
++ * related, language counts and the views/language browse modes from it. Everything here is
++ * fetched anonymously, so nothing unlisted or private can ever appear on a shared page.
++ */
++const live = require('../live-client');
++
++const WINDOW = 200; // Media's maximum page size
++const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live
++const PER_PAGE = 24;
++
++let _recent = { at: 0, promise: null, pastes: [] };
++
++function _textOnlyPublic(rows) {
++ return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
++}
++
++/** The latest public pastes (cached). Never throws — an unreachable Live yields the stale list or []. */
++async function recent() {
++ const now = Date.now();
++ if (_recent.promise) return _recent.promise;
++ if (now - _recent.at < TTL_MS) return _recent.pastes;
++ _recent.promise = live.listPastes({ limit: WINDOW, offset: 0 })
++ .then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
++ .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });
++ return _recent.promise;
++}
++
++function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0) - (a.likes || 0) || String(b.created_at).localeCompare(String(a.created_at)); }
++function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }
++
++async function latest(n = 12) { return (await recent()).slice().sort(byNewest).slice(0, n); }
++async function trending(n = 8) { return (await recent()).slice().sort(byViews).slice(0, n); }
++
++/** Pastes in the same language first, then the newest, never the paste itself. */
++async function related(paste, n = 6) {
++ const rows = (await recent()).filter((p) => p.slug !== paste.slug);
++ const lang = paste.language && paste.language !== 'text' ? paste.language : null;
++ const same = lang ? rows.filter((p) => p.language === lang) : [];
++ const rest = rows.filter((p) => !same.includes(p));
++ return same.concat(rest).slice(0, n);
++}
++
++/** [{ language, count }] over the recent window, most common first. */
++async function languages() {
++ const counts = new Map();
++ for (const p of await recent()) {
++ const l = p.type === 'screenshot' ? 'image' : (p.language || 'text');
++ counts.set(l, (counts.get(l) || 0) + 1);
++ }
++ return [...counts.entries()].map(([language, count]) => ({ language, count })).sort((a, b) => b.count - a.count || a.language.localeCompare(b.language));
++}
++
++function normalizeBrowseQuery(query = {}) {
++ const q = String(query.q || '').trim().slice(0, 120);
++ const sort = query.sort === 'views' ? 'views' : 'new';
++ const lang = String(query.lang || '').trim().toLowerCase().replace(/[^a-z0-9+#.-]/g, '').slice(0, 32);
++ const page = Math.max(1, Math.min(parseInt(query.page, 10) || 1, 500));
++ return { q, sort, lang, page };
++}
++
++/**
++ * Browse: { q, sort: 'new'|'views', lang, page } → { pastes, total, page, pages, perPage, ... }.
++ * Newest with no language filter pages straight through Live (its total is exact); the views
++ * sort and the language filter are answered from the recent window.
++ */
++async function browse(query = {}, ctx = {}) {
++ const { q, sort, lang, page } = normalizeBrowseQuery(query);
++ const offset = (page - 1) * PER_PAGE;
++ let pastes, total, windowed = false;
++
++ if (sort === 'new' && !lang) {
++ const out = await live.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
++ pastes = _textOnlyPublic(out && out.pastes);
++ total = Number(out && out.total) || pastes.length;
++ } else {
++ let rows;
++ if (q) {
++ const out = await live.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
++ rows = _textOnlyPublic(out && out.pastes);
++ } else {
++ rows = await recent();
++ }
++ if (lang) rows = rows.filter((p) => (lang === 'image' ? p.type === 'screenshot' : (p.language || 'text') === lang));
++ rows = rows.slice().sort(sort === 'views' ? byViews : byNewest);
++ total = rows.length;
++ pastes = rows.slice(offset, offset + PER_PAGE);
++ windowed = true;
++ }
++ const pages = Math.max(1, Math.ceil(total / PER_PAGE));
++ return { pastes, total, page, pages, perPage: PER_PAGE, q, sort, lang, windowed, windowSize: WINDOW };
++}
++
++/** For tests and a hot reload after a write. */
++function reset() { _recent = { at: 0, promise: null, pastes: [] }; }
++
++module.exports = { recent, latest, trending, related, languages, browse, normalizeBrowseQuery, reset, PER_PAGE, WINDOW };
ddiff --git a/server/pastes/proxy.js b/server/pastes/proxy.js
nnew file mode 100644
iindex 0000000..5eac776
---- /dev/null
++++ b/server/pastes/proxy.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++/**
++ * /api/pastes/* → OpenVibe.Live /api/pastes/* (transparent, server-side)
++ *
++ * The browser talks to /api/pastes/* on this site; we proxy to the same API the streaming
++ * site's own paste pages use. This deliberately does NOT call Media's tenant API directly:
++ * the visitor's JWT names the account by its NETWORK id, while the user_id Media stores for
++ * these pastes is Live's own — different numbers for the same person, so a write filed here
++ * straight into Media would land under whichever unrelated Live account holds that number.
++ * Live resolves the visitor against its own accounts before writing, which only Live can do,
++ * and this inherits its author names, anonymous-post limits and permission checks rather
++ * than reimplementing them.
++ *
++ * Mounted BEFORE the body parsers: the request body is streamed upstream byte-for-byte
++ * (JSON, multipart screenshot uploads, anything), with the visitor's token as a Bearer header
++ * and the real client address in X-Forwarded-For.
++ */
++const config = require('../config');
++const { extractToken } = require('../auth/routes');
++
++const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'cookie', 'authorization', 'content-length']);
++const BODYLESS = new Set(['GET', 'HEAD', 'OPTIONS']);
++
++function createPastesProxy(opts = {}) {
++ const upstreamBase = () => `${opts.liveUrl || config.liveInternalUrl}/api/pastes`;
++
++ return async function pastesProxy(req, res) {
++ const target = `${upstreamBase()}${req.url === '/' ? '' : req.url}`;
++ const headers = {};
++ for (const [k, v] of Object.entries(req.headers)) {
++ if (HOP_BY_HOP.has(k) || v == null) continue;
++ headers[k] = Array.isArray(v) ? v.join(', ') : v;
++ }
++ // Forward the visitor's JWT — Live verifies it and maps it to a local account.
++ const userToken = extractToken(req);
++ if (userToken) headers.authorization = `Bearer ${userToken}`;
++ // Live rate-limits, bans and counts views by client address. Without this every visitor
++ // arriving through this site would share one bucket and one identity.
++ if (req.ip) headers['x-forwarded-for'] = req.ip;
++ headers['x-forwarded-host'] = req.get('host') || '';
++ headers.accept = req.get('accept') || 'application/json';
++
++ const fetchOpts = { method: req.method, headers, redirect: 'manual' };
++ if (!BODYLESS.has(req.method)) {
++ if (req.headers['content-length']) headers['content-length'] = req.headers['content-length'];
++ fetchOpts.body = req; // stream the body through untouched
++ fetchOpts.duplex = 'half';
++ }
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), BODYLESS.has(req.method) ? 20_000 : 120_000);
++ fetchOpts.signal = ctrl.signal;
++ const onClientGone = () => { try { ctrl.abort(); } catch { /* */ } };
++ res.on('close', onClientGone);
++
++ try {
++ const upstream = await fetch(target, fetchOpts);
++ res.status(upstream.status);
++ for (const [k, v] of upstream.headers) {
++ if (k === 'content-type' || k === 'cache-control' || k === 'location' || k === 'retry-after'
++ || k === 'ratelimit-limit' || k === 'ratelimit-remaining' || k === 'ratelimit-reset') {
++ res.setHeader(k, v);
++ }
++ }
++ const raw = Buffer.from(await upstream.arrayBuffer());
++ res.send(raw);
++ } catch (err) {
++ if (!res.headersSent) {
++ console.warn('[PasteProxy]', err.name === 'AbortError' ? 'upstream timed out' : err.message);
++ res.status(502).json({ error: 'Could not reach the paste service' });
++ }
++ } finally {
++ clearTimeout(timer);
++ res.off('close', onClientGone);
++ }
++ };
++}
++
++module.exports = { createPastesProxy };
ddiff --git a/server/render/highlight.js b/server/render/highlight.js
nnew file mode 100644
iindex 0000000..93af52c
---- /dev/null
++++ b/server/render/highlight.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++/**
++ * Server-side syntax highlighting (highlight.js, common language set). Rendered into the
++ * HTML so crawlers and no-JS readers get the same page as everyone else. Anything the
++ * highlighter cannot name comes back escaped as plain text — never unhighlighted markup.
++ */
++const hljs = require('highlight.js/lib/common');
++
++const MAX_HIGHLIGHT_BYTES = 200 * 1024; // beyond this the page is served as plain text; the raw file is still there
++
++// Media's language names → highlight.js names (and a few aliases people type).
++const ALIASES = {
++ text: 'plaintext', txt: 'plaintext', plain: 'plaintext', log: 'plaintext',
++ js: 'javascript', node: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
++ py: 'python', sh: 'bash', shell: 'bash', zsh: 'bash', console: 'shell', ps1: 'powershell',
++ yml: 'yaml', md: 'markdown', 'c++': 'cpp', cc: 'cpp', h: 'c', cs: 'csharp', 'c#': 'csharp',
++ rb: 'ruby', rs: 'rust', golang: 'go', kt: 'kotlin', docker: 'dockerfile', html: 'xml', htm: 'xml', vue: 'xml', svg: 'xml',
++ conf: 'ini', toml: 'ini', env: 'ini', make: 'makefile', mk: 'makefile', patch: 'diff',
++};
++
++// What the "language" select offers. `auto` lets Media guess from the content.
++const LANGUAGES = [
++ ['auto', 'Auto-detect'], ['text', 'Plain text'], ['markdown', 'Markdown'],
++ ['javascript', 'JavaScript'], ['typescript', 'TypeScript'], ['json', 'JSON'], ['html', 'HTML'], ['css', 'CSS'], ['scss', 'SCSS'],
++ ['python', 'Python'], ['bash', 'Bash / shell'], ['powershell', 'PowerShell'], ['sql', 'SQL'], ['yaml', 'YAML'], ['ini', 'INI / TOML'],
++ ['go', 'Go'], ['rust', 'Rust'], ['php', 'PHP'], ['java', 'Java'], ['kotlin', 'Kotlin'], ['swift', 'Swift'],
++ ['c', 'C'], ['cpp', 'C++'], ['csharp', 'C#'], ['ruby', 'Ruby'], ['lua', 'Lua'], ['perl', 'Perl'], ['r', 'R'],
++ ['xml', 'XML'], ['dockerfile', 'Dockerfile'], ['makefile', 'Makefile'], ['diff', 'Diff / patch'], ['nginx', 'nginx config'],
++];
++
++function escapeHtml(s) {
++ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++}
++
++function resolveLanguage(name) {
++ const raw = String(name || 'text').trim().toLowerCase();
++ const mapped = ALIASES[raw] || raw;
++ return hljs.getLanguage(mapped) ? mapped : 'plaintext';
++}
++
++/** Human label for a stored language name. */
++function languageLabel(name) {
++ const raw = String(name || 'text').toLowerCase();
++ const known = LANGUAGES.find(([id]) => id === raw);
++ if (known) return known[1];
++ return raw === 'plaintext' ? 'Plain text' : raw;
++}
++
++/**
++ * @returns {{ html: string, language: string, lines: number, highlighted: boolean }}
++ * html is the inner HTML of a <code> element.
++ */
++function highlight(content, language) {
++ const text = String(content || '');
++ const lines = text.length ? text.split('\n').length : 0;
++ const lang = resolveLanguage(language);
++ if (lang === 'plaintext' || Buffer.byteLength(text, 'utf8') > MAX_HIGHLIGHT_BYTES) {
++ return { html: escapeHtml(text), language: lang, lines, highlighted: false };
++ }
++ try {
++ const out = hljs.highlight(text, { language: lang, ignoreIllegals: true });
++ return { html: out.value, language: lang, lines, highlighted: true };
++ } catch {
++ return { html: escapeHtml(text), language: 'plaintext', lines, highlighted: false };
++ }
++}
++
++/** File extension for downloads. */
++function extensionFor(language) {
++ const map = { javascript: 'js', typescript: 'ts', python: 'py', bash: 'sh', shell: 'sh', powershell: 'ps1', markdown: 'md', yaml: 'yml', rust: 'rs', ruby: 'rb', csharp: 'cs', cpp: 'cpp', kotlin: 'kt', dockerfile: 'Dockerfile', makefile: 'Makefile', plaintext: 'txt', text: 'txt', html: 'html', xml: 'xml', json: 'json', css: 'css', scss: 'scss', sql: 'sql', go: 'go', php: 'php', java: 'java', swift: 'swift', c: 'c', lua: 'lua', perl: 'pl', r: 'r', ini: 'ini', diff: 'diff', nginx: 'conf' };
++ return map[String(language || 'text').toLowerCase()] || 'txt';
++}
++
++module.exports = { highlight, escapeHtml, resolveLanguage, languageLabel, extensionFor, LANGUAGES };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..63456b3
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,146 @@
++'use strict';
++
++/**
++ * Page shell — every page on the site is server-rendered through this: full <head> SEO
++ * (title, description, canonical, robots, Open Graph, Twitter card, JSON-LD), the shared
++ * OpenVibe chrome (theme-loader first so there is no flash, navbar.js + footer.js from the
++ * Network), this site's small stylesheet and its progressive script.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const config = require('../config');
++const { escapeHtml } = require('./highlight');
++
++const SITE_NAME = 'OpenVibe.Community';
++const NETWORK_URL = 'https://openvibe.network';
++const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, and soon spaces, threads and submissions. Free speech within the rules.';
++const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
++
++// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
++// every deploy (the same scheme Live uses in server/web/assets.js).
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const _hashes = new Map();
++function assetVersion(rel) {
++ if (_hashes.has(rel)) return _hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('md5').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* */ }
++ if (config.isProduction) _hashes.set(rel, v);
++ return v;
++}
++function asset(rel) { return `/${rel}?v=${assetVersion(rel)}`; }
++
++const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
++
++function jsonLdScript(objects) {
++ const list = (Array.isArray(objects) ? objects : [objects]).filter(Boolean);
++ if (!list.length) return '';
++ // </script> inside a JSON string would end the block early — encode the angle bracket.
++ return list.map((o) => `<script type="application/ld+json">${JSON.stringify(o).replace(/</g, '\\u003c')}</script>`).join('\n');
++}
++
++function navbarInit(opts) {
++ const cfg = {
++ service: 'community',
++ apiBase: NETWORK_URL,
++ links: [
++ { label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
++ { label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
++ ],
++ menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
++ history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${encodeURIComponent(opts.canonicalPath || '/')}`,
++ };
++ return cfg;
++}
++
++function footerInit(opts) {
++ return {
++ service: 'community',
++ variant: opts.footerVariant || 'full',
++ mount: '#ov-footer',
++ brandName: SITE_NAME,
++ tagline: 'Community-run and open source. Free speech within the rules — the people of OpenVibe.',
++ legalBase: config.liveUrl,
++ links: [{
++ heading: 'Community',
++ items: [
++ { name: 'Pastes', url: '/pastes' },
++ { name: 'New paste', url: '/new' },
++ { name: 'My pastes', url: '/my' },
++ { name: 'What is coming', url: '/#coming' },
++ { name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Community' },
++ ],
++ }],
++ };
++}
++
++/**
++ * @param {object} o
++ * title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
++ * ogImage, jsonLd (array), body (main HTML), active ('home'|'pastes'|'new'|'my'),
++ * historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
++ * published/modified (ISO, for article:*), noChrome (error pages during outages)
++ */
++function renderPage(o) {
++ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — the people of OpenVibe`;
++ const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
++ const canonical = abs(o.canonicalPath || '/');
++ const robots = o.robots || 'index,follow';
++ const ogType = o.ogType || 'website';
++ const ogImage = o.ogImage || DEFAULT_OG_IMAGE;
++ const nav = navbarInit(o);
++ const foot = footerInit(o);
++
++ return `<!DOCTYPE html>
++<html lang="en" data-page="${escapeHtml(o.active || 'page')}">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${escapeHtml(title)}</title>
++<meta name="description" content="${escapeHtml(description)}">
++<link rel="canonical" href="${escapeHtml(canonical)}">
++<meta name="robots" content="${escapeHtml(robots)}">
++<meta name="theme-color" content="#0a0f1c">
++<meta property="og:site_name" content="${SITE_NAME}">
++<meta property="og:type" content="${escapeHtml(ogType)}">
++<meta property="og:title" content="${escapeHtml(o.title || SITE_NAME)}">
++<meta property="og:description" content="${escapeHtml(description)}">
++<meta property="og:url" content="${escapeHtml(canonical)}">
++<meta property="og:image" content="${escapeHtml(ogImage)}">
++${o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : ''}
++${o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : ''}
++<meta name="twitter:card" content="${o.ogImage ? 'summary_large_image' : 'summary'}">
++<meta name="twitter:title" content="${escapeHtml(o.title || SITE_NAME)}">
++<meta name="twitter:description" content="${escapeHtml(description)}">
++<meta name="twitter:image" content="${escapeHtml(ogImage)}">
++<link rel="icon" href="/favicon.svg" type="image/svg+xml">
++<link rel="alternate" type="application/rss+xml" title="${SITE_NAME} — latest pastes" href="/feed.xml">
++<script src="${NETWORK_URL}/shared/theme-loader.js"></script>
++<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">
++${jsonLdScript(o.jsonLd)}
++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
++<script src="${NETWORK_URL}/shared/footer.js" defer></script>
++<script src="${asset('js/community.js')}" defer></script>
++</head>
++<body class="${escapeHtml(o.bodyClass || '')}">
++<div id="navbar-mount"></div>
++<main id="main" class="page">
++${o.body || ''}
++</main>
++<footer id="ov-footer" class="ov-footer"></footer>
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer: foot }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* chrome is optional */ }
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
ddiff --git a/server/render/pages.js b/server/render/pages.js
nnew file mode 100644
iindex 0000000..fa1e570
---- /dev/null
++++ b/server/render/pages.js
@@@ -0,0 +1,379 @@
++'use strict';
++
++/**
++ * Page templates. Plain template strings, every user value escaped, no framework. The markup
++ * is the whole page for crawlers and no-JS readers; public/js/community.js only adds the
++ * comforts (copy, in-place pagination, the create form's upload path).
++ */
++const config = require('../config');
++const live = require('../live-client');
++const seo = require('../seo');
++const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
++const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
++
++const NETWORK_URL = 'https://openvibe.network';
++
++// ── Small helpers ────────────────────────────────────────────
++function timeAgo(v) {
++ const iso = seo.isoDate(v);
++ if (!iso) return '';
++ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
++ if (s < 60) return 'just now';
++ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
++ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
++ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
++ if (s < 86400 * 365) return `${Math.floor(s / (86400 * 30))}mo ago`;
++ return `${Math.floor(s / (86400 * 365))}y ago`;
++}
++function fmtDate(v) {
++ const iso = seo.isoDate(v);
++ if (!iso) return '';
++ return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');
++}
++function timeTag(v) {
++ const iso = seo.isoDate(v);
++ return iso ? `<time datetime="${esc(iso)}" title="${esc(fmtDate(v))}">${esc(timeAgo(v))}</time>` : '';
++}
++function num(n) { return Number(n || 0).toLocaleString('en-US'); }
++function authorName(p) { return p.display_name || p.username || 'Anonymous'; }
++function authorHtml(p, { link = true } = {}) {
++ const name = authorName(p);
++ const initial = esc(name.trim()[0] || '?').toUpperCase();
++ const avatarSrc = p.avatar_url ? (/^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`) : null;
++ const avatar = avatarSrc
++ ? `<img class="avatar" src="${esc(avatarSrc)}" alt="" loading="lazy" width="22" height="22">`
++ : `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;
++ const inner = `${avatar}<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>`;
++}
++function langBadge(p) {
++ if (p.type === 'screenshot') return `<span class="badge badge-lang"><i class="fa-solid fa-image" aria-hidden="true"></i> image</span>`;
++ const l = p.language || 'text';
++ return `<a class="badge badge-lang" href="/pastes?lang=${encodeURIComponent(l)}">${esc(languageLabel(l))}</a>`;
++}
++function pagerHref(state, page) {
++ const q = new URLSearchParams();
++ if (state.q) q.set('q', state.q);
++ if (state.sort && state.sort !== 'new') q.set('sort', state.sort);
++ if (state.lang) q.set('lang', state.lang);
++ if (page > 1) q.set('page', String(page));
++ const s = q.toString();
++ return `/pastes${s ? `?${s}` : ''}`;
++}
++
++// ── Paste card (grid item) ───────────────────────────────────
++function pasteCard(p) {
++ const isShot = p.type === 'screenshot';
++ const preview = isShot
++ ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(live.mediaPublicUrl(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
++ : `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
++ const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
++ return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
++ <a class="card-link" href="/p/${esc(p.slug)}">
++ ${preview}
++ <h3 class="card-title">${flags}${esc(p.title || 'Untitled')}</h3>
++ </a>
++ <div class="card-meta">
++ ${authorHtml(p)}
++ <span class="card-right">${langBadge(p)}<span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)}</span>${timeTag(p.created_at)}</span>
++ </div>
++</article>`;
++}
++function cardGrid(pastes, empty = 'Nothing here yet.') {
++ if (!pastes || !pastes.length) return `<p class="empty">${esc(empty)}</p>`;
++ return `<div class="grid">${pastes.map(pasteCard).join('\n')}</div>`;
++}
++
++// ── Home ─────────────────────────────────────────────────────
++function homePage({ latest, trending, languages, user }) {
++ const body = `
++<section class="hero">
++ <p class="eyebrow">OpenVibe.Community</p>
++ <h1>The people of OpenVibe</h1>
++ <p class="lede">A community-run home for the things the network makes together — starting with pastes. Open source, built in the open, and free speech within the rules.</p>
++ <div class="hero-actions">
++ <a class="btn btn-primary" href="/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> Start a paste</a>
++ <a class="btn" href="/pastes"><i class="fa-solid fa-paste" aria-hidden="true"></i> Browse pastes</a>
++ ${user ? `<a class="btn btn-ghost" href="/my"><i class="fa-solid fa-user" aria-hidden="true"></i> My pastes</a>` : `<a class="btn btn-ghost" href="/auth/login?next=%2F"><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i> Sign in with your OpenVibe account</a>`}
++ </div>
++ <ul class="hero-points">
++ <li><i class="fa-solid fa-people-group" aria-hidden="true"></i> Community-run — moderated by the people using it</li>
++ <li><i class="fa-solid fa-comment" aria-hidden="true"></i> Free speech within the rules — say it, own it</li>
++ <li><i class="fa-brands fa-github" aria-hidden="true"></i> Open source — <a href="https://github.com/OpenVibers/OpenVibe.Community" rel="noopener">read the code</a></li>
++ </ul>
++</section>
++
++<section class="section" 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.')}
++</section>
++
++<section class="section" 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>
++
++<section class="section cta" id="start">
++ <div class="cta-inner">
++ <h2>Start a paste</h2>
++ <p>Code, logs, configs, a screenshot — paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post anonymously.</p>
++ <a class="btn btn-primary" href="/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</a>
++ ${languages && languages.length ? `<p class="lang-cloud">${languages.slice(0, 12).map((l) => `<a href="/pastes?lang=${encodeURIComponent(l.language)}">${esc(languageLabel(l.language))} <small>${l.count}</small></a>`).join(' ')}</p>` : ''}
++ </div>
++</section>
++
++<section class="section coming" id="coming">
++ <h2>What is coming</h2>
++ <div class="coming-grid">
++ <div class="coming-item"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><h3>Spaces</h3><p>Places for the communities around each streamer, game and project — run by the people in them.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-comments" aria-hidden="true"></i><h3>Threads</h3><p>Long-form discussion that outlives a chat scrollback. Forums, the way they should have stayed.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-inbox" aria-hidden="true"></i><h3>Submissions</h3><p>Send in clips, art, ideas and reports; the community reviews and features the best of it.</p></div>
++ </div>
++ <p class="muted">Pastes are live today. The rest is being built in the open — follow along on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a> and <a href="https://discord.gg/M6MuRUaeJj" rel="noopener">Discord</a>.</p>
++</section>`;
++ return renderPage({
++ title: null,
++ description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — and soon spaces, threads and submissions. Free speech within the rules.',
++ canonicalPath: '/',
++ active: 'home',
++ jsonLd: [seo.websiteLd()],
++ body,
++ });
++}
++
++// ── Browse ───────────────────────────────────────────────────
++function browsePage(result, languages) {
++ const { pastes, total, page, pages, q, sort, lang, windowed, windowSize } = result;
++ const titleBits = [];
++ if (q) titleBits.push(`“${q}”`);
++ if (lang) titleBits.push(languageLabel(lang));
++ titleBits.push(sort === 'views' ? 'most viewed' : 'newest');
++ const title = `Pastes — ${titleBits.join(', ')}${page > 1 ? ` (page ${page})` : ''}`;
++ const canonical = pagerHref({ q, sort, lang }, page);
++ const opt = (id, label) => `<option value="${esc(id)}"${id === (lang || '') ? ' selected' : ''}>${esc(label)}</option>`;
++ const langOptions = [opt('', 'All languages')].concat((languages || []).map((l) => opt(l.language, `${languageLabel(l.language)} (${l.count})`)));
++ if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));
++
++ const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">
++ ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
++ <span class="pager-info">Page ${page} of ${pages}</span>
++ ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
++ </nav>` : '';
++
++ const body = `
++<header class="page-head">
++ <h1>Pastes</h1>
++ <p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p>
++</header>
++<form class="filters" method="get" action="/pastes" role="search" data-browse>
++ <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>
++ <label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>
++ <label>Language <select name="lang">${langOptions.join('')}</select></label>
++ <button class="btn" type="submit">Apply</button>
++ ${(q || lang || sort !== 'new') ? `<a class="btn btn-ghost" href="/pastes">Clear</a>` : ''}
++</form>
++<section id="results" data-results>
++ ${cardGrid(pastes, q ? 'No pastes match that search.' : 'No pastes yet.')}
++ ${pager}
++</section>`;
++ return renderPage({
++ title,
++ description: `Browse ${sort === 'views' ? 'the most viewed' : 'the latest'} pastes on OpenVibe.Community${lang ? ` in ${languageLabel(lang)}` : ''} — code, logs and screenshots shared by the people of OpenVibe.`,
++ canonicalPath: canonical,
++ robots: q ? 'noindex,follow' : 'index,follow',
++ active: 'pastes',
++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
++ body,
++ });
++}
++
++// ── Paste page ───────────────────────────────────────────────
++function pasteDescription(p) {
++ if (p.ai_summary) return seo.clean(p.ai_summary, 200);
++ if (p.type === 'screenshot') return seo.clean(p.content || `A screenshot shared by ${authorName(p)} on ${SITE_NAME}.`, 200);
++ return seo.clean(`${p.title || 'Paste'} — ${languageLabel(p.language)} paste by ${authorName(p)}: ${String(p.content || '').slice(0, 200)}`, 200);
++}
++
++function shareLinks(p) {
++ const url = `${config.baseUrl}/p/${p.slug}`;
++ const text = `${p.title || 'A paste'} — ${SITE_NAME}`;
++ const u = encodeURIComponent(url), t = encodeURIComponent(text);
++ return `<div class="share" aria-label="Share">
++ <button type="button" class="btn btn-sm" data-copy-link="${esc(url)}"><i class="fa-solid fa-link" aria-hidden="true"></i> Copy link</button>
++ <a class="btn btn-sm" href="https://twitter.com/intent/tweet?url=${u}&text=${t}" target="_blank" rel="noopener"><i class="fa-brands fa-x-twitter" aria-hidden="true"></i> X</a>
++ <a class="btn btn-sm" href="https://bsky.app/intent/compose?text=${t}%20${u}" target="_blank" rel="noopener"><i class="fa-brands fa-bluesky" aria-hidden="true"></i> Bluesky</a>
++ <a class="btn btn-sm" href="https://www.reddit.com/submit?url=${u}&title=${t}" target="_blank" rel="noopener"><i class="fa-brands fa-reddit-alien" aria-hidden="true"></i> Reddit</a>
++ <a class="btn btn-sm" href="mailto:?subject=${t}&body=${u}"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Email</a>
++ </div>`;
++}
++
++function pastePage({ paste: p, related, user }) {
++ const isShot = p.type === 'screenshot';
++ const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
++ const description = pasteDescription(p);
++ const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;
++ const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
++ const isOwner = !!(user && p.user_id != null && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)));
++ const hl = isShot ? null : highlight(p.content, p.language);
++ const ext = extensionFor(p.language);
++
++ let bodyBlock;
++ if (isShot) {
++ bodyBlock = `<figure class="shot${p.is_nsfw ? ' nsfw' : ''}">
++ <a href="${esc(shot)}" target="_blank" rel="noopener"><img src="${esc(shot)}" alt="${esc(title)}"></a>
++ ${p.content ? `<figcaption>${esc(p.content)}</figcaption>` : ''}
++ </figure>`;
++ } else {
++ const gutter = Array.from({ length: hl.lines }, (_, i) => i + 1).join('\n');
++ bodyBlock = `<div class="code-wrap" data-code>
++ <pre class="gutter" aria-hidden="true">${gutter}</pre>
++ <pre class="code"><code class="hljs language-${esc(hl.language)}" id="paste-content">${hl.html}</code></pre>
++ </div>`;
++ }
++
++ const actions = `<div class="actions">
++ ${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="fa-solid fa-copy" aria-hidden="true"></i> Copy</button>` : ''}
++ ${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
++ <a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
++ ${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
++ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
++ </div>`;
++
++ const body = `
++<article class="paste" itemscope itemtype="https://schema.org/${isShot ? 'ImageObject' : 'Article'}">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/pastes">Pastes</a> › <span aria-current="page">${esc(seo.clean(title, 60))}</span></nav>
++ <header class="paste-head">
++ <h1 itemprop="headline">${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}${esc(title)}</h1>
++ <p class="paste-meta">
++ ${authorHtml(p)}
++ <span class="sep">·</span>${langBadge(p)}
++ <span class="sep">·</span><time datetime="${esc(seo.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
++ <span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>
++ ${!isShot && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
++ ${p.visibility && p.visibility !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${p.visibility === 'private' ? 'lock' : 'eye-slash'}" aria-hidden="true"></i> ${esc(p.visibility)}</span>` : ''}
++ ${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="fa-solid fa-fire" aria-hidden="true"></i> burns after reading</span>` : ''}
++ </p>
++ ${p.ai_summary ? `<p class="summary" itemprop="description"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}
++ ${actions}
++ </header>
++ ${bodyBlock}
++ <footer class="paste-foot">
++ ${shareLinks(p)}
++ <p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
++ </footer>
++</article>
++${related && related.length ? `<section class="section" id="related">
++ <div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
++ ${cardGrid(related)}
++</section>` : ''}`;
++
++ return renderPage({
++ title,
++ description,
++ canonicalPath: `/p/${p.slug}`,
++ robots: indexable ? 'index,follow' : 'noindex,follow',
++ ogType: 'article',
++ ogImage: shot || undefined,
++ published: seo.isoDate(p.created_at) || undefined,
++ modified: seo.isoDate(p.updated_at) || undefined,
++ active: 'pastes',
++ historyType: 'paste',
++ historyTitle: title,
++ footerVariant: 'compact',
++ jsonLd: [
++ seo.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
++ seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
++ ],
++ body,
++ });
++}
++
++// ── New paste ────────────────────────────────────────────────
++function newPage({ user, values = {}, error = null, fork = null }) {
++ const v = { title: '', language: 'auto', content: '', visibility: 'public', ...values };
++ if (fork) { v.title = v.title || `Fork of ${fork.title || 'paste'}`; v.content = v.content || fork.content || ''; v.language = fork.language || v.language; }
++ const langOpts = LANGUAGES.map(([id, label]) => `<option value="${id}"${id === v.language ? ' selected' : ''}>${esc(label)}</option>`).join('');
++ const body = `
++<header class="page-head">
++ <h1>New paste</h1>
++ <p class="muted">${user ? `Posting as <strong>${esc(user.display_name || user.username)}</strong> — your pastes collect under <a href="/my">My pastes</a>.` : `Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together and delete them later.`}</p>
++</header>
++${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
++<form class="paste-form" method="post" action="/new" data-new-paste>
++ <div class="tabs" role="tablist">
++ <button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true"><i class="fa-solid fa-code" aria-hidden="true"></i> Text or code</button>
++ <button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshot</button>
++ </div>
++ <label class="field"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled"></label>
++ <div class="field-row">
++ <label class="field"><span>Language</span><select name="language">${langOpts}</select></label>
++ <label class="field"><span>Visibility</span><select name="visibility">
++ <option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public — listed and searchable</option>
++ <option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Unlisted — link only</option>
++ ${user ? `<option value="private"${v.visibility === 'private' ? ' selected' : ''}>Private — only you</option>` : ''}
++ </select></label>
++ </div>
++ <div data-pane="text">
++ <label class="field"><span>Content</span><textarea name="content" rows="18" spellcheck="false" placeholder="Paste it here…" required>${esc(v.content)}</textarea></label>
++ </div>
++ <div data-pane="image" hidden>
++ <label class="field"><span>Image (PNG, JPEG, WebP or GIF)</span><input type="file" name="screenshot" accept="image/png,image/jpeg,image/webp,image/gif" disabled></label>
++ <label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label>
++ </div>
++ <div class="field-row options">
++ <label class="check"><input type="checkbox" name="burn_after_read" value="1"${v.burn_after_read ? ' checked' : ''}> Burn after reading</label>
++ <label class="check"><input type="checkbox" name="is_nsfw" value="1"${v.is_nsfw ? ' checked' : ''}> Mark as NSFW</label>
++ </div>
++ <div class="form-actions">
++ <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Create paste</button>
++ <span class="muted small">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</span>
++ </div>
++ <p class="form-status" data-status aria-live="polite"></p>
++</form>`;
++ return renderPage({
++ title: 'New paste',
++ description: 'Create a paste on OpenVibe.Community — code, logs, configs or a screenshot, with a link to share anywhere.',
++ canonicalPath: '/new',
++ robots: 'noindex,follow',
++ active: 'new',
++ footerVariant: 'compact',
++ body,
++ });
++}
++
++// ── My pastes ────────────────────────────────────────────────
++function myPage({ user, pastes, total, error }) {
++ const body = `
++<header class="page-head">
++ <h1>My pastes</h1>
++ <p class="muted">${esc(user.display_name || user.username)} · ${num(total || (pastes || []).length)} ${total === 1 ? 'paste' : 'pastes'} · <a href="/new">new paste</a> · <a href="${NETWORK_URL}/my" rel="noopener">account</a></p>
++</header>
++${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
++<section data-results>
++ ${cardGrid(pastes, 'You have not posted anything yet. Your public, unlisted and private pastes all show up here.')}
++</section>`;
++ return renderPage({
++ title: 'My pastes',
++ description: 'Your pastes on OpenVibe.Community.',
++ canonicalPath: '/my',
++ robots: 'noindex,nofollow',
++ active: 'my',
++ footerVariant: 'compact',
++ body,
++ });
++}
++
++// ── Errors ───────────────────────────────────────────────────
++function errorPage({ status = 500, title = 'Something went wrong', message = '', links = true }) {
++ const body = `
++<section class="error">
++ <p class="eyebrow">${status}</p>
++ <h1>${esc(title)}</h1>
++ ${message ? `<p class="lede">${esc(message)}</p>` : ''}
++ ${links ? `<p><a class="btn" href="/">Home</a> <a class="btn" href="/pastes">Browse pastes</a> <a class="btn btn-primary" href="/new">New paste</a></p>` : ''}
++</section>`;
++ return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
++}
++
++module.exports = { homePage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo };
ddiff --git a/server/seo.js b/server/seo.js
nnew file mode 100644
iindex 0000000..c6aa934
---- /dev/null
++++ b/server/seo.js
@@@ -0,0 +1,148 @@
++'use strict';
++
++/**
++ * robots.txt, the dynamic sitemap and the RSS feed of latest pastes. JSON-LD builders live
++ * here too so page templates stay about markup.
++ */
++const config = require('./config');
++const catalog = require('./pastes/catalog');
++const { escapeHtml } = require('./render/highlight');
++const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
++
++const SITEMAP_TTL_MS = 60 * 60 * 1000;
++
++function isoDate(v) {
++ if (!v) return null;
++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++function clean(s, max) {
++ const t = String(s || '').replace(/\s+/g, ' ').trim();
++ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
++}
++
++// ── JSON-LD ──────────────────────────────────────────────────
++function websiteLd() {
++ return {
++ '@context': 'https://schema.org', '@type': 'WebSite',
++ name: SITE_NAME, url: `${config.baseUrl}/`, description: DEFAULT_DESCRIPTION,
++ potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${config.baseUrl}/pastes?q={search_term_string}` }, 'query-input': 'required name=search_term_string' },
++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
++ };
++}
++
++function breadcrumbLd(items) {
++ return {
++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
++ itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),
++ };
++}
++
++function authorLd(paste) {
++ const name = paste.display_name || paste.username;
++ if (!name) return { '@type': 'Person', name: 'Anonymous' };
++ return { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(paste.username || name)}` };
++}
++
++function pasteLd(paste, { description, image }) {
++ const isScreenshot = paste.type === 'screenshot';
++ const url = abs(`/p/${paste.slug}`);
++ const base = {
++ '@context': 'https://schema.org',
++ '@type': isScreenshot ? 'ImageObject' : 'Article',
++ headline: clean(paste.title || 'Untitled', 110),
++ name: clean(paste.title || 'Untitled', 110),
++ description,
++ url,
++ mainEntityOfPage: url,
++ author: authorLd(paste),
++ datePublished: isoDate(paste.created_at) || undefined,
++ dateModified: isoDate(paste.updated_at || paste.created_at) || undefined,
++ publisher: { '@type': 'Organization', name: SITE_NAME, url: `${config.baseUrl}/` },
++ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/ViewAction', userInteractionCount: Number(paste.views) || 0 },
++ };
++ if (isScreenshot) { base.contentUrl = image; base.uploadDate = base.datePublished; }
++ else {
++ base.image = image;
++ base.hasPart = {
++ '@type': 'SoftwareSourceCode',
++ programmingLanguage: paste.language && paste.language !== 'text' ? paste.language : undefined,
++ codeRepository: url,
++ };
++ }
++ return base;
++}
++
++// ── robots.txt ───────────────────────────────────────────────
++function robotsTxt() {
++ return [
++ 'User-agent: *',
++ 'Allow: /',
++ 'Disallow: /api/',
++ 'Disallow: /auth/',
++ 'Disallow: /my',
++ 'Disallow: /new',
++ 'Disallow: /*?sso=',
++ '',
++ `Sitemap: ${config.baseUrl}/sitemap.xml`,
++ '',
++ ].join('\n');
++}
++
++// ── sitemap.xml (cached ~1h) ─────────────────────────────────
++let _sitemap = null, _sitemapAt = 0;
++async function buildSitemap() {
++ const urls = [];
++ const add = (loc, lastmod, changefreq, priority) => urls.push(
++ ` <url><loc>${escapeHtml(abs(loc))}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}<changefreq>${changefreq}</changefreq><priority>${priority}</priority></url>`
++ );
++ add('/', null, 'hourly', '1.0');
++ add('/pastes', null, 'hourly', '0.8');
++ add('/pastes?sort=views', null, 'daily', '0.5');
++ for (const p of await catalog.recent()) {
++ if (Number(p.is_nsfw) || Number(p.burn_after_read)) continue;
++ add(`/p/${p.slug}`, isoDate(p.updated_at || p.created_at), 'weekly', '0.6');
++ }
++ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
++}
++async function sitemapHandler(_req, res) {
++ if (!_sitemap || Date.now() - _sitemapAt > SITEMAP_TTL_MS) {
++ try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }
++ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(503).end(); }
++ }
++ res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(_sitemap);
++}
++
++// ── RSS feed of the latest pastes ────────────────────────────
++async function feedHandler(_req, res) {
++ const items = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw)).map((p) => {
++ const url = abs(`/p/${p.slug}`);
++ const desc = p.type === 'screenshot' ? 'A screenshot shared on OpenVibe.Community.' : clean(p.content || '', 300);
++ return ` <item>
++ <title>${escapeHtml(p.title || 'Untitled')}</title>
++ <link>${escapeHtml(url)}</link>
++ <guid isPermaLink="true">${escapeHtml(url)}</guid>
++ ${isoDate(p.created_at) ? `<pubDate>${new Date(isoDate(p.created_at)).toUTCString()}</pubDate>` : ''}
++ <dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">${escapeHtml(p.display_name || p.username || 'Anonymous')}</dc:creator>
++ <description>${escapeHtml(desc)}</description>
++ </item>`;
++ });
++ const xml = `<?xml version="1.0" encoding="UTF-8"?>
++<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
++ <channel>
++ <title>${SITE_NAME} — latest pastes</title>
++ <link>${escapeHtml(config.baseUrl)}/pastes</link>
++ <atom:link href="${escapeHtml(config.baseUrl)}/feed.xml" rel="self" type="application/rss+xml"/>
++ <description>${escapeHtml(DEFAULT_DESCRIPTION)}</description>
++ <language>en</language>
++${items.join('\n')}
++ </channel>
++</rss>
++`;
++ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(xml);
++}
++
++function resetCaches() { _sitemap = null; _sitemapAt = 0; }
++
++module.exports = { isoDate, clean, websiteLd, breadcrumbLd, pasteLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, resetCaches };