openvibers/openvibe.community Commits
Pastes: Live proxy, server-rendered pages with syntax highlighting, SEO
Changes
nnew file mode 100644@@@ -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; }++}nnew file mode 100644@@@ -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>nnew file mode 100644@@@ -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;++ });++ });++ }++})();nnew file mode 100644BBinary files /dev/null and b/public/og-default.png differnnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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'));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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;
+ });
+ });
+ }
+})();
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/og-default.png differ
Binary files /dev/null and b/public/og-default.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,379 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -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 };