Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
49 lines
7.8 KiB
JavaScript
49 lines
7.8 KiB
JavaScript
// "Save to Files": books, free audiobook chapters and Verse By Verse lessons are copied into the person's Seafile (made folders, skipped files already there, handouts that do not exist).
|
|
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-save-'));
|
|
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-savefx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]); const epub = fs.readFileSync(path.join(fx, 'test.epub'));
|
|
const tree = new Map([['/', new Set()]]), files = [], seen = []; let port; // fake Seafile: folders and uploaded files
|
|
const fake = http.createServer(async (req, res) => {
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, c = 200) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, T = (t, b = '') => { res.writeHead(200, { 'content-type': t }); res.end(b); };
|
|
seen.push(req.method + ' ' + p + (u.searchParams.get('p') ? ' ' + u.searchParams.get('p') : ''));
|
|
if (p.startsWith('/api2/repos/') || p.startsWith('/seafhttp/')) { if (p.startsWith('/api2/') && req.headers.authorization !== 'Token SFTOKEN') { res.writeHead(401); return res.end(); } }
|
|
if (/^\/api2\/repos\/R{8}\/dir\/$/.test(p)) { const d = u.searchParams.get('p');
|
|
if (req.method === 'POST') { const par = d.replace(/\/[^/]+$/, '') || '/'; if (!tree.has(par)) { res.writeHead(404); return res.end(); } tree.set(d, new Set()); tree.get(par).add('D:' + d.split('/').pop()); return J('success', 201); }
|
|
if (!tree.has(d)) { res.writeHead(404); return res.end('{}'); } return J([...tree.get(d)].map(n => n.startsWith('D:') ? { name: n.slice(2), type: 'dir' } : { name: n, type: 'file' })); }
|
|
if (/^\/api2\/repos\/R{8}\/upload-link\/$/.test(p)) return J(`http://127.0.0.1:${port}/seafhttp/upload-api/tok?dir=${encodeURIComponent(u.searchParams.get('p'))}`);
|
|
if (p === '/seafhttp/upload-api/tok') { const b = Buffer.concat(await req.toArray()); const name = /filename="([^"]+)"/.exec(b.toString('latin1'))[1]; const d = u.searchParams.get('dir'); if (!tree.has(d)) { res.writeHead(404); return res.end(); }
|
|
if (name === 'fail.mp3') { res.writeHead(500); return res.end(); } tree.get(d).add(name); files.push(d + '/' + name + ' ' + b.length); return T('text/plain', 'id'); }
|
|
// sources: Verse By Verse, LibriVox
|
|
if (p === '/bible-studies/jude/podcast.xml') return T('application/xml', `<rss><channel><title>Jude: Faith</title><itunes:author>Wes</itunes:author><itunes:image href="http://127.0.0.1:${port}/c.gif"/>` + [1, 2, 3].map(n => `<item><title>${n} Lesson ${n}</title><link>http://127.0.0.1:${port}/lessons/jude-${n}</link><enclosure url="http://127.0.0.1:${port}/media/${n}.mp3" length="5" type="audio/mpeg"/></item>`).join('') + '</channel></rss>');
|
|
if (/^\/lessons\/jude-[12]$/.test(p)) return T('text/html', `<a href="http://127.0.0.1:${port}/media/notes${p.slice(-1)}.pdf">n</a>`); if (p === '/lessons/jude-3') return T('text/html', 'none');
|
|
if (/^\/media\/\d\.mp3$/.test(p)) return T('audio/mpeg', 'AUDIO' + p); if (/^\/media\/notes\d\.pdf$/.test(p)) return T('application/pdf', '%PDF-1.4 notes');
|
|
if (p === '/api/feed/audiobooks/') { const base = { id: '9', title: 'Old: Story?', totaltimesecs: 60, authors: [{ first_name: 'A', last_name: 'B' }] }; return J({ books: [{ ...base, sections: [1, 2].map(n => ({ title: 'Ch ' + n + '/x', listen_url: `http://127.0.0.1:${port}/media/${n}.mp3`, playtime: '30' })) }] }); }
|
|
res.writeHead(404); res.end();
|
|
}).listen(0, () => {
|
|
port = fake.address().port; Object.assign(process.env, { SEAFILE_URL: 'http://127.0.0.1:' + port, VBV_URL: 'http://127.0.0.1:' + port, LIBRIVOX_URL: 'http://127.0.0.1:' + port }); tree.set('/Lib', new Set());
|
|
const users = require('../users'), books = require('../books'), save = require('../savefiles');
|
|
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['files'], handle: 'Yg' }), noTok = users.create({ name: 'Cy', role: 'admin', handle: 'Yw' });
|
|
const as = (u, f) => users.runAs(u, f); as(ann, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' }))); as(bob, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' })));
|
|
const bookId = as(ann, () => books.addBook(epub, 'epub', { title: 'My Book', author: 'Some Author' }).id);
|
|
const hub = http.createServer((req, res) => as({ b: bob, c: noTok }[req.headers['x-user']] || ann, () => save.handle(req, res))).listen(0, async () => {
|
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/save', R = 'RRRRRRRR', j = r => r.json(), post = (b, h = {}) => fetch(base + '/', { method: 'POST', body: JSON.stringify({ repo: R, dir: '/Lib', ...b }), headers: h });
|
|
const finish = async id => { for (let i = 0; i < 100; i++) { const s = await j(await fetch(base + '/job/' + id)); if (s.state !== 'running') return s; await new Promise(ok => setTimeout(ok, 50)); } throw new Error('job did not finish'); };
|
|
try {
|
|
let r = await post({ kind: 'book', id: bookId }); let s = await finish((await j(r)).id); assert.equal(s.state, 'done'); assert.equal(s.done, 1); assert.ok(files.some(f => f.startsWith('/Lib/Books/Some Author - My Book.epub ')), 'the book is in a Books folder, named by author and title');
|
|
r = await post({ kind: 'book', id: bookId }); s = await finish((await j(r)).id); assert.equal(s.skipped, 1); assert.equal(s.done, 0, 'running it again skips what is already there');
|
|
r = await post({ kind: 'vbv-lesson', slug: 'jude', n: 0, what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Verse By Verse/Jude Faith/01 - Lesson 1.mp3')), 'folders are made and names are safe'); assert.ok(files.some(f => f.includes('01 - Lesson 1 - Notes.pdf')));
|
|
r = await post({ kind: 'vbv-study', slug: 'jude', what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.total, 6); assert.equal(s.skipped, 2, 'lesson 1 was already saved'); assert.equal(s.missing, 1, 'a lesson with no handout is counted, not failed'); assert.equal(s.failed, 0); assert.equal(s.done, 3);
|
|
assert.ok(files.some(f => f.includes('03 - Lesson 3.mp3')) && !files.some(f => f.includes('03 - Lesson 3 - Notes')));
|
|
r = await post({ kind: 'librivox-book', id: '9', dir: '/Lib/Sub' }); assert.equal((await finish((await j(r)).id)).state, 'error', 'a folder that does not exist in Files is an error, not a crash');
|
|
r = await post({ kind: 'librivox-book', id: '9' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Audiobooks/Old Story/01 - Ch 1 x.mp3')), 'odd characters in names are removed');
|
|
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'b' }); assert.equal(r.status, 403, 'a person without the Books tab cannot save books');
|
|
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'c' }); assert.equal(r.status, 401, 'no Files sign-in saved: told to sign in'); assert.match((await j(r)).error, /sign in/);
|
|
assert.equal((await post({ kind: 'nope' })).status, 400); assert.equal((await post({ kind: 'book', id: bookId, repo: '../x' })).status, 400); assert.equal((await post({ kind: 'book', id: 'zzz' })).status, 404);
|
|
r = await post({ kind: 'book', id: bookId, dir: '/Lib/../../etc' }); s = await finish((await j(r)).id); assert.ok(!s.dir.includes('..'), 'a folder path cannot climb out');
|
|
assert.equal((await fetch(base + '/job/' + s.id, { headers: { 'x-user': 'b' } })).status, 404, "another person's save is not visible");
|
|
console.log('savefiles test ok');
|
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
|
hub.close(); fake.close();
|
|
});
|
|
});
|