Fix: the server crashed on every Search; Search now covers tasks and events and explains itself
build-container / build (push) Successful in 1m55s
build-container / build (push) Successful in 1m55s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea882cafbf
commit
489ea0aaf0
8 files changed
+226
-37
No files matched your search
@@ -12,6 +12,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
# Repo secrets needed (Settings -> Actions -> Secrets):
|
# Repo secrets needed (Settings -> Actions -> Secrets):
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ 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 ./
|
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.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
|
||||||
|
|||||||
@@ -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 json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||||
const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
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) {
|
async function handle(req, res) {
|
||||||
const u = new URL(req.url, 'http://x');
|
const u = new URL(req.url, 'http://x');
|
||||||
try {
|
try {
|
||||||
@@ -269,18 +285,7 @@ async function handle(req, res) {
|
|||||||
const q = u.searchParams;
|
const q = u.searchParams;
|
||||||
const win = { from: +q.get('from'), to: +q.get('to'), fd: q.get('fd'), td: q.get('td') };
|
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');
|
if (!win.from || !win.to || !win.fd || !win.td) throw new Error('from/to required');
|
||||||
const all = [], errors = [];
|
return json(res, 200, await collectEvents(win));
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
|
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
|
||||||
const q = u.searchParams, tz = q.get('tz') || 'UTC';
|
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 }); }
|
} 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 };
|
||||||
+70
-21
@@ -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)
|
// ---- 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() {
|
function viewSearch() {
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Files, libraries, artists, albums, tracks" autocomplete="off" autocapitalize="off">
|
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off">
|
||||||
|
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
||||||
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
||||||
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
||||||
<div id="res"></div></div>`;
|
<div id="res"></div></div>`;
|
||||||
const q = $('#q'); q.focus();
|
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) => `<a href="${href}" style="color:var(--accent)">${t}</a>`, bad = t => `<span style="color:var(--danger)">${esc(t)}</span>`;
|
||||||
|
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.
|
// 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 : ''); };
|
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); };
|
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
|
||||||
async function runSearch(term, mySeq) {
|
async function runSearch(term, mySeq) {
|
||||||
const res = $('#res');
|
const res = $('#res');
|
||||||
if (term.length < 2) { res.innerHTML = ''; return; }
|
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
|
||||||
res.innerHTML = '<p class="empty">Searching…</p>';
|
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||||
const [files, libs, music, ddg] = await Promise.allSettled([
|
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
||||||
sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')),
|
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
||||||
sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')),
|
const [libs, files, music, ddg, hub] = await Promise.allSettled([
|
||||||
jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))),
|
libsP,
|
||||||
|
libsP.then(l => searchFiles(term, l)),
|
||||||
|
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
||||||
jfetch('/api/ddg?q=' + enc(term)),
|
jfetch('/api/ddg?q=' + enc(term)),
|
||||||
|
jfetch('/api/search?q=' + enc(term)),
|
||||||
]);
|
]);
|
||||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||||
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
||||||
const lower = term.toLowerCase();
|
|
||||||
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
|
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
|
||||||
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
|
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
|
||||||
let found = files.status === 'fulfilled' ? files.value.results || [] : null;
|
const found = files.status === 'fulfilled' ? files.value.results : [];
|
||||||
if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead
|
const fileHtml = found.map(f => {
|
||||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
|
||||||
const fileHtml = found ? found.map(f => {
|
|
||||||
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
|
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
|
||||||
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
|
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
|
||||||
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
|
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
|
||||||
}).join('') : '';
|
}).join('');
|
||||||
let musicHtml = '';
|
let musicHtml = '';
|
||||||
if (music.status === 'fulfilled') {
|
for (const m of music.status === 'fulfilled' ? music.value : []) {
|
||||||
for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) {
|
|
||||||
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
|
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
|
||||||
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
|
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
|
||||||
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
|
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const note = !found && sf.token() ? '<p class="sub">Could not search Seafile files right now.</p>' : '';
|
|
||||||
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
||||||
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
||||||
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||||
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
|
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
|
||||||
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
|
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
|
||||||
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
|
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
|
||||||
res.innerHTML = card + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note;
|
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
|
||||||
if (!libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results</p>' + note;
|
evStore.length = 0;
|
||||||
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 taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
|
||||||
|
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).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 `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b><small>${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; }).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 => `<p class="sub">${esc(e)}</p>`).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 = '<p class="empty">No results. The line above shows what was searched.</p>' + 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-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'; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -13,6 +13,7 @@ const auth = require('./auth');
|
|||||||
const cal = require('./cal');
|
const cal = require('./cal');
|
||||||
const tasks = require('./tasks');
|
const tasks = require('./tasks');
|
||||||
const settings = require('./settings');
|
const settings = require('./settings');
|
||||||
|
const search = require('./search');
|
||||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||||
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
|
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
|
||||||
const PUBLIC = path.join(__dirname, 'public');
|
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.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.'); }
|
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.');
|
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;
|
const pathname = new URL(req.url, 'http://x').pathname;
|
||||||
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
|
||||||
@@ -313,10 +336,22 @@ http.createServer((req, res) => {
|
|||||||
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);
|
||||||
|
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/tasks')) return tasks.handle(req, res);
|
||||||
if (req.url.startsWith('/api/cal/')) return cal.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('/api/plex/')) return plexApi(req, res);
|
||||||
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
||||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||||
serveStatic(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})`));
|
}).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));
|
||||||
@@ -119,4 +119,12 @@ async function handle(req, res) {
|
|||||||
} catch (e) { json(res, 400, { error: e.message }); }
|
} 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 };
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user