diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ac9cd95..785280f 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -17,6 +17,7 @@ jobs: with: node-version: 22 - run: npm ci + - run: npm audit --omit=dev --audit-level=high - run: node tools/smoke-test.js - run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself @@ -25,6 +26,7 @@ jobs: - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht) + - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 # Repo secrets needed (Settings -> Actions -> Secrets): @@ -48,7 +50,7 @@ jobs: - uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index cd35dc8..cb07fe5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,12 @@ FROM node:22-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev -COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js signal.js lyrics.js ./ +COPY *.js ./ COPY public ./public ENV PORT=8080 DATA_DIR=/data RUN mkdir -p /data && chown node:node /data VOLUME /data EXPOSE 8080 USER node +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 CMD ["node", "server.js"] diff --git a/README.md b/README.md index 89ee0de..bb216b7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. - **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). - **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail, - Calendar, Tasks, Search; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events, + Calendar, Tasks, Search, Links; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events, settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users//`). Limits are enforced by the server, not just hidden. You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin automatically on first start (the original files are left in place as a backup). @@ -64,6 +64,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume (a Docker *volume*, not a host folder: the bridge runs as user 1000 and a root-owned host folder gives "permission denied"; if you do want a folder, `chown -R 1000:1000` it first): whoever gets into your server can read your chats, so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is not connected for about a month; the hub stays connected, so this only matters if the stack is switched off. +- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted. +- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in @@ -97,7 +99,7 @@ iOS keeps a separate sign-in for the home-screen app, so sign in once inside it `.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to `git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag), and the webmail container from `cypht/` (the official Cypht plus the hub's settings) to -`git.necloud.us/nate/hub-cypht`. amd64 (x86) only. +`git.necloud.us/nate/hub-cypht`. The hub image is amd64 and arm64; the webmail and lyrics images are amd64 only. One-time setup in Gitea: create an access token with the **write:package** scope, then add it to the repo under Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker. diff --git a/atomic.js b/atomic.js new file mode 100644 index 0000000..0bedd34 --- /dev/null +++ b/atomic.js @@ -0,0 +1,8 @@ +// Write a file so a crash or power cut never leaves half of it: write to a temp file, then rename over the original. +const fs = require('fs'); +const crypto = require('crypto'); +module.exports = function writeFileAtomic(file, data, opts = { mode: 0o600 }) { + const tmp = file + '.' + crypto.randomBytes(4).toString('hex') + '.tmp'; + try { fs.writeFileSync(tmp, data, opts); fs.renameSync(tmp, file); } + catch (e) { try { fs.unlinkSync(tmp); } catch {} throw e; } +}; diff --git a/auth.js b/auth.js index 48cd8ff..30ac49f 100644 --- a/auth.js +++ b/auth.js @@ -3,6 +3,7 @@ // Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices. const crypto = require('crypto'); const fs = require('fs'); +const atomicWrite = require('./atomic'); const path = require('path'); const { generateRegistrationOptions, verifyRegistrationResponse, @@ -13,7 +14,7 @@ const users = require('./users'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } }; -const writeJson = (f, v) => fs.writeFileSync(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 }); +const writeJson = (f, v) => atomicWrite(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 }); // Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts. let secret = process.env.COOKIE_SECRET; diff --git a/backup.js b/backup.js new file mode 100644 index 0000000..4cef0f4 --- /dev/null +++ b/backup.js @@ -0,0 +1,17 @@ +// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz. +// Treat the file like a password: it contains the session secret and encrypted sign-in tokens. +const { spawn } = require('child_process'); +const path = require('path'); +const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); +function handle(req, res) { + if (req.method !== 'GET') { res.writeHead(405); return res.end(); } + const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz'; + const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); + tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); }); + tar.stderr.on('data', d => console.warn('[backup]', String(d).trim())); + res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' }); + tar.stdout.pipe(res); + res.on('close', () => tar.kill()); + console.log('[backup] downloaded'); +} +module.exports = { handle }; diff --git a/cal.js b/cal.js index b84fc71..1b53ece 100644 --- a/cal.js +++ b/cal.js @@ -1,6 +1,7 @@ // Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars). // Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window. const fs = require('fs'); +const atomicWrite = require('./atomic'); const path = require('path'); const dns = require('dns').promises; const net = require('net'); @@ -12,7 +13,7 @@ const users = require('./users'); const here = f => path.join(users.dir(), f); // each person's own files const FILE_NAME = 'calendars.json'; const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } }; -const save = v => fs.writeFileSync(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 }); +const save = v => atomicWrite(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 }); // ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses. const isPrivate = ip => { @@ -224,7 +225,7 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD // ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed. const EV_FILE = 'events.json', FEED_FILE = 'feed.json'; const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; -const writeJ = (f, v) => fs.writeFileSync(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); +const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); const loadLocal = () => readJ(here(EV_FILE), []); const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' }; function feedToken(reset) { diff --git a/caldav.js b/caldav.js index 7b15a19..fcb8bc1 100644 --- a/caldav.js +++ b/caldav.js @@ -2,6 +2,7 @@ // Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. // Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted. const fs = require('fs'); +const atomicWrite = require('./atomic'); const path = require('path'); const crypto = require('crypto'); const auth = require('./auth'); @@ -18,7 +19,7 @@ const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipher const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); }; function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } } -function save(o) { fs.writeFileSync(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); } +function save(o) { atomicWrite(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); } function wipe() { try { fs.unlinkSync(file()); } catch {} } // ---- HTTP + XML diff --git a/lyrics.js b/lyrics.js index 85836f1..7e8d56c 100644 --- a/lyrics.js +++ b/lyrics.js @@ -2,6 +2,7 @@ // The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording, // and keeps what it finds on disk so a song is looked up once. const fs = require('fs'); +const atomicWrite = require('./atomic'); const path = require('path'); const crypto = require('crypto'); @@ -76,7 +77,7 @@ async function lookup(artist, title, seconds) { const d = await r.json().catch(() => null); if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 }); const result = shape(d, seconds); - try { fs.writeFileSync(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } + try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } return result; })().finally(() => inflight.delete(k)); inflight.set(k, p); return p; diff --git a/package.json b/package.json index 28617e3..a957dfe 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "for t in smoke users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done", + "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], "author": "", @@ -13,4 +15,4 @@ "dependencies": { "@simplewebauthn/server": "^14.0.3" } -} \ No newline at end of file +} diff --git a/public/app.js b/public/app.js index 2d46a4f..a774e90 100644 --- a/public/app.js +++ b/public/app.js @@ -10,7 +10,7 @@ const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: '', book: '', mic: '', + plus: '', + link: '', check: '', trash: '', }; @@ -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')) : ''} ${allowed('mail') ? `

Mail

@@ -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 = `

Links

+
+ ${all.length ? '' : '

No links yet. Add the sites you use most; they are saved on your server and show up on every device.

'} + ${all.length && !shown.length ? '

Nothing matches.

' : ''} + ${groups.map(g => `${g ? `

${esc(g)}

` : (groups.length > 1 ? '

Other

' : '')}`).join('')}
`; + $('#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', '', `
+ + ${groups.map(g => ` +
${link ? '' : ''}
`); + 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 = '

Tasks

Loading…

'; @@ -1621,7 +1666,8 @@ function viewSettings() { ${ME ? `

Signed in as

${esc(ME.name)}${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''} ${isAdmin ? `

People

Loading…
-

Storage

Checking…
` : ''} +

Storage

Checking…
+

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.

` : ''}

Passkeys

Loading…

Connections

diff --git a/server.js b/server.js index 86318a4..ab7d39e 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ // (Seafile sends no CORS headers, so the browser talks to /sf/* instead). const http = require('http'); const fs = require('fs'); +const atomicWrite = require('./atomic'); const crypto = require('crypto'); const path = require('path'); const { Readable } = require('stream'); @@ -35,6 +36,7 @@ const settings = require('./settings'); const search = require('./search'); const users = require('./users'); const admin = require('./admin'); +const backup = require('./backup'); const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL) const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL) const vault = require('./vault')('nehub-plex'); @@ -198,7 +200,7 @@ const PLEX_HEADERS = { // Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device. const plexFile = () => require('path').join(users.dir(), 'plex.json'); const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } }; -const plexSave = o => require('fs').writeFileSync(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); +const plexSave = o => atomicWrite(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} }; const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this) const plexTv = async (path, token, opts = {}) => { @@ -438,7 +440,8 @@ async function ddgAnswer(req, res) { } catch { json(res, 200, {}); } // a failed lookup must never break Search } -if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.'); +if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing is switched off (the save callback would be unauthenticated). Set it, or ALLOW_UNSIGNED_OO=1 for testing only.'); +const OO_OK = !!JWT_SECRET || process.env.ALLOW_UNSIGNED_OO === '1'; // Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all). const ACCESS = [ @@ -455,8 +458,29 @@ const ACCESS = [ ]; const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); }; +// Headers for every response. No script/style rules on purpose (the app uses inline handlers); these stop framing by other sites, +// content sniffing, form hijacking and leaking the address to other sites. +function securityHeaders(res) { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'same-origin'); + res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'"); + res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()'); + if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000'); +} +/** Cross-site request check: a browser request that changes something must come from this same site. */ +function crossSite(req) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return false; + const sfs = req.headers['sec-fetch-site']; + if (sfs) return !['same-origin', 'none'].includes(sfs); + const o = req.headers.origin; if (!o) return false; // not a browser form/fetch (scripts, tests, OnlyOffice) + try { const h = new URL(o).host; return ![req.headers['x-forwarded-host'], req.headers.host, PUBLIC_URL && new URL(PUBLIC_URL).host].includes(h); } catch { return true; } +} + async function handleRequest(req, res) { const pathname = new URL(req.url, 'http://x').pathname; + securityHeaders(res); + if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); } + if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused'); if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session // Everything else needs a passkey session, except the sign-in page and its assets, @@ -466,7 +490,7 @@ async function handleRequest(req, res) { const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback']; if (!user) { if (open.includes(pathname) || pathname.startsWith('/icons/')) { - if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res); + if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return OO_OK ? ooCallback(req, res) : json(res, 403, { error: 1 }); return serveStatic(req, res); } if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); } @@ -483,9 +507,10 @@ async function handleRequest(req, res) { const c = t => users.can(user, t); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', - podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD })); + podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); } - if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); + if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); + if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/settings') return settings.handle(req, res); diff --git a/settings.js b/settings.js index df76cfb..53d105e 100644 --- a/settings.js +++ b/settings.js @@ -9,7 +9,7 @@ const users = require('./users'); const file = () => path.join(users.dir(), 'settings.json'); // each person has their own // Only these keys are accepted. (The music queue stays in the browser.) -const KEYS = 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 KEYS = 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 SECRET = new Set(['ab_creds', 'sf_token']); const MAX_VALUE = 20000; // bytes of JSON per key diff --git a/tasks.js b/tasks.js index f263188..dcd113b 100644 --- a/tasks.js +++ b/tasks.js @@ -1,6 +1,7 @@ // Tasks: a list stored in NE Hub, plus any iCloud reminder lists the account exposes over CalDAV. // (Apple hides Reminders lists that were upgraded for iOS 13+ from CalDAV, so iCloud lists may be absent; NE Hub lists always work.) const fs = require('fs'); +const atomicWrite = require('./atomic'); const path = require('path'); const crypto = require('crypto'); const ics = require('./ics'); @@ -10,7 +11,7 @@ const caldav = require('./caldav'); const users = require('./users'); const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; -const save = d => fs.writeFileSync(file(), JSON.stringify(d, null, 2), { mode: 0o600 }); +const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 }); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const isLocal = id => String(id || '').startsWith('local:'); diff --git a/tools/smoke-test.js b/tools/smoke-test.js index b4f9ad9..ec6b599 100644 --- a/tools/smoke-test.js +++ b/tools/smoke-test.js @@ -60,6 +60,20 @@ const wait = ms => new Promise(r => setTimeout(r, ms)); ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/)); const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/)); const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('addPersonSheet'), va.status); + // hardening: headers, cross-site writes, health check, backup, links setting + const h1 = await fetch(base + '/login'); await h1.text(); + ok('responses carry security headers', h1.headers.get('x-content-type-options') === 'nosniff' && /frame-ancestors 'self'/.test(h1.headers.get('content-security-policy') || ''), [...h1.headers]); + const hz = await fetch(base + '/healthz'); ok('/healthz answers without signing in', hz.status === 200 && (await hz.json()).ok === true, hz.status); + const cs = await fetch(base + '/api/tasks/lists', { method: 'POST', headers: { cookie, origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' }); await cs.text(); + ok('a write coming from another website is refused', cs.status === 403, cs.status); + const cs2 = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, 'sec-fetch-site': 'cross-site', 'content-type': 'application/json' }, body: '{"key":"links","value":[]}' }); await cs2.text(); + ok('and so is one the browser marks cross-site', cs2.status === 403, cs2.status); + const sl = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, origin: base, 'content-type': 'application/json' }, body: JSON.stringify({ key: 'links', value: [{ id: 'a', title: 'T', url: 'https://example.com', group: '' }] }) }); await sl.text(); + const gl = await (await fetch(base + '/api/settings', { headers: { cookie } })).json(); + ok('links are saved with the settings and read back', sl.status === 200 && gl.values.links && gl.values.links[0].url === 'https://example.com', [sl.status, gl]); + const bk = await fetch(base + '/api/backup', { headers: { cookie } }); const bb = Buffer.from(await bk.arrayBuffer()); + ok('an admin can download a backup (a gzip file)', bk.status === 200 && bb[0] === 0x1f && bb[1] === 0x8b, [bk.status, bb.length]); + const bn = await fetch(base + '/api/backup'); await bn.text(); ok('but not without signing in', bn.status === 401, bn.status); ok('the server is still running after all of that', alive); child.kill(); fs.rmSync(dir, { recursive: true, force: true }); if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); } diff --git a/users.js b/users.js index 5a3026a..95c7448 100644 --- a/users.js +++ b/users.js @@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users'); fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). -const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search']; +const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; const ALWAYS = ['home', 'settings']; -const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' }; +const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };