From f292294ad78bff69c5bf86802ae22e9b2d1f0d0a Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:08:41 -0400 Subject: [PATCH] Store: one tab combining books, free audiobooks and radio, plus My Store for your own books, music, apps and sandboxed extensions - Store tab with sections; the book store, LibriVox and radio finder are shown inside it (old entry points redirect) - My Store: admin uploads files or copies from Seafile; books go to a person's shelf, music plays in the player, apps download - Extensions: one HTML file run in a sandboxed frame with a small storage/toast/user bridge; example in docs/extensions Co-Authored-By: Claude Sonnet 5.5 --- README.md | 13 +++ docs/extensions/notepad.html | 5 + mystore.js | 156 +++++++++++++++++++++++++++++++ package.json | 2 +- public/app.js | 15 +-- public/index.html | 1 + public/librivox-view.js | 8 +- public/music.js | 2 +- public/radio-view.js | 8 +- public/reader.js | 10 +- public/store-view.js | 175 +++++++++++++++++++++++++++++++++++ public/style.css | 4 + public/sw.js | 2 +- server.js | 3 + tools/mystore-test.js | 55 +++++++++++ users.js | 4 +- 16 files changed, 439 insertions(+), 24 deletions(-) create mode 100644 docs/extensions/notepad.html create mode 100644 mystore.js create mode 100644 public/store-view.js create mode 100644 tools/mystore-test.js diff --git a/README.md b/README.md index b535d5b..b433d11 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,16 @@ playing an episode or pressing the button still works. Settings -> Health shows - **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time. - **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. Turn the tab on per person in the admin screen. - **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported. + +## Store + +One **Store** tab (turn it on per person) with sections for **Books** (the free book sources, Open Library and any OPDS catalogue), **Audiobooks** (LibriVox) and **Radio** (the station finder), each shown only to people who have that tab, plus **My Store**. The old Book store and Free audiobooks buttons now open the Store. + +**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture. +- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it. +- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded. +- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them. +- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. See `docs/extensions/notepad.html` for a complete example. + +Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items. + diff --git a/docs/extensions/notepad.html b/docs/extensions/notepad.html new file mode 100644 index 0000000..1014fbf --- /dev/null +++ b/docs/extensions/notepad.html @@ -0,0 +1,5 @@ +Notepad

Notepad

diff --git a/mystore.js b/mystore.js new file mode 100644 index 0000000..44256b1 --- /dev/null +++ b/mystore.js @@ -0,0 +1,156 @@ +// My Store: a catalogue of your own content that everyone with the Store tab can use. The admin adds books, music, apps (files to download) and extensions +// (small web apps that add features to the hub), by uploading files or copying them from their Seafile. Books go onto a person's shelf in one tap, music plays +// in the hub's player, apps download, and extensions run in a sandbox of their own (an iframe with no access to the hub's sign-in or data; it gets a small +// message bridge for its own saved settings, below). Items and files live in the data folder, shared by everyone. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const { pipeline } = require('stream/promises'); +const atomicWrite = require('./atomic'); +const users = require('./users'), settings = require('./settings'), books = require('./books'); +const { readBody } = require('./body'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(), 'items.json'), FILES = () => path.join(ROOT(), 'files'); +const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, ''); +const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200; +const KINDS = ['book', 'music', 'app', 'extension']; +const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx']; +const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' }; +const IMAGE = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' }; + +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n); +const idOk = s => /^[0-9a-f]{12}$/.test(s || ''); +const newId = () => crypto.randomBytes(6).toString('hex'); +const extOf = n => (/\.([A-Za-z0-9]{1,8})$/.exec(n) || [])[1] ? (/\.([A-Za-z0-9]{1,8})$/.exec(n))[1].toLowerCase() : ''; +const safeName = n => str(path.basename(String(n || 'file')), 160).replace(/[\\/:*?"<>|]+/g, '_') || 'file'; +const needAdmin = me => { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change the store'); }; + +const loadItems = () => { try { const j = JSON.parse(fs.readFileSync(ITEMS(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }; +const saveItems = l => { fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); atomicWrite(ITEMS(), JSON.stringify(l, null, 1), { mode: 0o600 }); }; +const fileOn = (item, fid) => path.join(FILES(), item.id + '-' + fid); +const coverOn = item => path.join(FILES(), item.id + '-cover'); +const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.creator, description: it.description, version: it.version, url: it.url, cover: !!it.cover, added: it.added, updated: it.updated, + files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' ? { icon: it.icon || '' } : {}) }); + +// ---- the per-person bits: which extensions are installed, and each extension's saved settings +const stateFile = () => path.join(users.dir(), 'mystore.json'); +const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {} }; } catch { return { installed: [], data: {} }; } }; +const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 }); + +/** Stream a stored file, with Range support so audio can be skipped through. */ +function send(req, res, file, type, name, download) { + let st; try { st = fs.statSync(file); } catch { return json(res, 404, { error: 'Not found' }); } + const h = { 'content-type': type, 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' }; + if (download) h['content-disposition'] = `attachment; filename="${name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(name)}`; + const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); + if (m && (m[1] || m[2])) { + let a = m[1] ? +m[1] : Math.max(0, st.size - +m[2]), b = m[1] && m[2] ? Math.min(+m[2], st.size - 1) : st.size - 1; + if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); } + res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(file, { start: a, end: b }).pipe(res); + } + res.writeHead(200, { ...h, 'content-length': st.size }); fs.createReadStream(file).pipe(res); +} +/** Save a request body to a file, refusing more than `max` bytes. */ +async function receive(req, file, max) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + if ((+req.headers['content-length'] || 0) > max) throw fail(413, 'That file is too large'); + const tmp = file + '.part'; let n = 0; + req.on('data', c => { n += c.length; if (n > max) req.destroy(fail(413, 'That file is too large')); }); + try { await pipeline(req, fs.createWriteStream(tmp, { mode: 0o600 })); } catch (e) { fs.rm(tmp, { force: true }, () => {}); throw e.code === 413 ? e : fail(e.code === 413 ? 413 : 400, e.message || 'The upload did not finish'); } + fs.renameSync(tmp, file); return n; +} + +// ---- copying a file from the admin's Seafile (the Seafile sign-in the hub already holds for them) +async function fromSeafile(repo, p) { + const token = settings.get('sf_token'); if (!token) throw fail(401, 'Open Files and sign in first, then try again'); + if (!/^[\w-]{8,64}$/.test(repo) || !p || !p.startsWith('/') || p.split('/').includes('..')) throw fail(400, 'Choose a file'); + const call = async url => fetch(url, { headers: { Authorization: 'Token ' + token }, signal: AbortSignal.timeout(30000) }); + const lr = await call(`${SEAFILE()}/api2/repos/${repo}/file/?p=${encodeURIComponent(p)}&reuse=1`); + if (lr.status === 401) throw fail(401, 'Your Files sign-in has expired. Open Files and sign in again.'); if (!lr.ok) throw fail(lr.status === 404 ? 404 : 502, lr.status === 404 ? 'That file was not found in Files' : 'Files answered ' + lr.status); + const link = new URL(JSON.parse(await lr.text()), SEAFILE()); + const r = await fetch(link, { signal: AbortSignal.timeout(30 * 60e3) }); if (!r.ok || !r.body) throw fail(502, 'Files would not send that file (' + r.status + ')'); + if ((+r.headers.get('content-length') || 0) > MAX_FILE) throw fail(413, 'That file is too large'); + return { stream: require('stream').Readable.fromWeb(r.body), name: safeName(p.split('/').pop()) }; +} + +// ---- extensions: one self-contained HTML file, run in a sandboxed frame with this Content-Security-Policy (no hub cookies, no hub address, no other frames) +const EXT_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https:; media-src data: blob: https:; font-src data: https:; connect-src https:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"; +const EXT_META = ''; // (the same rules, for when the page is shown from the hub's own copy of it) +const BRIDGE = EXT_META + ``; +const withBridge = html => /]*>/i.test(html) ? html.replace(/]*>/i, m => m + BRIDGE) : BRIDGE + html; + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current(); + try { + let m; + if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' }); + if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin) + needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null; + if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is'); + const title = str(b.title, 120); if (!title) throw fail(400, 'Give it a title'); + const url = str(b.url, 300); if (url && !/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'The link must start with http'); + const it = old || { id: newId(), kind: b.kind, files: [], cover: false, added: Date.now() }; + Object.assign(it, { title, creator: str(b.creator, 120), description: str(b.description, 2000), version: str(b.version, 30), url, updated: Date.now() }); + if (it.kind === 'extension') it.icon = str(b.icon, 4); + if (!old) { if (l.length >= 2000) throw fail(400, 'That is plenty of items'); l.push(it); } saveItems(l); return json(res, 200, view(it)); + } + if ((m = /^\/items\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { + needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found'); + for (const f of it.files) fs.rmSync(fileOn(it, f.id), { force: true }); fs.rmSync(coverOn(it), { force: true }); saveItems(l.filter(x => x !== it)); return json(res, 200, { ok: true }); + } + if ((m = /^\/items\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // upload one file (the body is the file; its name is in ?name=) or copy one from Seafile (JSON { repo, path }) + needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found'); + if (it.files.length >= MAX_FILES_PER_ITEM) throw fail(400, 'That is plenty of files for one item'); + if (it.kind !== 'music' && it.kind !== 'app' && it.files.length >= 1) throw fail(400, 'This kind of item holds one file. Remove the old one first.'); + const fid = newId(); let name, size; + if (/json/.test(req.headers['content-type'] || '')) { const b = JSON.parse(await readBody(req, 1e4) || '{}'), src = await fromSeafile(String(b.repo || ''), String(b.path || '')); name = src.name; const tmp = fileOn(it, fid) + '.part'; fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); let n = 0; src.stream.on('data', c => { n += c.length; if (n > MAX_FILE) src.stream.destroy(fail(413, 'That file is too large')); }); await pipeline(src.stream, fs.createWriteStream(tmp, { mode: 0o600 })).catch(e => { fs.rm(tmp, { force: true }, () => {}); throw e.code ? e : fail(502, 'The copy did not finish'); }); fs.renameSync(tmp, fileOn(it, fid)); size = n; } + else { name = safeName(u.searchParams.get('name')); size = await receive(req, fileOn(it, fid), it.kind === 'extension' ? MAX_EXT : MAX_FILE); } + const ext = extOf(name); + const bad = it.kind === 'book' && !BOOK_EXT.includes(ext) ? 'A book must be an EPUB, PDF, MOBI, AZW3 or DOCX file' : it.kind === 'music' && !/^(mp3|m4a|aac|flac|ogg|opus|wav)$/.test(ext) ? 'Music must be an MP3, M4A, AAC, FLAC, OGG, OPUS or WAV file' : it.kind === 'extension' && ext !== 'html' ? 'An extension is one .html file' : ''; + if (bad) { fs.rm(fileOn(it, fid), { force: true }, () => {}); throw fail(400, bad); } + it.files.push({ id: fid, name, size, ext }); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it)); + } + if ((m = /^\/items\/([0-9a-f]{12})\/files\/([0-9a-f]{12})$/.exec(p))) { + const l = loadItems(), it = l.find(x => x.id === m[1]), f = it && it.files.find(x => x.id === m[2]); if (!f) throw fail(404, 'Not found'); + if (req.method === 'DELETE') { needAdmin(me); fs.rmSync(fileOn(it, f.id), { force: true }); it.files = it.files.filter(x => x !== f); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it)); } + if (req.method === 'GET') return send(req, res, fileOn(it, f.id), it.kind === 'extension' ? 'application/octet-stream' : TYPES[f.ext] || 'application/octet-stream', f.name, u.searchParams.get('dl') === '1' || !/^(audio|application\/pdf)/.test(TYPES[f.ext] || '')); + } + if ((m = /^\/items\/([0-9a-f]{12})\/cover$/.exec(p))) { + const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Not found'); + if (req.method === 'POST') { // the picture is the body; its type must be a picture + needAdmin(me); const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (!Object.values(IMAGE).includes(type)) throw fail(400, 'The cover must be a JPEG, PNG, GIF or WebP picture'); + await receive(req, coverOn(it), MAX_COVER); it.cover = type; it.updated = Date.now(); saveItems(l); return json(res, 200, view(it)); + } + if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); } + if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); } + } + if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf + if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab'); + const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book'); + const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} } + try { return json(res, 200, books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover)); } catch (e) { throw fail(400, e.message); } + } + // ---- extensions + if ((m = /^\/ext\/([0-9a-f]{12})\/page$/.exec(p)) && req.method === 'GET') { // the page itself, framed by the hub + const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension' || !it.files[0]) throw fail(404, 'Not found'); + const html = withBridge(fs.readFileSync(fileOn(it, it.files[0].id), 'utf8')); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': EXT_CSP, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' }); return res.end(html); + } + if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') { + const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); + const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') delete s.data[it.id]; saveState(s); return json(res, 200, { installed: s.installed }); + } + if ((m = /^\/ext\/([0-9a-f]{12})\/data$/.exec(p))) { // the extension's own saved settings for this person + const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); + if (req.method === 'GET') return json(res, 200, s.data[it.id] || {}); + if (req.method === 'POST') { const b = JSON.parse(await readBody(req, MAX_STATE + 1000) || '{}'); if (!b || typeof b !== 'object' || Array.isArray(b)) throw fail(400, 'Bad data'); if (JSON.stringify(b).length > MAX_STATE) throw fail(413, 'That is more than an extension may keep (256 KB)'); s.data[it.id] = b; saveState(s); return json(res, 200, { ok: true }); } + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} + +module.exports = { handle, loadItems, EXT_CSP }; diff --git a/package.json b/package.json index 38eaaa7..0a2bd4f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index 5a7513a..b668673 100644 --- a/public/app.js +++ b/public/app.js @@ -93,6 +93,8 @@ const ICONS = { shuffle: '', more: '', cal: '', + store: '', + download: '', chef: '', people: '', search: '', @@ -162,8 +164,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation -const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; -const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; +const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; +const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1]; @@ -173,14 +175,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0])); const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1]; /** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */ -const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Media', ['listen', 'reading', 'video']], ['', ['settings']]]; +const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; const navGroups = (tabs = navTabs()) => { const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) })); const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0]))); return out.filter(g => g.tabs.length); }; const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state -const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; +const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; // The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More". const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail']; const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); }; @@ -250,7 +252,7 @@ function drawSubnav(name) { const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; let curRoute = ''; -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, recipes: args => viewRecipes(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, recipes: args => viewRecipes(args), store: args => viewStore(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; @@ -318,6 +320,7 @@ function homeApps() { office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'), audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'), podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'), + store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'), recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'), contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'), radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'), @@ -327,7 +330,7 @@ function homeApps() { mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), }; T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); - const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video']]]; + const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `

${esc(label)}

${ids.map(id => T[id]()).join('')}
`).join(''); } diff --git a/public/index.html b/public/index.html index 742ea89..35513c9 100644 --- a/public/index.html +++ b/public/index.html @@ -76,6 +76,7 @@ + diff --git a/public/librivox-view.js b/public/librivox-view.js index 2e1fcd2..c7186ec 100644 --- a/public/librivox-view.js +++ b/public/librivox-view.js @@ -46,13 +46,13 @@ function lvPlay(b, at) { lvApi('/shelf', lvOpt('POST', { id: b.id })).catch(() => {}); // a book you start is kept on your shelf } -async function viewFreeBooks() { - const v = $('#view'); v.innerHTML = '

Free audiobooks

Loading…

'; +async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '

Free audiobooks

Loading…

'; try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `
${icon('back')}Audiobooks

${esc(e.message)}

`; return; } - if (!location.hash.startsWith('#/audio/free')) return; + if (!here()) return; const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 }; const card = b => lvCard(b); - v.innerHTML = `
+ v.innerHTML = `
${host ? '' : ``}

Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.

`; const known = new Map(lvShelf.map(b => [b.id, b])); diff --git a/public/music.js b/public/music.js index 2d19c02..9dc817f 100644 --- a/public/music.js +++ b/public/music.js @@ -13,7 +13,7 @@ const px = { if (!r.ok) throw new Error('Plex error ' + r.status); return (await r.json()).MediaContainer || {}; }, - art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv|store)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', // All music libraries on the server; the chosen one is remembered per server. async sections() { if (px.secs) return px.secs; diff --git a/public/radio-view.js b/public/radio-view.js index cf6bfaf..1913adf 100644 --- a/public/radio-view.js +++ b/public/radio-view.js @@ -38,17 +38,17 @@ function rdoApplyNow(t, n) { setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); }); function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `Now playing${esc(n.title)}${n.artist ? ' · ' + esc(n.artist) + '' : ''}` : 'Live'; } -async function viewRadio() { - const v = $('#view'); v.innerHTML = '

Radio

Loading…

'; +async function viewRadio(host) { // host: { el, hash } when shown inside the Store tab + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '

Radio

Loading…

'; let feat, mine; try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `

Radio

${esc(e.message)}

`; return; } - if (!location.hash.startsWith('#/radio')) return; + if (!here()) return; rdoSaved = mine; const S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 }; const saved = id => rdoSaved.some(x => x.id === id); const row = (s, fav) => `
${s.featured ? '' : ``}
`; const j = feat[0]; - v.innerHTML = `

Radio

+ v.innerHTML = `
${host ? '' : '

Radio

'}
Featured

${esc(j.name)}

${esc(rdoMeta(j))}

Live
${icon('ext')}Website
diff --git a/public/reader.js b/public/reader.js index b6cdbbd..7ab1f28 100644 --- a/public/reader.js +++ b/public/reader.js @@ -27,7 +27,7 @@ const rdTitleOf = f => f.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' '); // ======================================================= Library async function viewReader(args = []) { if (args[0] === 'open' && args[1]) return rdOpen(args[1]); - if (args[0] === 'store') return rdStore(); + if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore(); const v = $('#view'); v.innerHTML = '

Books

Loading…

'; try { await rdLibrary(); } catch (e) { @@ -151,14 +151,14 @@ async function rdMobiOpen(u8) { } // ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue -async function rdStore() { - const v = $('#view'); v.innerHTML = '

Book store

Loading…

'; +async function rdStore(host) { // host: { el, hash } when shown inside the Store tab + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '

Book store

Loading…

'; let sources, mine; try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `
${icon('back')}Books

${esc(e.message)}

`; return; } - if (!location.hash.startsWith('#/read/store')) return; + if (!here()) return; const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id])); const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 }; - v.innerHTML = `
${icon('back')}Books

Book store

${ME && ME.role === 'admin' ? `` : ''}
+ v.innerHTML = `
${host ? '' : `${icon('back')}Books

Book store

`}${ME && ME.role === 'admin' ? `` : ''}

Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.

`; diff --git a/public/store-view.js b/public/store-view.js new file mode 100644 index 0000000..ac9204e --- /dev/null +++ b/public/store-view.js @@ -0,0 +1,175 @@ +'use strict'; +// ---- Store: everything you can get, in one place. Books (free sources), free audiobooks, radio stations, and My Store: content the admin adds themselves (books, +// music, apps to download, and extensions that add features to the hub). The first three are the same screens as before, shown inside this one. +const stApi = (p, opts) => jfetch('/api/store/my' + p, opts); +const stOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); +const ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog']]; +const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2]; +const stSize = b => fmtSize(+b || 0); +const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : ''; +const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; }; +let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all'; + +/** The sections this person may open (each needs its own tab's permission, except My Store). */ +const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]); + +async function viewStore(args = []) { + const secs = stSections(), want = args[0] === 'ext' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0]; + const v = $('#view'); + v.innerHTML = `

Store

${secs.map(s => `${esc(s[1])}`).join('')}
`; + const el = $('#stbody'), host = { el, hash: '#/store/' + sec }; + if (sec === 'books') return rdStore(host); + if (sec === 'audiobooks') return viewFreeBooks(host); + if (sec === 'radio') return viewRadio(host); + if (args[0] === 'ext' && args[1]) return stRunExt(el, args[1]); + return stMine(el); +} + +// ---------------------------------------------------------------- My Store +async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; } +async function stMine(el) { + try { await stLoad(); } catch (e) { el.innerHTML = `

${esc(e.message)}

`; return; } + const draw = () => { + if (!$('#stbody')) return; + const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter); + el.innerHTML = `

Books, music, apps and extensions added just for this hub.

${stAdmin ? `` : ''}
+ ${mine.length ? `

My extensions

${mine.map(i => stCard(i, true)).join('')}
` : ''} +
All${ST_KINDS.map(k => `${k[1]}`).join('')}
+ ${shown.length ? `
${shown.map(i => stCard(i)).join('')}
` : `

${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}

`}`; + el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); }); + el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); }); + if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw); + }; + draw(); +} +const stCard = (it, run) => `${it.cover ? `` : esc((it.kind === 'extension' && it.icon) || (it.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())} + ${esc(it.title)}${esc([stKind(it.kind)[1].replace(/s$/, ''), it.creator].filter(Boolean).join(' · '))}`; + +function stDetail(it, redraw) { + const k = it.kind, f0 = it.files[0], isMine = k === 'extension' && stInstalled.includes(it.id); + const url = f => `/api/store/my/items/${it.id}/files/${f.id}`; + const el = openSheet(it.title, [stKind(k)[1].replace(/s$/, ''), it.creator, it.version && 'v' + it.version].filter(Boolean).join(' · '), `
${it.cover ? `` : ''} +
${it.description ? `

${esc(it.description).replace(/\n/g, '
')}

` : '

No description.

'}
+ ${k === 'music' || k === 'app' ? `
${it.files.map((f, i) => `
${esc(f.name)}${esc(stSize(f.size))}${k === 'music' ? `` : ''}${icon('download')}
`).join('') || '

No files.

'}
` : ''} +
+ ${k === 'book' && f0 ? `${allowed('read') ? '' : ''}Download` : ''} + ${k === 'music' && it.files.length ? `` : ''} + ${k === 'app' && it.url ? `Open link` : ''} + ${k === 'extension' && f0 ? `${isMine ? '' : ''}` : ''} + ${stAdmin ? '' : ''}
`); + const err = t => { el.querySelector('#sterr').textContent = t; }; + const tracks = () => it.files.map(f => ({ key: `my:${it.id}:${f.id}`, title: f.name.replace(/\.[^.]+$/, '').replace(/^\d+[\s._-]+/, ''), artist: it.creator || '', album: it.title, thumb: stCover(it), src: url(f), nocache: true })); + const play = i => { playQueue(tracks(), i || 0); closeSheet(); }; + if (el.querySelector('#stplay')) el.querySelector('#stplay').onclick = () => play(0); + el.querySelectorAll('[data-play]').forEach(b => b.onclick = () => play(+b.dataset.play)); + if (el.querySelector('#stlib')) el.querySelector('#stlib').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const r = await stApi(`/items/${it.id}/to-library`, stOpt('POST')); closeSheet(); toast(r.duplicate ? 'Already in your library' : 'Added to your library', { label: 'Open', run: () => { location.hash = '#/read/open/' + r.id; } }); } catch (x) { err(x.message); b.disabled = false; } }; + if (el.querySelector('#stinst')) el.querySelector('#stinst').onclick = async e => { const b = e.currentTarget; try { if (!isMine) { b.disabled = true; stInstalled = (await stApi(`/ext/${it.id}/install`, stOpt('POST'))).installed; } closeSheet(); location.hash = '#/store/ext/' + it.id; } catch (x) { err(x.message); b.disabled = false; } }; + if (el.querySelector('#stun')) el.querySelector('#stun').onclick = () => { closeSheet(); confirmSheet('Remove this extension?', 'Its saved settings are forgotten too.', 'Remove', async () => { try { stInstalled = (await stApi(`/ext/${it.id}/uninstall`, stOpt('POST'))).installed; toast('Removed'); redraw(); } catch (x) { toast(x.message); } }); }; + if (el.querySelector('#stedit')) el.querySelector('#stedit').onclick = () => { closeSheet(); stEdit(it, redraw); }; +} + +// ---- adding and changing (admin) +function stEdit(it, redraw) { + const x = it || { kind: 'book', title: '', creator: '', description: '', version: '', url: '', icon: '', files: [], cover: false }; + let created = false; + const el = openSheet(it ? 'Edit item' : 'Add to My Store', '', `
+ ${it ? '' : ``} + + + + + +
+
${allowed('files') ? `` : ''}
+
${x.cover ? 'A cover is set' : ''}
+
${it ? '' : ''}
`); + const $e = s => el.querySelector(s), err = t => { $e('#sterr').textContent = t; }; + let kind = x.kind; const files = [], existing = [...x.files]; let cover = null; + const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stapp').hidden = kind !== 'app' && kind !== 'extension'; $e('#stext').hidden = kind !== 'extension'; + $e('#stup').accept = kind === 'book' ? '.epub,.pdf,.mobi,.azw3,.azw,.docx' : kind === 'music' ? 'audio/*,.mp3,.m4a,.flac,.ogg,.opus,.wav' : kind === 'extension' ? '.html' : ''; $e('#stup').multiple = kind === 'music' || kind === 'app'; }; + const drawFiles = () => { $e('#stfl').innerHTML = [...existing.map((f, i) => [f.name, stSize(f.size), 'e', i]), ...files.map((f, i) => [f.name || f.path.split('/').pop(), f.size != null ? stSize(f.size) : 'from Files', 'n', i])].map(([n, s, t, i]) => `
${esc(n)}${esc(s)}
`).join('') || '

No files yet.

'; + el.querySelectorAll('#stfl [data-t]').forEach(b => b.onclick = () => { (b.dataset.t === 'e' ? existing : files).splice(+b.dataset.i, 1); drawFiles(); }); }; + if (!it) $e('#stk').onchange = kindUi; kindUi(); drawFiles(); + $e('#stup').onchange = e => { const single = kind !== 'music' && kind !== 'app'; if (single) { files.length = 0; existing.length = 0; } for (const f of e.target.files) files.push(f); e.target.value = ''; drawFiles(); }; + if ($e('#stsf')) $e('#stsf').onclick = async () => { const p = await stPickFile(); if (!p) return; if (kind !== 'music' && kind !== 'app') { files.length = 0; existing.length = 0; } files.push({ path: p.path, repo: p.repo, name: p.path.split('/').pop() }); drawFiles(); }; + $e('#stcv').onchange = e => { cover = e.target.files[0] || null; $e('#stcvn').textContent = cover ? cover.name : ''; }; + $e('#stx').onclick = closeSheet; + if ($e('#stdel')) $e('#stdel').onclick = () => { closeSheet(); confirmSheet('Delete this item?', it.title, 'Delete', async () => { try { await stApi('/items/' + it.id, { method: 'DELETE' }); toast('Deleted'); await stLoad(); redraw(); } catch (e) { toast(e.message); } }); }; + $e('#stf').onsubmit = async e => { + e.preventDefault(); const ok = $e('#stok'); ok.disabled = true; err(''); + try { + if (!files.length && !existing.length && kind !== 'app') throw new Error('Add the file first'); + let saved = await stApi('/items', stOpt('POST', { id: it && it.id, kind, title: $e('#stt').value, creator: $e('#stc').value, description: $e('#std').value, version: $e('#stv').value, url: $e('#stu').value, icon: $e('#sti') ? $e('#sti').value : '' })); + if (!it) created = true; + for (const f of x.files) if (!existing.some(y => y.id === f.id)) saved = await stApi(`/items/${saved.id}/files/${f.id}`, { method: 'DELETE' }); + let n = 0; + for (const f of files) { + ok.textContent = `Adding ${++n} of ${files.length}…`; + saved = f instanceof File ? await stApi(`/items/${saved.id}/files?name=${encodeURIComponent(f.name)}`, { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } }) : await stApi(`/items/${saved.id}/files`, stOpt('POST', { repo: f.repo, path: f.path })); + } + if (cover) saved = await stApi(`/items/${saved.id}/cover`, { method: 'POST', body: cover, headers: { 'content-type': cover.type } }); + closeSheet(); toast(it ? 'Saved' : 'Added'); await stLoad(); redraw(); + } catch (x2) { err(x2.message + (created ? ' The item was created without it: open it to finish.' : '')); ok.disabled = false; ok.textContent = it ? 'Save' : 'Add'; } + }; +} + +/** Pick one file from your Seafile libraries. Resolves { repo, path } or null. */ +function stPickFile() { + return new Promise(resolve => { + if (!sf.token()) { toast('Open Files and sign in first'); return resolve(null); } + let repo = null, dir = '/', done = false; + const el = openSheet('Choose a file from Files', '', '
'); const finish = v => { done = true; closeSheet(); resolve(v); }; + el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); + const draw = async () => { + const box = el.querySelector('#stpk'); box.innerHTML = '

Loading…

'; + try { + if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name)); + box.innerHTML = `
${repos.map(r => ``).join('')}
`; + box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; dir = '/'; draw(); }); + } else { + const items = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).sort((a, b) => (a.type === b.type ? 0 : a.type === 'dir' ? -1 : 1) || a.name.localeCompare(b.name)); + box.innerHTML = `

${esc(dir)}

+ ${items.map(x => ``).join('')}
`; + box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = (dir === '/' ? '' : dir) + '/' + b.dataset.d; draw(); }); + box.querySelectorAll('[data-f]').forEach(b => b.onclick = () => finish({ repo, path: (dir === '/' ? '' : dir) + '/' + b.dataset.f })); + box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); }; + } + box.querySelector('#pcx').onclick = () => finish(null); + } catch (e) { box.innerHTML = `

${esc(e.message)}

`; } + }; + draw(); + }); +} + +// ---------------------------------------------------------------- running an extension +// The extension is one HTML file in a sandboxed frame (no access to your sign-in or the hub's data). It talks to the hub only through the messages below. +async function stRunExt(el, id) { + try { await stLoad(); } catch (e) { el.innerHTML = `

${esc(e.message)}

`; return; } + const it = stItems.find(x => x.id === id && x.kind === 'extension'); + if (!it) { el.innerHTML = '

That extension is not in the store any more.

'; return; } + if (!stInstalled.includes(id)) { el.innerHTML = `

Install “${esc(it.title)}” from My Store first.

`; return; } + el.innerHTML = `
${icon('back')}My Store${esc(it.title)}
+ `; + const frame = el.querySelector('#stframe'); let data = null, timer; + // (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's) + fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `

${esc(e.message)}

`; }); + const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({}))); + const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); }; + const onMsg = async e => { + if (!document.body.contains(frame)) return removeEventListener('message', onMsg); + if (e.source !== frame.contentWindow || !e.data || !e.data.hub) return; + const { id: mid, method, args = [] } = e.data; let result, error; + try { + if (method === 'storage.get') result = (await load())[String(args[0])] ?? null; + else if (method === 'storage.all') result = { ...(await load()) }; + else if (method === 'storage.set') { const d = await load(); d[String(args[0]).slice(0, 100)] = args[1]; if (JSON.stringify(d).length > 256 * 1024) { delete d[String(args[0]).slice(0, 100)]; throw new Error('Out of space (256 KB)'); } save(); result = true; } + else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; } + else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; } + else if (method === 'user') result = { name: ME ? ME.name : '' }; + else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; } + else throw new Error('Unknown request'); + } catch (x) { error = x.message; } + frame.contentWindow.postMessage({ hub: 1, id: mid, result, error }, '*'); + }; + addEventListener('message', onMsg); +} diff --git a/public/style.css b/public/style.css index caf0cd2..76e5637 100644 --- a/public/style.css +++ b/public/style.css @@ -961,3 +961,7 @@ body.vizopen { overflow: hidden; } .ctsug { position: absolute; z-index: 30; left: 0; right: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.15); max-height: 220px; overflow: auto; } .ctsug button { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; } .ctsug button:hover, .ctsug button.on { background: rgba(127,127,127,.15); } .pdhit { margin-bottom: 8px; } mark { background: rgba(255, 200, 0, .45); color: inherit; border-radius: 3px; } + +/* Store */ +#stbody .wrap { padding: 0; max-width: none; } +#stbody .vimg img { width: 100%; height: 100%; object-fit: cover; } diff --git a/public/sw.js b/public/sw.js index 9821cd7..b449a91 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v51'; +const CACHE = 'hub-v52'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/server.js b/server.js index 1b64272..8688977 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,7 @@ const savefiles = require('./savefiles'); // "Save to Files": copy books, lesson const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies) const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) +const mystore = require('./mystore'); // My Store: your own books, music, apps and extensions for everyone to use const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM) const librivox = require('./librivox'); // free audiobooks from LibriVox const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes @@ -553,6 +554,7 @@ const ACCESS = [ [/^\/api\/bible\//, ['bible']], [/^\/api\/read\//, ['read']], [/^\/api\/radio\//, ['radio']], + [/^\/api\/store\/my(\/|$)/, ['store']], [/^\/api\/recipes(\/|$)/, ['recipes']], [/^\/api\/contacts(\/|$)/, ['contacts']], [/^\/api\/signal\//, ['signal']], @@ -650,6 +652,7 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/read/')) return books.handle(req, res); if (pathname.startsWith('/api/radio/')) return radio.handle(req, res); + if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res); if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res); if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res); if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res); diff --git a/tools/mystore-test.js b/tools/mystore-test.js new file mode 100644 index 0000000..82f22fc --- /dev/null +++ b/tools/mystore-test.js @@ -0,0 +1,55 @@ +// My Store: the admin adds items and files, everyone reads and downloads, books go onto a shelf, music streams with ranges, extensions are served sandboxed. +const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ms-')); +const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-msfx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]); +const epub = fs.readFileSync(path.join(fx, 'test.epub')), gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); +const users = require('../users'), books = require('../books'), ms = require('../mystore'); +const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['store', 'read'], handle: 'Yg' }) }; +const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => req.url.startsWith('/api/read/') ? books.handle(req, res) : ms.handle(req, res))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port, B = base + '/api/store/my', bob = { 'x-user': 'b' }; + const post = (p, b, h = {}) => fetch(B + p, { method: 'POST', body: JSON.stringify(b), headers: { 'content-type': 'application/json', ...h } }); + const put = (p, body, h = {}) => fetch(B + p, { method: 'POST', body, headers: h }); + try { + assert.equal((await post('/items', { kind: 'book', title: 'X' }, bob)).status, 403, 'only the admin adds things'); + assert.equal((await post('/items', { kind: 'nope', title: 'X' })).status, 400); assert.equal((await post('/items', { kind: 'book', title: ' ' })).status, 400); + // a book: one file, goes on the shelf + let book = await (await post('/items', { kind: 'book', title: 'My Own Book', creator: 'Me', description: 'Hello' })).json(); assert.match(book.id, /^[0-9a-f]{12}$/); + assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub, bob)).status, 403); + assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub)).status, 400, 'a book must be a book file'); + book = await (await put(`/items/${book.id}/files?name=../../my%20book.epub`, epub)).json(); assert.equal(book.files[0].name, 'my book.epub', 'a name cannot reach other folders'); assert.equal(book.files[0].size, epub.length); + assert.equal((await put(`/items/${book.id}/files?name=two.epub`, epub)).status, 400, 'a book holds one file'); + assert.equal((await put(`/items/${book.id}/cover`, 'not a picture', { 'content-type': 'text/html' })).status, 400); book = await (await put(`/items/${book.id}/cover`, gif, { 'content-type': 'image/gif' })).json(); assert.equal(book.cover, true); + let r = await fetch(`${B}/items/${book.id}/cover`, { headers: bob }); assert.equal(r.headers.get('content-type'), 'image/gif'); + let list = await (await fetch(B + '/items', { headers: bob })).json(); assert.equal(list.items.length, 1); assert.equal(list.admin, false); assert.equal((await (await fetch(B + '/items')).json()).admin, true); + r = await fetch(`${base}/api/store/my/items/${book.id}/files/${book.files[0].id}`, { headers: bob }); assert.equal(r.status, 200); assert.match(r.headers.get('content-disposition'), /attachment/); assert.equal(Buffer.from(await r.arrayBuffer()).length, epub.length); + r = await fetch(`${B}/items/${book.id}/to-library`, { method: 'POST', headers: bob }); const sh = await r.json(); assert.equal(r.status, 200); assert.equal(sh.title, 'My Own Book'); assert.equal(sh.cover, true); + assert.equal((await (await fetch(base + '/api/read/list', { headers: bob })).json()).length, 1, 'it is on Bob\'s shelf, not Ann\'s'); assert.equal((await (await fetch(base + '/api/read/list')).json()).length, 0); + // music: several files, streamed with ranges + let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij'); + alb = await (await put(`/items/${alb.id}/files?name=01.mp3`, mp3)).json(); alb = await (await put(`/items/${alb.id}/files?name=02.mp3`, mp3)).json(); assert.equal(alb.files.length, 2); + r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { ...bob, range: 'bytes=2-5' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '2345'); assert.equal(r.headers.get('content-range'), 'bytes 2-5/20'); assert.equal(r.headers.get('content-type'), 'audio/mpeg'); + r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=-4' } }); assert.equal(await r.text(), 'ghij'); assert.equal((await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=50-60' } })).status, 416); + alb = await (await fetch(`${B}/items/${alb.id}/files/${alb.files[1].id}`, { method: 'DELETE' })).json(); assert.equal(alb.files.length, 1); + // an app: any file, plus a link + let app = await (await post('/items', { kind: 'app', title: 'Tool', version: '1.2', url: 'https://example.com/tool' })).json(); app = await (await put(`/items/${app.id}/files?name=tool.apk`, 'apk-bytes')).json(); assert.equal(app.version, '1.2'); + assert.equal((await post('/items', { kind: 'app', title: 'Bad', url: 'javascript:alert(1)' })).status, 400); + // an extension: one html file, framed with a sandbox + let ext = await (await post('/items', { kind: 'extension', title: 'Counter', icon: '#' })).json(); assert.equal((await put(`/items/${ext.id}/files?name=a.js`, 'x')).status, 400); + ext = await (await put(`/items/${ext.id}/files?name=counter.html`, 'chi')).json(); + r = await fetch(`${B}/ext/${ext.id}/page`, { headers: bob }); const page = await r.text(); assert.match(r.headers.get('content-security-policy'), /^sandbox allow-scripts/); assert.match(r.headers.get('content-security-policy'), /default-src 'none'/); assert.ok(!/allow-same-origin/.test(r.headers.get('content-security-policy')), 'no same-origin: it cannot reach the hub'); + assert.match(page, /window\.hub=/); assert.match(page, /hi/); + assert.deepEqual((await (await post(`/ext/${ext.id}/install`, {}, bob)).json()).installed, [ext.id]); assert.deepEqual((await (await fetch(B + '/items')).json()).installed, [], 'installing is per person'); + assert.equal((await post(`/ext/${ext.id}/data`, { n: 3 }, bob)).status, 200); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), { n: 3 }); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`)).json(), {}); + assert.equal((await post(`/ext/${ext.id}/data`, { big: 'x'.repeat(300000) }, bob)).status, 413); + assert.deepEqual((await (await post(`/ext/${ext.id}/uninstall`, {}, bob)).json()).installed, []); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), {}, 'uninstalling forgets its settings'); + assert.equal((await fetch(`${B}/ext/${book.id}/page`)).status, 404, 'only extensions are framed'); + // a Seafile copy needs a sign-in + r = await post(`/items/${alb.id}/files`, { repo: 'a'.repeat(36), path: '/x.mp3' }); assert.equal(r.status, 401); + // changing, deleting + book = await (await post('/items', { id: book.id, title: 'Renamed' })).json(); assert.equal(book.title, 'Renamed'); assert.equal(book.creator, '', 'details are replaced as a whole'); + assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE', headers: bob })).status, 403); assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE' })).status, 200); + assert.equal(fs.readdirSync(path.join(process.env.DATA_DIR, 'mystore', 'files')).filter(f => f.startsWith(alb.id)).length, 0, 'its files are removed too'); + console.log('mystore ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +}); diff --git a/users.js b/users.js index f5907de..a109cb5 100644 --- a/users.js +++ b/users.js @@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users'); fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). -const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; +const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; const ALWAYS = ['home', 'settings']; -const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; +const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };