From efd17f42635c1df3cd88d17da0d9332c6138610b Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:25:39 -0400 Subject: [PATCH] Extensions: hub bridge for tasks, contacts and calendar (asked of the person once), Sites in My Store, and four example extensions - hub.tasks / hub.contacts / hub.calendar in the extension sandbox; each kind of access is approved by the person and remembered per extension - My Store: Sites kind (any web link, optionally shown inside the hub) - Examples: notes and checklists, habit tracker, kitchen converter and scaler, weather and verse of the day Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 +- docs/extensions/kitchen.html | 32 +++++++++++++++++ docs/extensions/notes.html | 42 +++++++++++++++++++++++ docs/extensions/tracker.html | 23 +++++++++++++ docs/extensions/weather.html | 24 +++++++++++++ mystore.js | 25 ++++++++++---- public/store-view.js | 66 ++++++++++++++++++++++++++++++------ tools/mystore-test.js | 7 ++++ 8 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 docs/extensions/kitchen.html create mode 100644 docs/extensions/notes.html create mode 100644 docs/extensions/tracker.html create mode 100644 docs/extensions/weather.html diff --git a/README.md b/README.md index b433d11..a3bf0c6 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,8 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free - **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. +- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way). +- **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)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one). 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/kitchen.html b/docs/extensions/kitchen.html new file mode 100644 index 0000000..3dcce1a --- /dev/null +++ b/docs/extensions/kitchen.html @@ -0,0 +1,32 @@ +Kitchen + +

Convert

+
to
+

Scale a recipe

+
Fromservings to
+ +
+ diff --git a/docs/extensions/notes.html b/docs/extensions/notes.html new file mode 100644 index 0000000..aecf565 --- /dev/null +++ b/docs/extensions/notes.html @@ -0,0 +1,42 @@ +Notes + +
+

Pick a note, or make a new one.

+ diff --git a/docs/extensions/tracker.html b/docs/extensions/tracker.html new file mode 100644 index 0000000..9344a1d --- /dev/null +++ b/docs/extensions/tracker.html @@ -0,0 +1,23 @@ +Habits +

Habits

+
+ diff --git a/docs/extensions/weather.html b/docs/extensions/weather.html new file mode 100644 index 0000000..8152729 --- /dev/null +++ b/docs/extensions/weather.html @@ -0,0 +1,24 @@ +Today + +
Pick a city for the weather.
+
Verse of the day
Loading…
+ diff --git a/mystore.js b/mystore.js index 44256b1..3259177 100644 --- a/mystore.js +++ b/mystore.js @@ -12,7 +12,8 @@ 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 KINDS = ['book', 'music', 'app', 'extension', 'link']; +const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for 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' }; @@ -32,11 +33,11 @@ const saveItems = l => { fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }) 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 || '' } : {}) }); + files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' || it.kind === 'link' ? { icon: it.icon || '' } : {}), ...(it.kind === 'link' ? { embed: !!it.embed } : {}) }); // ---- 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 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 : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } }; const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 }); /** Stream a stored file, with Range support so audio can be skipped through. */ @@ -80,7 +81,10 @@ const EXT_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; de const EXT_META = ''; // (the same rules, for when the page is shown from the hub's own copy of it) const BRIDGE = EXT_META + ``; +window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])}, +tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}}, +contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}}, +calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()`; const withBridge = html => /]*>/i.test(html) ? html.replace(/]*>/i, m => m + BRIDGE) : BRIDGE + html; async function handle(req, res) { @@ -93,9 +97,11 @@ async function handle(req, res) { 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'); + if ((old ? old.kind : b.kind) === 'link' && !url) throw fail(400, 'Paste the address of the site'); 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 (it.kind === 'extension' || it.kind === 'link') it.icon = str(b.icon, 4); + if (it.kind === 'link') it.embed = !!b.embed; 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') { @@ -105,7 +111,7 @@ async function handle(req, res) { 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.'); + if (it.kind === 'link') throw fail(400, 'A site has no files'); 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); } @@ -142,7 +148,12 @@ async function handle(req, res) { } 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 }); + 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]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed }); + } + if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub + 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, { grants: s.grants[it.id] || [] }); + if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'); s.grants[it.id] = [...new Set((Array.isArray(b.grants) ? b.grants : []).filter(x => PERMS.includes(x)))]; saveState(s); return json(res, 200, { grants: s.grants[it.id] }); } } 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(); diff --git a/public/store-view.js b/public/store-view.js index ac9204e..edb56a3 100644 --- a/public/store-view.js +++ b/public/store-view.js @@ -3,7 +3,7 @@ // 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 ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog'], ['link', 'Sites', 'link']]; 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}` : ''; @@ -14,7 +14,7 @@ let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all'; 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 secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? '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 }; @@ -22,6 +22,7 @@ async function viewStore(args = []) { if (sec === 'audiobooks') return viewFreeBooks(host); if (sec === 'radio') return viewRadio(host); if (args[0] === 'ext' && args[1]) return stRunExt(el, args[1]); + if (args[0] === 'site' && args[1]) return stRunSite(el, args[1]); return stMine(el); } @@ -42,7 +43,7 @@ async function stMine(el) { }; draw(); } -const stCard = (it, run) => `${it.cover ? `` : esc((it.kind === 'extension' && it.icon) || (it.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())} +const stCard = (it, run) => `${it.cover ? `` : esc(((it.kind === 'extension' || it.kind === 'link') && 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) { @@ -51,10 +52,11 @@ function stDetail(it, redraw) { 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 === 'link' ? `

${esc(it.url)}

` : ''}
${k === 'book' && f0 ? `${allowed('read') ? '' : ''}Download` : ''} ${k === 'music' && it.files.length ? `` : ''} ${k === 'app' && it.url ? `Open link` : ''} + ${k === 'link' ? `New tab` : ''} ${k === 'extension' && f0 ? `${isMine ? '' : ''}` : ''} ${stAdmin ? '' : ''}
`); const err = t => { el.querySelector('#sterr').textContent = t; }; @@ -63,6 +65,7 @@ function stDetail(it, redraw) { 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('#stopen')) el.querySelector('#stopen').onclick = () => { closeSheet(); if (it.embed) location.hash = '#/store/site/' + it.id; else window.open(it.url, '_blank', 'noopener,noreferrer'); }; 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); }; @@ -77,15 +80,17 @@ function stEdit(it, redraw) { - + + + -
-
${allowed('files') ? `` : ''}
+
+
${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'; + const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stver').hidden = kind !== 'app' && kind !== 'extension'; $e('#stlnk').hidden = kind !== 'app' && kind !== 'link'; $e('#stul').textContent = kind === 'link' ? 'Address of the site' : 'Link (optional)'; $e('#stemb').hidden = kind !== 'link'; $e('#stext').hidden = kind !== 'extension' && kind !== 'link'; $e('#stfiles').hidden = kind === 'link'; $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(); }); }; @@ -98,8 +103,8 @@ function stEdit(it, redraw) { $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 (!files.length && !existing.length && kind !== 'app' && kind !== 'link') 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, embed: !!$e('#ste').checked, 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; @@ -155,6 +160,36 @@ async function stRunExt(el, id) { 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); }; + // What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed. + const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' }; + let grants = null; const refused = new Set(); + const allowedTo = async perm => { + if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants; + if (grants.includes(perm)) return true; if (refused.has(perm) || !allowed(perm.split('.')[0] === 'calendar' ? 'calendar' : perm.split('.')[0])) return false; + const yes = await new Promise(ok => { let done = false; const el2 = openSheet('Allow access?', it.title, `

“${esc(it.title)}” would like to ${esc(WHY[perm])}.

`); + const fin = v => { if (!done) { done = true; closeSheet(); ok(v); } }; el2.querySelector('#pyes').onclick = () => fin(true); el2.querySelector('#pno').onclick = () => fin(false); el2.querySelector('.scrim').addEventListener('click', () => fin(false)); }); + if (!yes) { refused.add(perm); return false; } + grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true; + }; + const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }); + const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : ''; + const hubCall = async (method, a = {}) => { + const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method]; + if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {}; + if (!(await allowedTo(need))) throw new Error('The person did not allow that'); + const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; }; + if (method === 'tasks.lists') return (await jf('/api/tasks/lists')).lists.map(l => ({ id: l.id, name: l.name })); + if (method === 'tasks.list') return (await jf('/api/tasks?list=' + encodeURIComponent(txt(a.list, 80)))).map(t => ({ id: t.id, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done })); + if (method === 'tasks.add') { const l = await localList(a.list); if (!l) throw new Error('No task list'); await jf('/api/tasks', pj('POST', { list: l.id, fields: { title: txt(a.title, 200), notes: txt(a.notes, 2000), due: ymd(a.due), done: false } })); return { ok: true, list: l.name }; } + if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; } + if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday })); + if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; } + if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t }); + return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); } + if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + if (!re.test(a.start || '')) throw new Error('start must be ' + (allDay ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH:MM')); const end = re.test(a.end || '') ? a.end : a.start; + await jf('/api/cal/event', pj('POST', { cal: c.id, fields: { title: txt(a.title, 200), location: txt(a.location, 200), notes: txt(a.notes, 2000), allDay, start: a.start, end, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } })); return { ok: true }; } + }; const onMsg = async e => { if (!document.body.contains(frame)) return removeEventListener('message', onMsg); if (e.source !== frame.contentWindow || !e.data || !e.data.hub) return; @@ -166,6 +201,7 @@ async function stRunExt(el, id) { 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.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]); 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; } @@ -173,3 +209,13 @@ async function stRunExt(el, id) { }; addEventListener('message', onMsg); } + +/** A site shown inside the hub. It is another website in a frame of its own, so it cannot see anything of the hub's. */ +async function stRunSite(el, id) { + try { await stLoad(); } catch (e) { el.innerHTML = `

${esc(e.message)}

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

That site is not in the store any more.

'; return; } + el.innerHTML = ` + +

If the page stays blank, that site does not allow being shown here. Use New tab.

`; +} diff --git a/tools/mystore-test.js b/tools/mystore-test.js index 82f22fc..0b645e7 100644 --- a/tools/mystore-test.js +++ b/tools/mystore-test.js @@ -43,6 +43,13 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] == 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'); + // permissions an extension has been given + assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200); + assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept'); + assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back'); + // a site (a link, optionally shown inside the hub) + assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D'); + assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files'); // 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