Files
hub/mystore.js
T
nmemmertandClaude Sonnet 5.5 f292294ad7
build-container / build (push) Successful in 7m39s
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 <noreply@anthropic.com>
2026-10-26 09:08:41 -04:00

157 lines
17 KiB
JavaScript

// 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 = '<meta http-equiv="Content-Security-Policy" content="' + EXT_CSP.replace(/^sandbox [^;]*; /, '').replace(/; frame-ancestors 'self'$/, '') + '">'; // (the same rules, for when the page is shown from the hub's own copy of it)
const BRIDGE = EXT_META + `<script>(function(){var n=0,w={};window.addEventListener('message',function(e){var m=e.data;if(m&&m.hub&&w[m.id]){var f=w[m.id];delete w[m.id];m.error?f[1](new Error(m.error)):f[0](m.result)}});
function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[ok,no];parent.postMessage({hub:1,id:id,method:method,args:args||[]},'*')})}
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)])}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/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 };