From 489ea0aaf0fd7baf917df92bc49eacf7f7bab068 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:18:12 -0400 Subject: [PATCH] Fix: the server crashed on every Search; Search now covers tasks and events and explains itself - Restore ddgAnswer, which an earlier edit deleted: /api/ddg threw a ReferenceError and killed the whole server process whenever Search ran - One failing request can no longer take the server down (errors are logged and answered with a 500) - tools/smoke-test.js starts the real server and calls every endpoint; the Gitea workflow runs it before building the image, so a missing function or a crash stops the build - Search: also searches NE Hub tasks and calendar events; Seafile file search falls back to scanning file names when the search index returns nothing; Plex search falls back to the library's artists/albums; a status line shows what was searched, per-source counts and errors, with sign-in links Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/build.yml | 7 +++ Dockerfile | 2 +- cal.js | 31 +++++++----- public/app.js | 97 ++++++++++++++++++++++++++++---------- search.js | 31 ++++++++++++ server.js | 37 ++++++++++++++- tasks.js | 10 +++- tools/smoke-test.js | 54 +++++++++++++++++++++ 8 files changed, 229 insertions(+), 40 deletions(-) create mode 100644 search.js create mode 100644 tools/smoke-test.js diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index fdb87fa..05dd07e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -12,6 +12,13 @@ jobs: steps: - uses: actions/checkout@v4 + # Start the real server and call every endpoint; a missing function or a crash stops the build here. + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: node tools/smoke-test.js + - uses: docker/setup-buildx-action@v3 # Repo secrets needed (Settings -> Actions -> Secrets): diff --git a/Dockerfile b/Dockerfile index 89625a2..02123a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ 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 ./ +COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js ./ COPY public ./public ENV PORT=8080 DATA_DIR=/data RUN mkdir -p /data && chown node:node /data diff --git a/cal.js b/cal.js index 7259b09..5e71174 100644 --- a/cal.js +++ b/cal.js @@ -247,6 +247,22 @@ const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; +/** Every event in the window from all calendars (ICS links, iCloud, NE Hub): { events, errors } */ +async function collectEvents(win) { + const all = [], errors = []; + await Promise.all(load().map(async c => { + try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); } + catch (e) { errors.push(`${c.name}: ${e.message}`); } + })); + try { + for (const r of await caldav.listRaw(win.from, win.to)) + for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag }); + } catch (e) { errors.push('iCloud: ' + e.message); } + try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); } + catch (e) { errors.push('NE Hub: ' + e.message); } + return { events: all, errors }; +} + async function handle(req, res) { const u = new URL(req.url, 'http://x'); try { @@ -269,18 +285,7 @@ async function handle(req, res) { const q = u.searchParams; const win = { from: +q.get('from'), to: +q.get('to'), fd: q.get('fd'), td: q.get('td') }; if (!win.from || !win.to || !win.fd || !win.td) throw new Error('from/to required'); - const all = [], errors = []; - await Promise.all(load().map(async c => { - try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); } - catch (e) { errors.push(`${c.name}: ${e.message}`); } - })); - try { - for (const r of await caldav.listRaw(win.from, win.to)) - for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag }); - } catch (e) { errors.push('iCloud: ' + e.message); } - try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); } - catch (e) { errors.push('NE Hub: ' + e.message); } - return json(res, 200, { events: all, errors }); + return json(res, 200, await collectEvents(win)); } if (u.pathname === '/api/cal/event' && req.method === 'GET') { const q = u.searchParams, tz = q.get('tz') || 'UTC'; @@ -339,4 +344,4 @@ async function handle(req, res) { } catch (e) { json(res, 400, { error: e.message }); } } -module.exports = { unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; +module.exports = { collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/public/app.js b/public/app.js index 3445d85..9e78751 100644 --- a/public/app.js +++ b/public/app.js @@ -221,59 +221,108 @@ async function walkSearch(repos, term) { } // ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others) +// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library. +async function searchFiles(term, libs) { + const enc = encodeURIComponent, lower = term.toLowerCase(); + let results = [], via = 'index'; + try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {} + if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; } + return { results, via, scanned: libs.filter(l => !l.encrypted).length }; +} + +// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly. +async function searchMusic(term) { + const enc = encodeURIComponent, lower = term.toLowerCase(), items = []; + try { + const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`); + for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m); + } catch {} + if (!items.length) { + const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort'; + const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]); + for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' }); + for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' }); + } + return items.slice(0, 16); +} + function viewSearch() { - $('#view').innerHTML = `

Search

+ $('#view').innerHTML = ``; const q = $('#q'); q.focus(); - let timer, seq = 0; + let timer, seq = 0, plexSt = null; + // One line that says what Search covers and what each source returned, with a link to sign in where a source needs it. + const showSources = (r = {}) => { + const el = $('#src'); if (!el) return; + const link = (href, t) => `${t}`, bad = t => `${esc(t)}`; + const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`; + const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓'; + const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓'; + el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`; + }; + jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources()); + showSources(); // Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab. const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); }; q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); }; async function runSearch(term, mySeq) { const res = $('#res'); - if (term.length < 2) { res.innerHTML = ''; return; } + if (term.length < 2) { res.innerHTML = ''; showSources(); return; } res.innerHTML = '

Searching…

'; - const enc = encodeURIComponent; - const [files, libs, music, ddg] = await Promise.allSettled([ - sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')), - sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')), - jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))), + const enc = encodeURIComponent, lower = term.toLowerCase(); + const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server); + const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')); + const [libs, files, music, ddg, hub] = await Promise.allSettled([ + libsP, + libsP.then(l => searchFiles(term, l)), + plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')), jfetch('/api/ddg?q=' + enc(term)), + jfetch('/api/search?q=' + enc(term)), ]); if (mySeq !== seq || !location.hash.startsWith('#/search')) return; const sec = (title, html) => html ? `

${title}

${html}
` : ''; - const lower = term.toLowerCase(); const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r => `${icon('files')}${esc(r.name)}Library`).join('') : ''; - let found = files.status === 'fulfilled' ? files.value.results || [] : null; - if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead - if (mySeq !== seq || !location.hash.startsWith('#/search')) return; - const fileHtml = found ? found.map(f => { + const found = files.status === 'fulfilled' ? files.value.results : []; + const fileHtml = found.map(f => { const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/'; return f.is_dir ? `${icon('files')}${esc(f.name)}${esc(f.repo_name || '')} ${esc(f.fullpath)}` : ``; - }).join('') : ''; + }).join(''); let musicHtml = ''; - if (music.status === 'fulfilled') { - for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) { - if (m.type === 'artist') musicHtml += `${esc(m.title)}Artist`; - else if (m.type === 'album') musicHtml += `${esc(m.title)}Album · ${esc(m.parentTitle || '')}`; - else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += ``; } - } + for (const m of music.status === 'fulfilled' ? music.value : []) { + if (m.type === 'artist') musicHtml += `${esc(m.title)}Artist`; + else if (m.type === 'album') musicHtml += `${esc(m.title)}Album · ${esc(m.parentTitle || '')}`; + else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += ``; } } - const note = !found && sf.token() ? '

Could not search Seafile files right now.

' : ''; const d = ddg.status === 'fulfilled' ? ddg.value : {}; const card = (d.text || d.answer) ? `
${d.image ? `` : ''}
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''} ${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''} ${d.text ? `

${esc(d.text)}

` : ''}${d.url ? `Read more` : ''}
` : ''; - res.innerHTML = card + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note; - if (!libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '

No results

' + note; - res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); }); + const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] }; + evStore.length = 0; + const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due); + return ``; }).join(''); + const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start); + return ``; }).join(''); + // what each source did, so an empty screen always has an explanation + showSources({ + tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message }, + events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message }, + files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' }, + music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message }, + }); + const notes = [...(h.errors || [])].map(e => `

${esc(e)}

`).join(''); + res.innerHTML = card + sec('Tasks', taskHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes; + if (!taskHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; + res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); }); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); + res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; }); } } diff --git a/search.js b/search.js new file mode 100644 index 0000000..ba1a9b7 --- /dev/null +++ b/search.js @@ -0,0 +1,31 @@ +// Search over what lives in NE Hub itself (tasks and calendar events). Files and music are searched from the browser, +// because those need the person's own Seafile / Plex sign-in. +const cal = require('./cal'); +const tasks = require('./tasks'); + +const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; +const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +async function handle(req, res) { + const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().toLowerCase().slice(0, 100); + if (q.length < 2) return json(res, 200, { tasks: [], events: [], errors: [] }); + const errors = []; + let foundTasks = [], foundEvents = []; + try { + foundTasks = (await tasks.allTasks()).filter(t => (t.title + ' ' + t.notes).toLowerCase().includes(q)) + .sort((a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999')).slice(0, 8); + } catch (e) { errors.push('Tasks: ' + e.message); } + try { // two months back to just over a year ahead + const now = new Date(), from = new Date(now); from.setDate(from.getDate() - 60); from.setHours(0, 0, 0, 0); + const to = new Date(now); to.setDate(to.getDate() + 400); to.setHours(23, 59, 59, 999); + const r = await cal.collectEvents({ from: from.getTime(), to: to.getTime(), fd: ymd(from), td: ymd(to) }); + errors.push(...r.errors); + const when = e => e.allDay ? Date.parse(e.start + 'T00:00:00') : e.start, t0 = Date.now() - 864e5; + foundEvents = r.events.filter(e => (e.title + ' ' + (e.location || '')).toLowerCase().includes(q)) + .sort((a, b) => { const ua = when(a) >= t0, ub = when(b) >= t0; return ua === ub ? (ua ? when(a) - when(b) : when(b) - when(a)) : ua ? -1 : 1; }) // upcoming first (soonest), then recent past + .slice(0, 8); + } catch (e) { errors.push('Calendar: ' + e.message); } + json(res, 200, { tasks: foundTasks, events: foundEvents, errors }); +} + +module.exports = { handle }; diff --git a/server.js b/server.js index b899b29..cf3ceb5 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,7 @@ const auth = require('./auth'); const cal = require('./cal'); const tasks = require('./tasks'); const settings = require('./settings'); +const search = require('./search'); const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/'; const PUBLIC = path.join(__dirname, 'public'); @@ -290,9 +291,31 @@ const STARTED = Date.now(); if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.'); if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); } +// ---- DuckDuckGo instant answers for the Search tab (a short summary when DDG has one; many queries return nothing) +const ddgCache = new Map(); +async function ddgAnswer(req, res) { + const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().slice(0, 200); + if (q.length < 2) return json(res, 200, {}); + const hit = ddgCache.get(q.toLowerCase()); + if (hit && Date.now() - hit.t < 10 * 60e3) return json(res, 200, hit.v); + try { + const u = new URL('https://api.duckduckgo.com/'); + u.search = new URLSearchParams({ q, format: 'json', no_html: '1', skip_disambig: '1', t: 'nehub' }); + const r = await fetch(u, { signal: AbortSignal.timeout(6000), headers: { Accept: 'application/json' } }); + const d = await r.json(); + const v = {}; + if (d.AbstractText) Object.assign(v, { heading: d.Heading, text: d.AbstractText.slice(0, 600), source: d.AbstractSource, url: d.AbstractURL, image: d.Image ? 'https://duckduckgo.com' + d.Image : '' }); + else if (d.Definition) Object.assign(v, { heading: d.Heading || q, text: d.Definition.slice(0, 600), source: d.DefinitionSource, url: d.DefinitionURL }); + if (d.Answer && typeof d.Answer === 'string') v.answer = d.Answer.slice(0, 200); + if (ddgCache.size > 200) ddgCache.clear(); + ddgCache.set(q.toLowerCase(), { t: Date.now(), v }); + json(res, 200, v); + } 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.'); -http.createServer((req, res) => { +async function handleRequest(req, res) { const pathname = new URL(req.url, 'http://x').pathname; 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 @@ -313,10 +336,22 @@ http.createServer((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/settings') return settings.handle(req, res); + if (pathname === '/api/search') return search.handle(req, res); if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res); if (req.url.startsWith('/api/cal/')) return cal.handle(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res); serveStatic(req, res); +} + +// A bug or bad input in one request must never take the whole server down: log it and answer that request with an error. +http.createServer(async (req, res) => { + try { await handleRequest(req, res); } + catch (e) { + console.error('[error]', req.method, req.url.split('?')[0], e && e.stack || e); + if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Server error: ' + (e && e.message || 'unknown') })); } else res.destroy(); + } }).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`)); +process.on('unhandledRejection', e => console.error('[unhandledRejection]', e && e.stack || e)); +process.on('uncaughtException', e => console.error('[uncaughtException]', e && e.stack || e)); diff --git a/tasks.js b/tasks.js index 5721a9e..f38c7bf 100644 --- a/tasks.js +++ b/tasks.js @@ -119,4 +119,12 @@ async function handle(req, res) { } catch (e) { json(res, 400, { error: e.message }); } } -module.exports = { handle, parseTodos }; +/** Every task from every list (NE Hub and iCloud), with its list name. */ +async function allTasks() { + const d = load(), out = []; + for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }); } + for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} } + return out; +} + +module.exports = { handle, parseTodos, allTasks }; diff --git a/tools/smoke-test.js b/tools/smoke-test.js new file mode 100644 index 0000000..d70647e --- /dev/null +++ b/tools/smoke-test.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Starts the real server on a spare port with a temporary data folder and calls every endpoint while signed in. +// Fails if any request gets a 500 (502/504 for an unreachable Seafile/Plex is a normal answer), if the process dies, or if a route points at a function that does not exist. +// Run: node tools/smoke-test.js (also run by the Gitea workflow before the container is built) +const { spawn } = require('child_process'); +const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); + +const root = path.join(__dirname, '..'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-smoke-')); +const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`; +let failed = 0; +const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + extra)); if (!cond) failed++; }; + +// 1) every function the router calls must exist +const src = fs.readFileSync(path.join(root, 'server.js'), 'utf8'); +const targets = [...new Set([...src.matchAll(/return (\w+)\(req, res\)/g)].map(m => m[1]))]; +const missing = targets.filter(n => !new RegExp(`(async )?function ${n}\\b|const ${n} =`).test(src)); +ok('every function the router calls is defined', !missing.length, missing); + +const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: 'smoke-'.repeat(8), HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } }); +let alive = true, log = ''; child.on('exit', () => { alive = false; }); child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d); +const wait = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } } + const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', 'smoke-'.repeat(8)).update('s' + exp).digest('base64url'); + const cookie = `hub_session=${exp}.${sig}`; + const get = async (p, method = 'GET', body) => { try { const r = await fetch(base + p, { method, redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); await r.text(); return r.status; } catch { return -1; } }; + + const pages = ['/', '/login', '/app.js', '/style.css', '/sw.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; + const api = ['/api/config', '/api/system', '/api/settings', '/api/auth/state', '/api/auth/passkeys', '/api/tasks/lists', '/api/tasks?list=local:inbox', '/api/tasks/summary', + '/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status', + `/api/cal/events?from=${Date.now()}&to=${Date.now() + 864e5}&fd=2026-01-01&td=2026-01-02`]; + const bad = []; + for (const p of [...pages, ...api]) { const st = await get(p); if (st === 500 || st === -1 || !alive) bad.push(`${p} -> ${alive ? st : 'server died'}`); } + ok(`${pages.length + api.length} pages and endpoints answer without a server error`, !bad.length, bad); + // a few writes, including bad input, which must produce 4xx and never a crash + const writes = [['/api/tasks', { list: 'local:inbox', fields: { title: 'smoke', notes: '', due: '', done: false } }], ['/api/tasks', { list: 'local:inbox', fields: { title: '' } }], ['/api/tasks', { list: 'nope', fields: {} }], + ['/api/settings', { key: 'cal_view', value: 'month' }, 'PUT'], ['/api/settings', { key: 'evil', value: 1 }, 'PUT'], ['/api/cal/event', { cal: 'local', fields: { title: 'x' } }], ['/api/cal/event', { cal: 'icloud:0000000000', fields: {} }], + ['/api/cal/add', { url: 'http://localhost/x' }], ['/api/cal/icloud/connect', { appleId: '', password: '' }], ['/api/plex/server', { id: 'x' }], ['/api/oo/config', {}], ['/api/plex/pin', {}]]; + const wbad = []; + for (const [p, b, m] of writes) { const st = await get(p, m || 'POST', b); if (st === 500 || st === -1 || !alive) wbad.push(`${m || 'POST'} ${p} -> ${alive ? st : 'server died'}`); } + ok(`${writes.length} writes with good and bad input never crash the server`, !wbad.length, wbad); + // malformed requests + const rawSt = await get('/api/tasks', 'POST', undefined).catch(() => -1); + const raw = await fetch(base + '/api/tasks', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{not json' }).then(async r => { await r.text(); return r; }).catch(() => ({ status: -1 })); + ok('malformed JSON gets a clean error, not a crash', raw.status !== 500 && raw.status !== -1 && alive, raw.status); + ok('signed-out requests are refused', await fetch(base + '/api/tasks/lists').then(r => r.status === 401).catch(() => false)); + 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')); } + console.log(failed ? `\n${failed} check(s) failed` : '\nAll smoke checks passed'); + process.exit(failed ? 1 : 0); +})();