// 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'); // 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 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); });