diff --git a/README.md b/README.md index f43aec0..2168015 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free - **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. - **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). +- **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`), `hub.notes.list() / add({ title, body })` (new notes are private), `hub.recipes.search(text) / get(slug)` and `hub.music.nowPlaying()` (what is playing, or `null`). A refused request rejects with an error. Each permission can be switched off again from the extension's **Permissions** button, and when an admin changes an extension (its version or file) the people who have it see "Updated" the next time they open it. 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/mystore.js b/mystore.js index 3259177..e409879 100644 --- a/mystore.js +++ b/mystore.js @@ -13,7 +13,7 @@ const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT( 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', 'link']; -const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for +const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // 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' }; @@ -37,7 +37,7 @@ const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.cre // ---- 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 : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } }; +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 : {}, seen: j.seen && typeof j.seen === 'object' ? j.seen : {} }; } catch { return { installed: [], data: {}, grants: {}, seen: {} }; } }; const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 }); /** Stream a stored file, with Range support so audio can be skipped through. */ @@ -84,14 +84,17 @@ function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[ 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])}}}})()`; +calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}, +notes:{list:function(){return call('notes.list')},add:function(o){return call('notes.add',[o])}}, +recipes:{search:function(q){return call('recipes.search',[{q:q}])},get:function(slug){return call('recipes.get',[{slug:slug}])}}, +music:{nowPlaying:function(){return call('music.nowPlaying')}}}})()`; 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 === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, seen: stateOf().seen, 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'); @@ -148,7 +151,10 @@ 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]; delete s.grants[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); s.seen[it.id] = it.updated || 0; } if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; delete s.seen[it.id]; } saveState(s); return json(res, 200, { installed: s.installed }); + } + if ((m = /^\/ext\/([0-9a-f]{12})\/seen$/.exec(p)) && req.method === 'POST') { // the person has looked at this version of the extension (so it stops saying it was updated) + const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); if (s.installed.includes(it.id)) { s.seen[it.id] = it.updated || 0; saveState(s); } return json(res, 200, { seen: s.seen }); } 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(); diff --git a/public/store-view.js b/public/store-view.js index 6b8df92..7e20fb9 100644 --- a/public/store-view.js +++ b/public/store-view.js @@ -8,7 +8,8 @@ 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'; +let stItems = [], stInstalled = [], stSeen = {}, stAdmin = false, stFilter = 'all'; +const stUpdated = it => it.kind === 'extension' && stInstalled.includes(it.id) && stSeen[it.id] !== undefined && (it.updated || 0) > stSeen[it.id]; // a new version of an extension you have (one installed before this was tracked is not flagged) /** 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]); @@ -28,7 +29,7 @@ async function viewStore(args = []) { } // ---------------------------------------------------------------- My Store -async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; } +async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; } async function stMine(el) { try { await stLoad(); } catch (e) { el.innerHTML = `${esc(e.message)}
`; return; } const draw = () => { @@ -154,15 +155,17 @@ async function stRunExt(el, id) { 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 = ` + el.innerHTML = ` + ${stUpdated(it) ? `${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' }; + 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', 'notes.read': 'see your notes and lists', 'notes.write': 'add notes', 'recipes.read': 'search and read your recipes', 'music.read': 'see what music is playing' }; let grants = null; const refused = new Set(); const allowedTo = async perm => { if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants; @@ -172,10 +175,16 @@ async function stRunExt(el, id) { if (!yes) { refused.add(perm); return false; } grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true; }; + // The permissions screen: everything this extension may reach, each one switched off or on (turning one off just means it is asked again next time it needs it) + el.querySelector('#stperm').onclick = async () => { + if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants; + const sh = openSheet('Permissions', it.title, `What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.