diff --git a/README.md b/README.md index 66ccfad..8a2241c 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords. - **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet. - **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`. -- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position. +- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Each person has their own list of shows (the admin starts with everything already in Podgrab, everyone else starts empty); adding a show someone already follows just puts it on your list, and removing one only takes it off yours (Podgrab drops it when nobody follows it). Where you got to in each episode is kept per person on the server, so it follows you between devices. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position. - **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links. - **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation. - **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder. diff --git a/podcast.js b/podcast.js index cb64b5e..bfae367 100644 --- a/podcast.js +++ b/podcast.js @@ -1,5 +1,7 @@ // Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the // Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub. +const fs = require('fs'), path = require('path'); +const users = require('./users'); const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, ''); const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : ''; @@ -12,6 +14,23 @@ const ALLOWED = [ ['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/], ['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/], ]; + +// Podgrab holds every show in one shared library; which of them are on a person's own list is kept here, per person. A new person starts with an empty +// list (the admin starts with everything already in Podgrab). Taking a show off your list leaves Podgrab alone unless nobody else follows it. +const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json'); +const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } }; +const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); }; +const idOf = o => o && (o.ID !== undefined ? o.ID : o.id); +async function podgrab(p, init = {}) { + const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) }); + return r; +} +async function mySubs(me) { + const have = subsRead(me.id); if (have) return have; + let ids = []; + if (me.role === 'admin') { try { const l = await (await podgrab('/podcasts')).json(); ids = (Array.isArray(l) ? l : []).map(idOf).filter(Boolean); } catch { return []; } } + subsWrite(me.id, ids); return ids; +} const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const enabled = () => !!BASE; @@ -37,6 +56,35 @@ async function handle(req, res) { if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' }); if (p === '/status') return json(res, 200, { ok: true, host: new URL(BASE).host }); if (!ALLOWED.some(([m, re]) => m === req.method && re.test(p))) return json(res, 404, { error: 'Not found' }); + const me = users.current(), mine = await mySubs(me); + let m; + try { + if (req.method === 'GET' && p === '/podcasts') { // only the shows on this person's list + const r = await podgrab('/podcasts' + u.search); const l = await r.json(); + return json(res, r.status, Array.isArray(l) ? l.filter(x => mine.includes(idOf(x))) : l); + } + if (req.method === 'POST' && p === '/podcasts') { // add to Podgrab (if it is not there yet) and to this person's list + const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } + let url = ''; try { url = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).url || '').trim(); } catch {} + if (!/^https?:\/\//i.test(url)) return json(res, 400, { error: 'Enter a feed address starting with http' }); + const r = await podgrab('/podcasts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url }) }); + let added = null; if (r.status === 200) { try { added = await r.json(); } catch {} } + let id = idOf(added); + if (!id) { // Podgrab already had it (409), or did not say which: find it by its feed address + const l = await (await podgrab('/podcasts')).json(); const norm = x => String(x || '').replace(/\/+$/, '').toLowerCase(); + const hit = (Array.isArray(l) ? l : []).find(x => norm(x.URL || x.url) === norm(url)); id = idOf(hit); added = hit; + if (!id) return json(res, r.status === 200 ? 502 : r.status, { error: 'Podgrab could not add that feed' }); + } + subsWrite(me.id, [...mine, id]); return json(res, 200, added || { ID: id }); + } + if (req.method === 'DELETE' && (m = /^\/podcasts\/([\w-]+)$/.exec(p))) { // off this person's list; out of Podgrab only when nobody else follows it + subsWrite(me.id, mine.filter(x => x !== m[1])); + const others = users.all().some(x => x.id !== me.id && (subsRead(x.id) || []).includes(m[1])); + if (!others) await podgrab(p, { method: 'DELETE' }).catch(() => {}); + return json(res, 200, { ok: true, removed: !others }); + } + if ((m = /^\/podcasts\/([\w-]+)(\/|$)/.exec(p)) && !mine.includes(m[1])) return json(res, 404, { error: 'That podcast is not on your list' }); + } catch (e) { return json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'TimeoutError' ? ' in time' : '') }); } const hasBody = req.method === 'POST' || req.method === 'PATCH'; let body; if (hasBody) { const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } body = Buffer.concat(chunks); } diff --git a/public/podcast-view.js b/public/podcast-view.js index a9327ce..7530fec 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -52,7 +52,7 @@ async function pdHome() { ${shows.length ? `
${shows.map(s => ` ${esc(pdF(s, 'title'))}${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}`).join('')}
` - : '

No podcasts yet. Add one with its feed address.

'}`; + : '

No podcasts on your list yet. Search for one, or paste its feed address.

'}`; $('#pdadd').onclick = () => { const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `
@@ -109,11 +109,11 @@ async function pdShow(id) { $('#pdmore').onclick = () => { const el = openSheet(pdF(show, 'title'), '', `
-
`); + `); const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } }; act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background'); act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused'); - el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove this podcast?', 'Its episodes and downloaded files are deleted from Podgrab.', 'Remove', async () => { + el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove from your podcasts?', 'It leaves your list. Its downloaded episodes are only deleted when nobody else follows it.', 'Remove', async () => { try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); }; }; } diff --git a/tools/podcast-test.js b/tools/podcast-test.js index b5db2c6..a40ba20 100644 --- a/tools/podcast-test.js +++ b/tools/podcast-test.js @@ -5,7 +5,8 @@ const seen = []; const fake = http.createServer((req, res) => { let b = ''; req.on('data', c => b += c); req.on('end', () => { seen.push({ m: req.method, u: req.url, auth: req.headers.authorization, range: req.headers.range, b }); - if (req.url === '/podcasts') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('[{"ID":"p1","Title":"Show"}]'); } + if (req.url === '/podcasts' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify([{ ID: 'p1', Title: 'Show', URL: 'https://a.test/feed' }, { ID: 'p9', Title: 'Other', URL: 'https://b.test/feed' }])); } + if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); } if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); } if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); } res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); @@ -13,14 +14,26 @@ const fake = http.createServer((req, res) => { }).listen(0, async () => { process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; const podcast = require('../podcast'), users = require('../users'); - const hub = http.createServer((req, res) => users.runAs({ id: 'pod-user', name: 'P' }, () => podcast.handle(req, res))).listen(0, async () => { + const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) }; + const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => { const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o }); try { - let r = await get('/podcasts'); assert.equal(r.status, 200); assert.equal((await r.json())[0].Title, 'Show'); + let r = await get('/podcasts'); assert.equal(r.status, 200); assert.deepEqual((await r.json()).map(x => x.ID), ['p1', 'p9'], 'the admin starts with everything already in Podgrab'); assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server'); r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3'); r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3'); - r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal(seen.at(-1).b, '{"url":"https://x.test/feed"}'); + const other = { 'x-user': 'other' }; + r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list'); + r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable'); + r = await get('/podcasts', { method: 'POST', headers: other, body: '{"url":"https://a.test/feed"}' }); assert.equal(r.status, 200); // Podgrab already has it (409): found by its address and put on their list + assert.deepEqual((await (await get('/podcasts', { headers: other })).json()).map(x => x.ID), ['p1']); + r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal((await r.json()).ID, 'p2'); + assert.deepEqual((await (await get('/podcasts')).json()).map(x => x.ID), ['p1', 'p9'], 'the other list did not change (fake Podgrab lists only its own two)'); + let dels = seen.filter(x => x.m === 'DELETE').length; + r = await get('/podcasts/p1', { method: 'DELETE', headers: other }); assert.equal((await r.json()).removed, false, 'the admin still follows p1, so Podgrab keeps it'); + assert.equal(seen.filter(x => x.m === 'DELETE').length, dels); + r = await get('/podcasts/p9', { method: 'DELETE' }); assert.equal((await r.json()).removed, true, 'nobody else follows p9, so Podgrab drops it'); + assert.equal(seen.filter(x => x.m === 'DELETE').length, dels + 1); const post = b => get('/progress', { method: 'POST', body: JSON.stringify(b) }).then(x => x.json()); await post({ e1: { pos: 90, dur: 600, t: 10 } }); const pr = await post({ e1: { pos: 5, dur: 600, t: 3 }, e2: { pos: 7, dur: 300, t: 4 } }); assert.equal(pr.e1.pos, 90, 'the newest position wins'); assert.equal((await (await get('/progress')).json()).e2.pos, 7);