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:
1 parent
b0d3aec811
commit
1f8c9493b7
16 files changed
+147
-25
No files matched your search
@@ -17,6 +17,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
- run: node tools/smoke-test.js
|
- run: node tools/smoke-test.js
|
||||||
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
- 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
|
- 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/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
|
- 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
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
# Repo secrets needed (Settings -> Actions -> Secrets):
|
# Repo secrets needed (Settings -> Actions -> Secrets):
|
||||||
@@ -48,7 +50,7 @@ jobs:
|
|||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
+2
-1
@@ -2,11 +2,12 @@ FROM node:22-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
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
|
COPY public ./public
|
||||||
ENV PORT=8080 DATA_DIR=/data
|
ENV PORT=8080 DATA_DIR=/data
|
||||||
RUN mkdir -p /data && chown node:node /data
|
RUN mkdir -p /data && chown node:node /data
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER node
|
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"]
|
CMD ["node", "server.js"]
|
||||||
@@ -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.
|
- **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).
|
- **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,
|
- **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/<id>/`). Limits are enforced by the server, not just hidden.
|
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). 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
|
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).
|
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,
|
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
|
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.
|
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.
|
- **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.
|
- **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
|
- **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
|
`.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` (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
|
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.
|
Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker.
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
};
|
||||||
@@ -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.
|
// 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 crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {
|
const {
|
||||||
generateRegistrationOptions, verifyRegistrationResponse,
|
generateRegistrationOptions, verifyRegistrationResponse,
|
||||||
@@ -13,7 +14,7 @@ const users = require('./users');
|
|||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
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 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.
|
// Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts.
|
||||||
let secret = process.env.COOKIE_SECRET;
|
let secret = process.env.COOKIE_SECRET;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars).
|
// 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.
|
// Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const dns = require('dns').promises;
|
const dns = require('dns').promises;
|
||||||
const net = require('net');
|
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 here = f => path.join(users.dir(), f); // each person's own files
|
||||||
const FILE_NAME = 'calendars.json';
|
const FILE_NAME = 'calendars.json';
|
||||||
const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } };
|
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.
|
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
|
||||||
const isPrivate = ip => {
|
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.
|
// ---- 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 EV_FILE = 'events.json', FEED_FILE = 'feed.json';
|
||||||
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
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 loadLocal = () => readJ(here(EV_FILE), []);
|
||||||
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
|
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
|
||||||
function feedToken(reset) {
|
function feedToken(reset) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords.
|
// 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.
|
// 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 fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const auth = require('./auth');
|
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));
|
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')); };
|
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 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 {} }
|
function wipe() { try { fs.unlinkSync(file()); } catch {} }
|
||||||
|
|
||||||
// ---- HTTP + XML
|
// ---- HTTP + XML
|
||||||
|
|||||||
@@ -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,
|
// 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.
|
// and keeps what it finds on disk so a song is looked up once.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ async function lookup(artist, title, seconds) {
|
|||||||
const d = await r.json().catch(() => null);
|
const d = await r.json().catch(() => null);
|
||||||
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
|
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
|
||||||
const result = shape(d, seconds);
|
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;
|
return result;
|
||||||
})().finally(() => inflight.delete(k));
|
})().finally(() => inflight.delete(k));
|
||||||
inflight.set(k, p); return p;
|
inflight.set(k, p); return p;
|
||||||
|
|||||||
+4
-2
@@ -4,7 +4,9 @@
|
|||||||
"description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA",
|
"description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -13,4 +15,4 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/server": "^14.0.3"
|
"@simplewebauthn/server": "^14.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+51
-5
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
|||||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
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
|
// 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.
|
// 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;
|
const outbox = new Map(); let outTimer = null, warned = false;
|
||||||
function flushSettings() {
|
function flushSettings() {
|
||||||
outTimer = null;
|
outTimer = null;
|
||||||
@@ -88,6 +88,8 @@ const ICONS = {
|
|||||||
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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' });
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
// ---- Navigation
|
// ---- 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 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' };
|
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 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 PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||||
const phone = matchMedia('(max-width: 767px)');
|
const phone = matchMedia('(max-width: 767px)');
|
||||||
@@ -181,7 +183,7 @@ function openMore() {
|
|||||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
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;
|
let editor;
|
||||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
// 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 {}; } };
|
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('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('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('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') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
|
||||||
</div>
|
</div>
|
||||||
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
|
${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)' }; };
|
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
|
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() {
|
async function viewTasks() {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
|
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>` : ''}
|
${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>
|
${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>
|
<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>
|
<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 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">
|
<div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
|
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { Readable } = require('stream');
|
const { Readable } = require('stream');
|
||||||
@@ -35,6 +36,7 @@ const settings = require('./settings');
|
|||||||
const search = require('./search');
|
const search = require('./search');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const admin = require('./admin');
|
const admin = require('./admin');
|
||||||
|
const backup = require('./backup');
|
||||||
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
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 signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||||
const vault = require('./vault')('nehub-plex');
|
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.
|
// 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 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 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 plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
|
||||||
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
|
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
|
||||||
const plexTv = async (path, token, opts = {}) => {
|
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
|
} 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).
|
// Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all).
|
||||||
const ACCESS = [
|
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 })); };
|
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) {
|
async function handleRequest(req, res) {
|
||||||
const pathname = new URL(req.url, 'http://x').pathname;
|
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('/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
|
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,
|
// 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'];
|
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (open.includes(pathname) || pathname.startsWith('/icons/')) {
|
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);
|
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"}'); }
|
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);
|
const c = t => users.can(user, t);
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
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 : '',
|
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/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/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
||||||
if (pathname === '/api/settings') return settings.handle(req, res);
|
if (pathname === '/api/settings') return settings.handle(req, res);
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ const users = require('./users');
|
|||||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
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.)
|
// 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 SECRET = new Set(['ab_creds', 'sf_token']);
|
||||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Tasks: a list stored in NE Hub, plus any iCloud reminder lists the account exposes over CalDAV.
|
// 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.)
|
// (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 fs = require('fs');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const ics = require('./ics');
|
const ics = require('./ics');
|
||||||
@@ -10,7 +11,7 @@ const caldav = require('./caldav');
|
|||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
|
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 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 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 json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||||
const isLocal = id => String(id || '').startsWith('local:');
|
const isLocal = id => String(id || '').startsWith('local:');
|
||||||
|
|||||||
@@ -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[^"]*/));
|
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 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);
|
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);
|
ok('the server is still running after all of that', alive);
|
||||||
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
|
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')); }
|
if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); }
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
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).
|
// 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 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;
|
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; };
|
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; };
|
||||||
|
|||||||
Reference in new issue
Block a user