commit 55e0c3917117711103760bf8ae9b1699048aef96 Author: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue Oct 20 13:22:03 2026 -0400 NE Hub: Seafile, OnlyOffice, Plex, calendar and mail in one PWA - Mobile-first PWA (phone tab bar with More menu, iPad/desktop rail) - Passkey (WebAuthn) sign-in gates the whole app; first passkey via setup code - Files: Seafile browse/upload/new doc/rename/move/share link/preview/delete - In-app OnlyOffice editing with JWT-signed configs and save-back to Seafile - Plex music player via plex.tv sign-in and a server-side proxy - Calendar agenda from private ICS links (recurrence + time zones) - Search, Home dashboard, webmail deep links, audiobooks frame - Dockerfile, compose, and a Gitea Actions workflow that builds the image Co-Authored-By: Claude Sonnet 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..06297a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +data +.env +.git +.gitea +sample.docx +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f99615 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and fill in. Must match the Document Server's JWT secret. +ONLYOFFICE_JWT_SECRET= +# Address the OnlyOffice server can reach this app on (used for save-back callbacks), e.g. https://hub.necloud.us +PUBLIC_URL= +SEAFILE_URL=https://seafile.necloud.us +ONLYOFFICE_URL=https://onlyoffice.necloud.us +# Optional: keeps Plex/session cookies valid across restarts. Any long random string. +COOKIE_SECRET= +AUDIOBOOKS_URL=https://audiobooks.necloud.us +# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists. +HUB_SETUP_CODE= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..48a86c6 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,44 @@ +name: build-container + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + # Repo secrets needed (Settings -> Actions -> Secrets): + # REGISTRY_TOKEN a Gitea access token with write:package scope + # REGISTRY_USER optional; defaults to the user who pushed + - uses: docker/login-action@v3 + with: + registry: git.necloud.us + username: ${{ secrets.REGISTRY_USER || gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: git.necloud.us/nate/hub + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + type=ref,event=tag + + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache + cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ac63ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +data/ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..976319a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY server.js auth.js cal.js ./ +COPY public ./public +ENV PORT=8080 DATA_DIR=/data +RUN mkdir -p /data && chown node:node /data +VOLUME /data +EXPOSE 8080 +USER node +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fa3fbc --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# NE Hub + +One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts. +Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide). + +## Configure + + cp .env.example .env # set ONLYOFFICE_JWT_SECRET (same value as the Document Server) and PUBLIC_URL + +`PUBLIC_URL` must be an address the OnlyOffice server can reach (not localhost): it posts saved edits back there. + +## Run + + docker compose up -d --build # http://localhost:8080 + # or: node server.js + +Put it behind your reverse proxy with HTTPS (needed for "Add to Home Screen" and the service worker), +e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml. + +## What each part does + +- **Files**: signs in with your Seafile account, browses libraries, uploads, creates folders, downloads, deletes. + The server proxies `/sf/*` to Seafile because Seafile sends no CORS headers. Token lives in the browser only. + Office files open in Seafile's own OnlyOffice integration. +- **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. +- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs. +- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap. +- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. +- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose). + Providers forbid framing, and a real unified inbox would need Google/Azure app registrations. + +## Build and deploy with Gitea + +`.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). Multi-arch: amd64 + arm64. + +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. + +On the server: + + cp .env.example .env # fill in secrets, PUBLIC_URL, etc. + docker compose pull && docker compose up -d + docker compose logs hub | grep "Setup code" # first sign-in: create your passkey diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..d92f5ff --- /dev/null +++ b/auth.js @@ -0,0 +1,149 @@ +// Passkey (WebAuthn) sign-in that gates the whole app. +// First passkey: enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log). +// After that, further passkeys can only be added from an already signed-in session. +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { + generateRegistrationOptions, verifyRegistrationResponse, + generateAuthenticationOptions, verifyAuthenticationResponse, +} = require('@simplewebauthn/server'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +fs.mkdirSync(DATA_DIR, { recursive: true }); +const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } }; +const writeJson = (f, v) => fs.writeFileSync(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 }); + +// Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts. +let secret = process.env.COOKIE_SECRET; +if (!secret) { + secret = readJson('secret.json', {}).secret; + if (!secret) { secret = crypto.randomBytes(32).toString('hex'); writeJson('secret.json', { secret }); } +} + +const store = readJson('passkeys.json', { userId: crypto.randomBytes(32).toString('base64url'), creds: [] }); +const save = () => writeJson('passkeys.json', store); + +const SETUP_CODE = process.env.HUB_SETUP_CODE || crypto.randomBytes(4).toString('hex'); +if (!store.creds.length) console.log(`[auth] No passkeys yet. Setup code: ${SETUP_CODE}`); + +const SESSION_DAYS = 30; +const mac = v => crypto.createHmac('sha256', secret).update(v).digest('base64url'); +const cookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; })); +const isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https'; + +function isAuthed(req) { + const [exp, sig] = String(cookies(req).hub_session || '').split('.'); + if (!exp || !sig) return false; + const want = mac('s' + exp); + return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > Date.now(); +} +function startSession(req, res) { + const exp = Date.now() + SESSION_DAYS * 864e5; + res.setHeader('Set-Cookie', `hub_session=${exp}.${mac('s' + exp)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`); +} + +const challenges = new Map(); // cid -> { challenge, kind, exp } +const newChallenge = (challenge, kind) => { + for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k); + const cid = crypto.randomBytes(16).toString('base64url'); + challenges.set(cid, { challenge, kind, exp: Date.now() + 5 * 60e3 }); + return cid; +}; +const takeChallenge = (cid, kind) => { + const c = challenges.get(cid); challenges.delete(cid); + return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null; +}; + +const hits = new Map(); // crude per-IP throttle on anything that checks a secret +const throttled = req => { + const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); + const h = hits.get(ip) || { n: 0, t: Date.now() }; + if (Date.now() - h.t > 60e3) { h.n = 0; h.t = Date.now(); } + h.n++; hits.set(ip, h); + return h.n > 10; +}; + +const rp = req => { + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'; + const proto = req.headers['x-forwarded-proto'] || 'http'; + return { rpID: host.split(':')[0], origin: `${proto}://${host}` }; +}; +const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); }; + +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 send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); }; + +async function handle(req, res) { + const url = new URL(req.url, 'http://x'), p = url.pathname; + try { + if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: isAuthed(req) }); + if (p === '/api/auth/logout' && req.method === 'POST') { + res.setHeader('Set-Cookie', 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); + return send(res, 200, { ok: true }); + } + const body = req.method === 'POST' || req.method === 'DELETE' ? JSON.parse((await readBody(req)) || '{}') : {}; + const { rpID, origin } = rp(req); + + if (p === '/api/auth/register/options' && req.method === 'POST') { + const authed = isAuthed(req); + if (!authed) { + if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); + if (store.creds.length || !equalStr(body.setupCode || '', SETUP_CODE)) return send(res, 403, { error: 'Wrong setup code' }); + } + const options = await generateRegistrationOptions({ + rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'), + attestationType: 'none', + excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })), + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }); + return send(res, 200, { cid: newChallenge(options.challenge, authed ? 'reg-authed' : 'reg-setup'), options }); + } + if (p === '/api/auth/register/verify' && req.method === 'POST') { + const authed = isAuthed(req); + if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); + const challenge = takeChallenge(body.cid, authed ? 'reg-authed' : 'reg-setup'); + if (!challenge) return send(res, 400, { error: 'Request expired, try again' }); + if (!authed && store.creds.length) return send(res, 403, { error: 'Not allowed' }); + const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID }); + if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' }); + const c = v.registrationInfo.credential; + store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [], + name: String(body.name || 'Passkey').slice(0, 40), created: Date.now() }); + save(); + if (!authed) startSession(req, res); + return send(res, 200, { ok: true }); + } + if (p === '/api/auth/login/options' && req.method === 'POST') { + if (!store.creds.length) return send(res, 400, { error: 'No passkeys set up yet' }); + const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' }); + return send(res, 200, { cid: newChallenge(options.challenge, 'login'), options }); + } + if (p === '/api/auth/login/verify' && req.method === 'POST') { + if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); + const challenge = takeChallenge(body.cid, 'login'); + const cred = store.creds.find(c => c.id === body.response?.id); + if (!challenge || !cred) return send(res, 400, { error: 'Passkey not recognised' }); + const v = await verifyAuthenticationResponse({ + response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, + credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports }, + }); + if (!v.verified) return send(res, 401, { error: 'Sign-in failed' }); + cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save(); + startSession(req, res); + return send(res, 200, { ok: true }); + } + // Everything below needs a signed-in session. + if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' }); + if (p === '/api/auth/passkeys' && req.method === 'GET') { + return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null }))); + } + if (p === '/api/auth/passkeys' && req.method === 'DELETE') { + store.creds = store.creds.filter(c => c.id !== body.id); save(); + return send(res, 200, { ok: true, remaining: store.creds.length }); + } + send(res, 404, { error: 'Not found' }); + } catch (e) { send(res, 400, { error: e.message }); } +} + +module.exports = { handle, isAuthed, secret }; diff --git a/cal.js b/cal.js new file mode 100644 index 0000000..542a4fd --- /dev/null +++ b/cal.js @@ -0,0 +1,255 @@ +// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars). +// Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window. +const fs = require('fs'); +const path = require('path'); +const dns = require('dns').promises; +const net = require('net'); +const crypto = require('crypto'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const FILE = path.join(DATA_DIR, 'calendars.json'); +const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return []; } }; +const save = v => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(v, null, 2), { mode: 0o600 }); }; + +// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses. +const isPrivate = ip => { + if (net.isIPv6(ip)) { const l = ip.toLowerCase(); return l === '::1' || l.startsWith('fc') || l.startsWith('fd') || l.startsWith('fe80') || l.startsWith('::ffff:') && isPrivate(l.slice(7)); } + const [a, b] = ip.split('.').map(Number); + return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127) || a >= 224; +}; +async function assertPublic(u) { + if (u.protocol !== 'https:') throw new Error('Calendar links must be https (webcal:// is fine)'); + const host = u.hostname.replace(/^\[|\]$/g, ''); + const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true }); + if (!addrs.length || addrs.some(a => isPrivate(a.address))) throw new Error('That address is not allowed'); +} +async function fetchIcs(raw) { + let u = new URL(raw.replace(/^webcal:/i, 'https:')); + for (let hop = 0; hop < 4; hop++) { + await assertPublic(u); + const r = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(12000), headers: { Accept: 'text/calendar, */*' } }); + if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; } + if (!r.ok) throw new Error('Calendar server answered ' + r.status); + const text = await r.text(); + if (text.length > 8e6) throw new Error('Calendar file is too large'); + if (!/BEGIN:VCALENDAR/i.test(text)) throw new Error('That link is not a calendar (ICS) file'); + return text; + } + throw new Error('Too many redirects'); +} +const cache = new Map(); // url -> { t, text } +async function getIcs(url) { + const c = cache.get(url); + if (c && Date.now() - c.t < 10 * 60e3) return c.text; + const text = await fetchIcs(url); + cache.set(url, { t: Date.now(), text }); + return text; +} + +// ---- Parsing +const WIN_TZ = { 'Eastern Standard Time': 'America/New_York', 'Central Standard Time': 'America/Chicago', 'Mountain Standard Time': 'America/Denver', + 'Pacific Standard Time': 'America/Los_Angeles', 'US Mountain Standard Time': 'America/Phoenix', 'Alaskan Standard Time': 'America/Anchorage', + 'Hawaiian Standard Time': 'Pacific/Honolulu', 'GMT Standard Time': 'Europe/London', 'W. Europe Standard Time': 'Europe/Berlin', + 'Central Europe Standard Time': 'Europe/Budapest', 'Romance Standard Time': 'Europe/Paris', 'UTC': 'UTC' }; +const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } }; +const tzName = tz => (tz && (validTz(tz) ? tz : WIN_TZ[tz])) || null; + +const fmts = new Map(); +// Wall-clock time in a zone -> UTC ms. +function zonedToUtc(y, mo, d, h, mi, s, tz) { + const guess = Date.UTC(y, mo - 1, d, h, mi, s); + if (!tz) return guess; + let fmt = fmts.get(tz); + if (!fmt) fmts.set(tz, fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })); + const offset = t => { const p = Object.fromEntries(fmt.formatToParts(new Date(t)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - t; }; + let t = guess - offset(guess); + t = guess - offset(t); // second pass settles DST edges + return t; +} + +function unfold(text) { return text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/); } +function parseLine(line) { + const i = line.indexOf(':'); if (i < 0) return null; + const [name, ...params] = line.slice(0, i).split(';'); + const p = {}; params.forEach(x => { const [k, v] = x.split('='); p[k.toUpperCase()] = (v || '').replace(/^"|"$/g, ''); }); + return { name: name.toUpperCase(), params: p, value: line.slice(i + 1) }; +} +const unesc = v => v.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1'); + +// "20261020T090000Z" | "20261020T090000" | "20261020" -> { allDay, y, mo, d, h, mi, s, utc, tz } +function parseDate(value, params) { + const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim()); + if (!m) return null; + const allDay = !m[4] || params.VALUE === 'DATE'; + const r = { allDay, y: +m[1], mo: +m[2], d: +m[3], h: +(m[4] || 0), mi: +(m[5] || 0), s: +(m[6] || 0), tz: m[7] ? 'UTC' : tzName(params.TZID) }; + r.ms = allDay ? Date.UTC(r.y, r.mo - 1, r.d) : zonedToUtc(r.y, r.mo, r.d, r.h, r.mi, r.s, r.tz); + return r; +} +function parseDuration(v) { + const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim()); + if (!m) return null; + const sign = m[1] === '-' ? -1 : 1; + return sign * (((+m[2] || 0) * 7 + (+m[3] || 0)) * 864e5 + (+m[4] || 0) * 36e5 + (+m[5] || 0) * 6e4 + (+m[6] || 0) * 1e3); +} + +function parseEvents(text) { + const events = []; let cur = null; + for (const line of unfold(text)) { + if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; continue; } + if (line === 'END:VEVENT') { if (cur && cur.start) events.push(cur); cur = null; continue; } + if (!cur) continue; + const l = parseLine(line); if (!l) continue; + switch (l.name) { + case 'DTSTART': cur.start = parseDate(l.value, l.params); break; + case 'DTEND': cur.end = parseDate(l.value, l.params); break; + case 'DURATION': cur.dur = parseDuration(l.value); break; + case 'SUMMARY': cur.title = unesc(l.value); break; + case 'LOCATION': cur.location = unesc(l.value); break; + case 'UID': cur.uid = l.value; break; + case 'STATUS': cur.status = l.value.toUpperCase(); break; + case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); break; + case 'EXDATE': l.value.split(',').forEach(v => { const d = parseDate(v, l.params); if (d) cur.ex.push(d.allDay ? d.ms : d.ms); }); break; + case 'RECURRENCE-ID': cur.recId = parseDate(l.value, l.params); break; + } + } + return events; +} + +// ---- Expansion +const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; +const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate(); +function nthWeekday(y, m, dow, n) { // n>0: nth, n<0: nth from end + if (n > 0) { const first = new Date(Date.UTC(y, m - 1, 1)).getUTCDay(); const d = 1 + ((dow - first + 7) % 7) + (n - 1) * 7; return d <= daysInMonth(y, m) ? d : null; } + const dim = daysInMonth(y, m), last = new Date(Date.UTC(y, m - 1, dim)).getUTCDay(); + const d = dim - ((last - dow + 7) % 7) + (n + 1) * 7; return d >= 1 ? d : null; +} + +// Occurrence start dates as wall-clock {y,mo,d}, from DTSTART forward, within [..until]. +function* occurrences(ev, windowStartMs, windowEndMs) { + const s = ev.start, r = ev.rrule; + const tod = { h: s.h, mi: s.mi, s: s.s }; + const toMs = ({ y, mo, d }) => s.allDay ? Date.UTC(y, mo - 1, d) : zonedToUtc(y, mo, d, tod.h, tod.mi, tod.s, s.tz); + if (!r) { yield { y: s.y, mo: s.mo, d: s.d }; return; } + const interval = Math.max(1, +r.INTERVAL || 1), count = r.COUNT ? +r.COUNT : Infinity; + const until = r.UNTIL ? parseDate(r.UNTIL, {}) : null; + const byday = r.BYDAY ? r.BYDAY.split(',').map(x => { const m = /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(x); return m && { n: m[1] ? +m[1] : 0, dow: DAYS.indexOf(m[2]) }; }).filter(Boolean) : null; + const bymd = r.BYMONTHDAY ? r.BYMONTHDAY.split(',').map(Number) : null; + const bymonth = r.BYMONTH ? r.BYMONTH.split(',').map(Number) : null; + let n = 0; + const ok = c => { + const ms = toMs(c); + return ms >= s.ms && (!until || ms <= until.ms + (until.allDay ? 864e5 - 1 : 0)); + }; + const emit = function* (c) { if (!ok(c)) return; if (n >= count) return 'stop'; n++; yield c; }; + const startDate = Date.UTC(s.y, s.mo - 1, s.d); + // Without COUNT we can jump close to the window instead of walking from the original start. + let i0 = 0; + if (!r.COUNT && windowStartMs > s.ms) { + const dayDiff = (windowStartMs - startDate) / 864e5, monthDiff = (new Date(windowStartMs).getUTCFullYear() - s.y) * 12 + new Date(windowStartMs).getUTCMonth() - (s.mo - 1); + const steps = { DAILY: dayDiff / interval, WEEKLY: dayDiff / 7 / interval, MONTHLY: monthDiff / interval, YEARLY: monthDiff / 12 / interval }[r.FREQ] || 0; + i0 = Math.max(0, Math.floor(steps) - 2); + } + for (let i = i0; i < i0 + 5000; i++) { + let cands = []; + if (r.FREQ === 'DAILY') { + const dt = new Date(startDate + i * interval * 864e5); + cands = [{ y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }]; + } else if (r.FREQ === 'WEEKLY') { + const weekStart = startDate - new Date(startDate).getUTCDay() * 864e5 + i * interval * 7 * 864e5; + const dows = byday ? byday.map(b => b.dow) : [new Date(startDate).getUTCDay()]; + cands = dows.sort((a, b) => a - b).map(dow => { const dt = new Date(weekStart + dow * 864e5); return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }; }); + } else if (r.FREQ === 'MONTHLY') { + const mIdx = s.mo - 1 + i * interval, y = s.y + Math.floor(mIdx / 12), mo = (mIdx % 12) + 1; + if (byday) cands = byday.map(b => b.n ? nthWeekday(y, mo, b.dow, b.n) : null).filter(Boolean).map(d => ({ y, mo, d })); + else (bymd || [s.d]).forEach(d => { const dd = d < 0 ? daysInMonth(y, mo) + d + 1 : d; if (dd >= 1 && dd <= daysInMonth(y, mo)) cands.push({ y, mo, d: dd }); }); + cands.sort((a, b) => a.d - b.d); + } else if (r.FREQ === 'YEARLY') { + const y = s.y + i * interval; + const months = bymonth || [s.mo]; + for (const mo of months) { + if (byday) byday.forEach(b => { const d = b.n ? nthWeekday(y, mo, b.dow, b.n) : null; if (d) cands.push({ y, mo, d }); }); + else (bymd || [s.d]).forEach(d => { if (d <= daysInMonth(y, mo)) cands.push({ y, mo, d }); }); + } + } else { yield { y: s.y, mo: s.mo, d: s.d }; return; } + if (bymonth && r.FREQ !== 'YEARLY') cands = cands.filter(c => bymonth.includes(c.mo)); + if (!cands.length && i > 4000) return; + for (const c of cands) { + if (toMs(c) < s.ms) continue; + if (toMs(c) > windowEndMs) return; + const res = yield* emit(c); + if (res === 'stop') return; + } + if (cands.length && toMs(cands[0]) > windowEndMs) return; + if (until && cands.length && toMs(cands[0]) > until.ms + 864e5) return; + } +} + +const pad = n => String(n).padStart(2, '0'); +const dateStr = ms => { const d = new Date(ms); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; }; + +function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD, inclusive) } + const out = []; + const overrides = new Set(events.filter(e => e.recId).map(e => e.uid + '|' + e.recId.ms)); + for (const ev of events) { + if (ev.status === 'CANCELLED') continue; + const s = ev.start; + const durMs = ev.end ? ev.end.ms - s.ms : ev.dur != null ? ev.dur : s.allDay ? 864e5 : 0; + const add = (startMs) => { + const endMs = startMs + durMs; + if (s.allDay) { + const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5)); + if (ed <= win.fd || sd > win.td) return; + out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed }); + } else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs }); + }; + if (!ev.rrule || ev.recId) { add(s.ms); continue; } + let guard = 0; + for (const c of occurrences(ev, win.from - 864e5, win.to + 864e5)) { + const ms = s.allDay ? Date.UTC(c.y, c.mo - 1, c.d) : zonedToUtc(c.y, c.mo, c.d, s.h, s.mi, s.s, s.tz); + if (ev.ex.includes(ms) || overrides.has(ev.uid + '|' + ms)) continue; + add(ms); + if (++guard > 3000) break; + } + } + return out; +} + +// ---- HTTP API (called by server.js, behind the passkey gate) +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 COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'); + try { + if (u.pathname === '/api/cal/list') return json(res, 200, load().map(({ id, name, color }) => ({ id, name, color }))); + if (u.pathname === '/api/cal/add' && req.method === 'POST') { + const { name, url } = JSON.parse(await readBody(req)); + if (!url) throw new Error('Paste the calendar link'); + await getIcs(url); // validates the link before saving it + const cals = load(); + cals.push({ id: crypto.randomBytes(6).toString('hex'), name: String(name || 'Calendar').slice(0, 40), url, color: COLORS[cals.length % COLORS.length] }); + save(cals); + return json(res, 200, { ok: true }); + } + if (u.pathname === '/api/cal/remove' && req.method === 'POST') { + const { id } = JSON.parse(await readBody(req)); + save(load().filter(c => c.id !== id)); + return json(res, 200, { ok: true }); + } + if (u.pathname === '/api/cal/events') { + 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}`); } + })); + return json(res, 200, { events: all, errors }); + } + json(res, 404, { error: 'Not found' }); + } catch (e) { json(res, 400, { error: e.message }); } +} + +module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eeae7fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + hub: + image: git.necloud.us/nate/hub:latest # built by the Gitea workflow; `docker compose build` builds locally instead + build: . + restart: unless-stopped + ports: + - "8080:8080" + env_file: .env + volumes: + - hub-data:/data # passkeys + session secret live here +volumes: + hub-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..055e1ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,344 @@ +{ + "name": "project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "project", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@simplewebauthn/server": "^14.0.3" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.0.tgz", + "integrity": "sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.11.0.tgz", + "integrity": "sha512-r2957WdSZle/Zt4t9og7OMQYXNJ9/3ANXVtFwLCDJuHORlBZ4AFmDt1Qee0CzyTbHQg4ZZApBGEz5/6cFsyCRg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.0.tgz", + "integrity": "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "@peculiar/asn1-x509-attr": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.0.tgz", + "integrity": "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.0.tgz", + "integrity": "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.0.tgz", + "integrity": "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.0", + "@peculiar/asn1-pkcs8": "^2.11.0", + "@peculiar/asn1-rsa": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.0.tgz", + "integrity": "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.0.tgz", + "integrity": "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.0", + "@peculiar/asn1-pfx": "^2.11.0", + "@peculiar/asn1-pkcs8": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "@peculiar/asn1-x509-attr": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.0.tgz", + "integrity": "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz", + "integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.0.tgz", + "integrity": "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.0.tgz", + "integrity": "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.11.0.tgz", + "integrity": "sha512-K6EnquTX3xJ2G+uLPFH0jVkh7W8y6eTzoCxvSoXAj0sWS5uvVB2PEvpBPdzY4+ytaedUM3x9ZVgX0AZKb316ew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.2.0.tgz", + "integrity": "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.0", + "@peculiar/asn1-csr": "^2.11.0", + "@peculiar/asn1-ecc": "^2.11.0", + "@peculiar/asn1-pkcs9": "^2.11.0", + "@peculiar/asn1-rsa": "^2.11.0", + "@peculiar/asn1-schema": "^2.11.0", + "@peculiar/asn1-x509": "^2.11.0", + "@peculiar/asn1-x509-post-quantum": "^2.11.0", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz", + "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/asn1js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..28617e3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "ne-hub", + "version": "1.0.0", + "description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@simplewebauthn/server": "^14.0.3" + } +} \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..04bac56 --- /dev/null +++ b/public/app.js @@ -0,0 +1,840 @@ +'use strict'; +// If the server says the passkey session is gone, go back to the sign-in page. +const _fetch = window.fetch.bind(window); +window.fetch = async (...a) => { + const r = await _fetch(...a); + if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login'); + return r; +}; +const $ = s => document.querySelector(s); +const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const store = { + get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, + del(k) { try { localStorage.removeItem(k); } catch {} }, +}; + +const ICONS = { + home: '', + mail: '', + files: '', + doc: '', + cog: '', + chev: '', + back: '', + edit: '', + up: '', + newdir: '', + file: '', + dl: '', + audio: '', + copy: '', + music: '', + play: '', + pause: '', + next: '', + prev: '', + shuffle: '', + more: '', + cal: '', + search: '', + trash: '', +}; +const icon = (n, cls = '') => `${ICONS[n]}`; + +// ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail. +const MAIL = [ + { id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' }, + { id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' }, + { id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' }, + { id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' }, +]; + +// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox. +const mailHints = () => store.get('mail_hints', {}); +function mailUrl(m, kind) { + const base = m[kind], hint = (mailHints()[m.id] || '').trim(); + if (!hint) return base; + const u = new URL(base), q = encodeURIComponent(hint); + if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); } + if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); } + return base; // iCloud has no sign-in hint +} + +let cfg = { audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; + +// ---- Seafile client (via same-origin proxy) +const sf = { + token: () => store.get('sf_token'), + async req(path, opts = {}) { + const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) }; + const r = await fetch('/sf' + path, { ...opts, headers }); + if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); } + if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')'); + const t = await r.text(); + try { return JSON.parse(t); } catch { return t; } + }, + async login(username, password, otp) { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + if (otp) headers['X-SEAFILE-OTP'] = otp; + const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) { + if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true }); + throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' ')); + } + store.set('sf_token', j.token); + }, +}; + +const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); }; +const ext = n => (n.split('.').pop() || '').toLowerCase(); +const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']); +const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']); +const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB'; +const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + +// ---- Navigation +const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']]; +const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; +const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More" +const phone = matchMedia('(max-width: 767px)'); +let activeTab = 'home'; + +function renderNav(active = activeTab) { + activeTab = active; + const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}`; + const brand = `
NE Hub
`; + if (phone.matches) { + const more = !PRIMARY.includes(active); + $('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') + + `${icon('more')}More`; + $('#more').onclick = e => { e.preventDefault(); openMore(); }; + } else $('#nav').innerHTML = brand + TABS.map(tab).join(''); +} +phone.addEventListener('change', () => renderNav()); + +function openMore() { + const el = openSheet('More', '', `
${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => ` + ${icon(TAB_ICON[id])}${label}${icon('chev', 'chev')}`).join('')}
`); + el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); +} + +const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; +let editor; +function route() { + const [, id = 'home', ...rest] = location.hash.split('/'); + const name = routes[id] ? id : 'home'; + try { editor?.destroyEditor(); } catch {} + editor = null; + document.body.classList.toggle('editing', name === 'edit'); + renderNav(name === 'edit' ? 'files' : name); + routes[name](rest.map(decodeURIComponent)); + $('#view').scrollTop = 0; +} + +function tile(href, color, ic, title, sub, external) { + return ` + ${icon(ic)}${esc(title)}${esc(sub)}`; +} + +// ---- Views +function viewHome() { + const h = new Date().getHours(); + const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; + const recents = store.get('recents', []).slice(0, 5); + const t = typeof cur === 'function' ? cur() : null; + $('#view').innerHTML = `

${greet}

+ ${icon('search')}Search files, music, libraries + ${t ? `

Now playing

` : ''} +
+ ${recents.length ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''} +

Apps

+ ${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')} + ${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')} + ${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')} + ${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')} + ${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')} + ${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')} +
+

Mail

+ ${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')} +
`; + const np = $('#hnp'); if (np) np.onclick = openNow; + $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { + const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); + }); + if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today'); +} + +// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others) +function viewSearch() { + $('#view').innerHTML = `

Search

+
`; + const q = $('#q'); q.focus(); + let timer, seq = 0; + q.oninput = () => { 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; } + res.innerHTML = '

Searching…

'; + const enc = encodeURIComponent; + const [files, libs, music] = 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'))), + ]); + 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('') : ''; + const fileHtml = files.status === 'fulfilled' ? (files.value.results || []).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('') : ''; + 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 += ``; } + } + } + const note = files.status === 'rejected' && sf.token() ? '

File search is not available on your Seafile server (it needs Seafile\'s search index). Libraries and music still work.

' : ''; + res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || '

No results

'; + if (!libHtml && !fileHtml && !musicHtml) 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); }); + res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); + } +} + +// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server) +const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +async function loadAgenda(days) { + const start = new Date(); start.setHours(0, 0, 0, 0); + const end = new Date(start); end.setDate(end.getDate() + days); + const last = new Date(end.getTime() - 1); + const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) }); + const { events, errors } = await jfetch('/api/cal/events?' + q); + const byDay = new Map(); + const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); }; + for (const e of events) { + if (e.allDay) { // spans start..end-1 (end exclusive) + for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); } + } else put(ymd(new Date(e.start)), e); + } + for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start))); + return { byDay, errors, start }; +} +const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +const evRow = e => `
${esc(e.title)} + ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`; +const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1); + return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); }; + +async function renderAgenda(el, mode) { // Home card (today only) + if (!el) return; + try { + const cals = await jfetch('/api/cal/list'); if (!cals.length) return; + const { byDay } = await loadAgenda(1); + const list = byDay.get(ymd(new Date())) || []; + if (!document.body.contains(el)) return; + el.innerHTML = `

Today

${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`; + } catch {} +} + +async function viewCalendar() { + const range = store.get('cal_range', 7); + const v = $('#view'); + v.innerHTML = `

Calendar

${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `${l}`).join('')}
+

Loading…

`; + v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); }); + $('#mc').onclick = manageCalendars; + try { + const cals = await jfetch('/api/cal/list'); + if (!cals.length) { $('#ag').innerHTML = '

No calendars yet. Add a private calendar link to see your events here.

'; $('#ac').onclick = addCalendar; return; } + const { byDay, errors } = await loadAgenda(range); + if (!location.hash.startsWith('#/calendar')) return; + const keys = [...byDay.keys()].sort(); + $('#ag').innerHTML = (keys.map(k => `

${esc(dayLabel(k))}

${byDay.get(k).map(evRow).join('')}
`).join('') || '

Nothing coming up

') + + errors.map(e => `

${esc(e)}

`).join(''); + } catch (e) { $('#ag').innerHTML = `

${esc(e.message)}

`; } +} + +function addCalendar() { + const el = openSheet('Add a calendar', '', `
+ +

Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. iCloud: calendar's Share → Public Calendar → copy link. Anyone with the link can read that calendar, so keep it private.

+
`); + el.querySelector('#sx').onclick = closeSheet; + el.querySelector('form').onsubmit = async e => { + e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = ''; + try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); } + catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; } + }; +} +async function manageCalendars() { + const cals = await jfetch('/api/cal/list'); + const el = openSheet('Calendars', '', `
${cals.map(c => `
${esc(c.name)} +
`).join('') || '
No calendars
'}
+
`); + el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar; + el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); }); +} + +function viewMail() { + $('#view').innerHTML = `

Mail

+

Opens each provider's own webmail in a new tab. Nothing is stored here.

+

Inboxes

+

Compose

`; +} + +function viewOffice() { + const url = cfg.onlyoffice; + $('#view').innerHTML = `

Office

+

OnlyOffice at ${esc(new URL(url).host)}. To edit a file from Seafile, open it in Files.

+ + +

If the frame above is blank, your OnlyOffice server blocks embedding. Use the button, or allow this app's origin in its frame-ancestors / X-Frame-Options.

`; +} + +// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap, +// because a page can't type into another site's login form. +const abCreds = () => store.get('ab_creds', { u: '', p: '' }); +function viewAudio() { + const url = cfg.audiobooks, c = abCreds(); + $('#view').innerHTML = `

Audio

+

${esc(new URL(url).host)}

+
Open in new tab + ${c.u ? `` : ''} + ${c.p ? `` : ''} + ${c.u || c.p ? '' : 'Save sign-in'}
+
`; + const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } }; + const cu = $('#cu'), cp = $('#cp'); + if (cu) cu.onclick = () => copy(c.u, 'Username'); + if (cp) cp.onclick = () => copy(c.p, 'Password'); +} + +// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here) +const jfetch = async (path, opts) => { + const r = await fetch(path, opts); const j = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status }); + return j; +}; +const px = { + sec: null, + async get(path) { + const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } }); + if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true }); + if (!r.ok) throw new Error('Plex error ' + r.status); + return (await r.json()).MediaContainer || {}; + }, + art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + async section() { + if (px.sec) return px.sec; + const d = (await px.get('/library/sections')).Directory || []; + const m = d.find(x => x.type === 'artist'); + if (!m) throw new Error('No music library found on this Plex server'); + return (px.sec = m.key); + }, +}; +const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }; +const mapTrack = (t, mc) => { + const part = t.Media?.[0]?.Part?.[0]; + return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '', + album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 }; +}; + +async function viewMusic(args) { + const v = $('#view'); + v.innerHTML = '

Music

Loading…

'; + try { + const st = await jfetch('/api/plex/status'); + if (!location.hash.startsWith('#/music')) return; + if (!st.signedIn) return plexSignIn(); + if (!st.server) return plexPickServer(); + await musicRoute(args, st); + } catch (e) { + if (e.auth) return plexSignIn(); + v.innerHTML = `

Music

${esc(e.message)}

`; + $('#rs').onclick = plexPickServer; + } +} + +function plexSignIn(msg = '') { + $('#view').innerHTML = `

Music

Play your Plex music library here.

+

You'll sign in on plex.tv in a new tab. This app never sees your Plex password.

+
${esc(msg)}
`; + $('#pl').onclick = async () => { + const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it + try { + const pin = await jfetch('/api/plex/pin', { method: 'POST' }); + if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `Tap to open Plex sign-in`; + for (let i = 0; i < 150; i++) { + await new Promise(r => setTimeout(r, 2000)); + if (!location.hash.startsWith('#/music')) return; + const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`); + if (r.ok) { try { w?.close(); } catch {} return route(); } + } + $('#pe').textContent = 'Timed out waiting for Plex. Try again.'; + } catch (e) { $('#pe').textContent = e.message; } + }; +} + +async function plexPickServer() { + const v = $('#view'); + v.innerHTML = '

Music

Finding your Plex servers…

'; + try { + const servers = await jfetch('/api/plex/servers'); + if (!servers.length) { v.innerHTML = '

Music

No Plex servers found on this account.

'; return; } + v.innerHTML = `

Music

Choose a server

${servers.map(s => ` + `).join('')}
`; + v.querySelectorAll('button.row').forEach(b => b.onclick = async () => { + b.disabled = true; $('#pe').textContent = ''; + try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.sec = null; route(); } + catch (e) { b.disabled = false; $('#pe').textContent = e.message; } + }); + } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `

Music

${esc(e.message)}

`; } +} + +async function musicRoute([kind = 'albums', key], st) { + const v = $('#view'), sec = await px.section(); + const tabs = ['albums', 'artists', 'playlists']; + const seg = `
${tabs.map(t => `${t[0].toUpperCase() + t.slice(1)}`).join('')}
`; + const card = (href, thumb, title, sub, round) => ` + ${esc(title)}${esc(sub)}`; + const listing = (items, html) => { + v.innerHTML = `

Music

${esc(st.server)}

${seg} +
${html || '

Nothing here

'}
`; + $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); }; + }; + const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort'; + + if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`); + return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); } + if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`); + return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); } + if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio'); + return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); } + + if (kind === 'artist') { + const mc = await px.get(`/library/metadata/${key}/children`); + v.innerHTML = `

${esc(mc.title2 || mc.parentTitle || 'Artist')}

+

Albums

${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`; + return; + } + if (kind === 'album' || kind === 'playlist') { + const album = kind === 'album'; + const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`), + px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]); + const info = (meta.Metadata || [])[0] || {}; + const tracks = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); + const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks'; + const thumb = info.thumb || info.composite || mc.thumb; + v.innerHTML = `
+

${esc(title)}

${esc(sub)}

+
+
${tracks.map((t, i) => ``).join('') || '

No playable tracks

'}
`; + v.querySelectorAll('button.row').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.i)); + $('#pa').onclick = () => tracks.length && playQueue(tracks, 0); + $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0); + markPlaying(); + return; + } + location.hash = '#/music/albums'; +} + +// ---- Player: one