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 @@ +
Loading…
Loading…
${esc(e.message)}
Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.
Loading…
Loading…
${esc(e.message)}
${esc(rdoMeta(j))}
Loading…
Loading…
Loading…
${esc(e.message)}
Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.
${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 = ` + ${mine.length ? `${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.description).replace(/\n/g, '
')}
No description.
'}No files.
'}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 = `${esc(dir)}
${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 = ` + `; + 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`, '