Harden the server and add a Links page

Security headers, cross-site write refusal, atomic JSON writes, admin backup download, /healthz and Docker health check, OnlyOffice requires a JWT secret, npm test, CI audit, arm64 hub image. New Links tab for personal bookmarks, saved with per-person settings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:43:34 -04:00
1 parent b0d3aec811
commit 1f8c9493b7
16 files changed
+147 -25

No files matched your search

+51 -5
View File
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
const SYNCED = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -88,6 +88,8 @@ const ICONS = {
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
};
@@ -148,8 +150,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link' };
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)');
@@ -181,7 +183,7 @@ function openMore() {
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, settings: viewSettings, edit: viewEdit };
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit };
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -258,6 +260,7 @@ function viewHome() {
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
${allowed('links') ? tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved') : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
</div>
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
@@ -639,6 +642,48 @@ const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const lab
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
const taskStore = []; // tasks on screen, so a tap can open the right one
// ---- Links: your own bookmarks, grouped, saved with your other settings (private to you, every device)
const safeUrl = s => { let t = String(s || '').trim(); if (!t) return ''; if (!/^[a-z][a-z0-9+.-]*:/i.test(t)) t = 'https://' + t; try { const u = new URL(t); return /^https?:$/.test(u.protocol) ? u.href : ''; } catch { return ''; } };
const linkList = () => { const l = store.get('links', []); return Array.isArray(l) ? l.filter(x => x && safeUrl(x.url)) : []; };
const LINK_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f9d9a', '#c2417a'];
const linkColor = l => LINK_COLORS[[...String(l.title || l.url)].reduce((n, c) => n + c.charCodeAt(0), 0) % LINK_COLORS.length];
let linkQuery = '';
function viewLinks() {
const all = linkList(), q = linkQuery.trim().toLowerCase();
const shown = q ? all.filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(q)) : all;
const groups = [...new Set(shown.map(l => l.group || ''))].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
$('#view').innerHTML = `<div class="wrap"><h1>Links</h1>
<div class="bar"><input type="search" id="lq" placeholder="Filter links" value="${esc(linkQuery)}" aria-label="Filter links" style="flex:1"><button class="btn" id="ladd">${icon('plus')}Add</button></div>
${all.length ? '' : '<p class="empty">No links yet. Add the sites you use most; they are saved on your server and show up on every device.</p>'}
${all.length && !shown.length ? '<p class="empty">Nothing matches.</p>' : ''}
${groups.map(g => `${g ? `<h2>${esc(g)}</h2>` : (groups.length > 1 ? '<h2>Other</h2>' : '')}<div class="list">${shown.filter(l => (l.group || '') === g).map(l => `
<div class="row"><a class="grow" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;min-width:0">
<span class="ic" style="background:${linkColor(l)};color:#fff;font-weight:700">${esc([...(l.title || l.url)][0].toUpperCase())}</span>
<span class="grow" style="min-width:0"><b>${esc(l.title || l.url)}</b><small style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(new URL(safeUrl(l.url)).host)}</small></span></a>
<button class="btn ghost" data-edit="${esc(l.id)}" aria-label="Edit ${esc(l.title)}" style="min-height:36px">Edit</button></div>`).join('')}</div>`).join('')}</div>`;
$('#lq').oninput = e => { linkQuery = e.target.value; const p = e.target.selectionStart; viewLinks(); const n = $('#lq'); n.focus(); n.setSelectionRange(p, p); };
$('#ladd').onclick = () => linkSheet();
document.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => linkSheet(all.find(l => l.id === b.dataset.edit)));
}
function linkSheet(link) {
const groups = [...new Set(linkList().map(l => l.group).filter(Boolean))];
const el = openSheet(link ? 'Edit link' : 'Add a link', '', `<form id="lf"><label for="lt">Name</label><input type="text" id="lt" required autocomplete="off" value="${esc(link ? link.title : '')}" placeholder="Router">
<label for="lu">Address</label><input type="text" id="lu" required autocomplete="off" autocapitalize="off" inputmode="url" value="${esc(link ? link.url : '')}" placeholder="https://192.168.1.1">
<label for="lg">Group (optional)</label><input type="text" id="lg" list="lgl" autocomplete="off" value="${esc(link ? link.group || '' : '')}" placeholder="Home network"><datalist id="lgl">${groups.map(g => `<option value="${esc(g)}">`).join('')}</datalist>
<div class="bar">${link ? '<button class="btn danger" type="button" id="ld" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="le"></div></form>`);
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
if (link) el.querySelector('#ld').onclick = () => confirmSheet('Delete link?', link.title, 'Delete', () => { store.set('links', linkList().filter(l => l.id !== link.id)); viewLinks(); });
el.querySelector('#lf').onsubmit = e => {
e.preventDefault();
const url = safeUrl(el.querySelector('#lu').value);
if (!url) { el.querySelector('#le').textContent = 'That is not a web address (it must be http or https).'; return; }
const next = { id: link ? link.id : 'l' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), title: el.querySelector('#lt').value.trim().slice(0, 60), url, group: el.querySelector('#lg').value.trim().slice(0, 40) };
const rest = linkList();
store.set('links', link ? rest.map(l => l.id === link.id ? next : l) : [...rest, next]);
closeSheet(); viewLinks();
};
}
async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
@@ -1621,7 +1666,8 @@ function viewSettings() {
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>` : ''}
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<div id="sec-conn"><h2>Connections</h2><div class="list">