divan/api/src/ebooks.ts
Anas Rashid 0a507d1fdd E-books as a section of every poet, with tabs and covers; a right-to-left PDF reader; page bookmarks; book details
- ای بکس card beside the poet's books and genres on every poet page; /<poet>/ebooks with tabs
  پی ڈی ایف (PDF) / ای پب (ePUB) / یونیکوڈ متن (Unicode) (+ آرکائیو when present) and a grid of covers.
- Covers: made in the moderator's browser from a PDF's first page (pdf.js) or an EPUB's cover (epub.js); archive.org's
  thumbnail; a styled card for text; or the moderator's own image. سرورق بنائیں on existing books.
- PDF reader (pdf.js) reading right to left: next page to the left (button, ← key, left half), facing pages with the
  cover alone then 2|3; left to right for LTR books; full screen (F); opens in a new tab; byte-range requests.
- Page bookmarks in the reader's library (kind 'page'), listed in نشانات and opening at that page.
- Details: writer as printed, co-authors (linked to poets on the site, who list the book too; new names kept as text),
  language, reading direction (RTL/LTR), extra tags (on tag pages), source, copyright, note; in upload and review.
- Word lookup and selection menu only on reading text, not menus, buttons or tabs.
- Schema: the library kind check lists 'page' where it is defined (it was re-added without it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:16:06 +02:00

309 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// E-books (owner request): a poet's books as files (PDF, EPUB, text; a .docx becomes text) or archive.org items,
// uploaded by moderators with the 'ebooks' permission and listed under ای بکس on the poet's page once approved.
// Anyone with the permission may upload any book; reviewers (L1, admin) confirm it, as with every other change.
// Files live in the file store (DIVAN_FILES_DIR) under their content hash, ab/cd/<sha256>.<ext>, so a file uploaded
// twice is kept once; the database holds the details, indexed by poet and (trigram) by title and the text of text
// books, so lists and search stay fast. An approved e-book's details (not the file) are committed to divan-data.
// The details are a revision (entity 'ebook'), one per line: عنوان، مصنف (as printed)، شریک مصنفین (names, comma
// separated; those who are poets on the site are linked and the book is listed on their pages too)، زبان، سمت
// (دائیں سے بائیں / بائیں سے دائیں: how the reader turns pages)، ٹیگ (extra tags, comma separated)، ماخذ، اجازت، تفصیل.
// POST /api/mod/ebooks/upload?poet=&name= the file as the request body (up to 100 MB): {file, kind, size}
// POST /api/mod/ebooks {poet, title, file | archive, source, licence, note}: the e-book and its draft
// GET /api/ebooks?poet= a poet's published e-books
// GET /api/ebook/:id one e-book (unpublished: moderators only); the text of a text book
// GET /api/ebook/:id/file its file
// POST /api/mod/ebooks/:id/cover a cover image as the body (JPEG, PNG or WebP, up to 5 MB); the site makes
// one from a PDF's first page or an EPUB's own cover in the browser
// GET /api/ebook/:id/cover the cover
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { PoolClient } from 'pg';
import mammoth from 'mammoth';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
import { can } from './permissions.ts';
import { normalise } from './urdu.ts';
const MAX = 100 * 1024 * 1024;
const store = () => process.env.DIVAN_FILES_DIR ?? new URL('../../../divan-files', import.meta.url).pathname;
const pathOf = (file: string) => join(store(), file.slice(0, 2), file.slice(2, 4), file);
const TYPES: Record<string, string> = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8',
jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
// an image's format from its first bytes
const imageOf = (h: Buffer) => h[0] === 0xff && h[1] === 0xd8 ? 'jpg' : h.subarray(1, 4).toString() === 'PNG' ? 'png'
: h.subarray(0, 4).toString() === 'RIFF' && h.subarray(8, 12).toString() === 'WEBP' ? 'webp' : null;
const KEYS = { title: 'عنوان', writer: 'مصنف', coauthors: 'شریک مصنفین', language: 'زبان', direction: 'سمت', tags: 'ٹیگ',
source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const;
type Meta = Record<keyof typeof KEYS, string>;
export const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر'];
export const DIRECTIONS: Record<string, 'rtl' | 'ltr'> = { 'دائیں سے بائیں': 'rtl', 'بائیں سے دائیں': 'ltr' };
const RTL_LANGS = ['اردو', 'فارسی', 'عربی', 'سندھی', 'پشتو'];
const list = (s: string) => [...new Set(s.split(/[،,]/).map((x) => x.trim()).filter(Boolean))].slice(0, 30);
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
export const mayUpload = (u: any, poetId: number) => can(u, 'create', 'ebooks', { poetId });
// the details as revision text, and back
export const metaText = (m: Partial<Meta>) =>
(Object.keys(KEYS) as (keyof Meta)[]).map((k) => `${KEYS[k]}: ${String(m[k] ?? '').replace(/\s+/g, ' ').trim()}`).join('\n');
export function parseMeta(text: string): Meta | string {
const m: any = Object.fromEntries(Object.keys(KEYS).map((k) => [k, '']));
for (const line of text.split('\n')) {
const i = line.indexOf(':'); if (i < 0) continue;
const key = (Object.keys(KEYS) as (keyof Meta)[]).find((k) => KEYS[k] === line.slice(0, i).trim());
if (key) m[key] = line.slice(i + 1).trim().slice(0, key === 'note' ? 2000 : 300);
}
if (!m.title) return 'کتاب کا عنوان ضروری ہے';
m.language ||= 'اردو';
if (!LANGUAGES.includes(m.language)) return `زبان ${LANGUAGES.join('، ')} میں سے ہو`;
m.direction ||= RTL_LANGS.includes(m.language) ? 'دائیں سے بائیں' : 'بائیں سے دائیں';
if (!DIRECTIONS[m.direction]) return 'سمت «دائیں سے بائیں» یا «بائیں سے دائیں» ہو';
if (list(m.tags).some((t) => t.length > 60)) return 'ٹیگ بہت لمبا ہے';
return m;
}
// co-authors who are poets on the site (by name or pen name, Urdu-normalised)
async function poetsNamed(names: string[], db: { query: PoolClient['query'] } = pool) {
if (!names.length) return new Map<string, { id: number; url: string }>();
const { rows } = await db.query('SELECT id, url, name, nickname FROM poets');
const out = new Map<string, { id: number; url: string }>();
for (const n of names) {
const hit = rows.find((p) => normalise(p.nickname) === normalise(n) || normalise(p.name) === normalise(n));
if (hit) out.set(n, { id: hit.id, url: hit.url });
}
return out;
}
// archive.org: an item id, or a link to it
export const archiveId = (s: string) => {
const id = String(s ?? '').trim().replace(/^https?:\/\/(www\.)?archive\.org\/(details|embed)\//, '').split(/[/?#]/)[0];
return /^[A-Za-z0-9._-]{3,100}$/.test(id) ? id : null;
};
// what the bytes are: PDF, EPUB (a zip whose first entry is "mimetype"), a .docx (made into text), or UTF-8 text
async function kindOf(path: string, name: string) {
const fh = await open(path); const head = Buffer.alloc(64);
await fh.read(head, 0, 64, 0); await fh.close();
if (head.subarray(0, 5).toString() === '%PDF-') return 'pdf';
if (head.subarray(0, 4).toString('binary') === 'PK\x03\x04') {
if (head.subarray(30, 58).toString() === 'mimetypeapplication/epub+zip') return 'epub';
if (/\.docx$/i.test(name)) return 'docx';
return null;
}
if (/\.txt$/i.test(name)) {
try { new TextDecoder('utf-8', { fatal: true }).decode(await readFile(path)); return 'txt'; } catch { return null; }
}
return null;
}
// keep a file in the store under its hash; returns its name there
async function keep(tmp: string, ext: string) {
const sha = createHash('sha256').update(await readFile(tmp)).digest('hex');
const file = `${sha}.${ext}`, dest = pathOf(file);
await mkdir(dirname(dest), { recursive: true });
if (await stat(dest).catch(() => null)) await rm(tmp); else await rename(tmp, dest);
return file;
}
const textOf = async (file: string) => (await readFile(pathOf(file), 'utf8')).replace(/^/, '');
const searchText = async (title: string, kind: string, file: string | null) =>
normalise([title, kind === 'text' && file ? (await textOf(file)).slice(0, 2_000_000) : ''].join(' '));
// an e-book's published details (empty until approved, so its first draft is a change), and its version
export async function currentEbook(id: number) {
const ebook = (await pool.query(
'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [id])).rows[0];
if (!ebook) return null;
const last = (await pool.query(`SELECT max(version) AS v FROM revisions WHERE entity = 'ebook' AND entity_id = $1 AND status = 'published'`, [id])).rows[0];
const tags = (await pool.query(`SELECT t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [id])).rows.map((r) => r.name);
const text = metaText({ ...ebook, coauthors: ebook.coauthors.join('، '), tags: tags.join('، '),
direction: Object.keys(DIRECTIONS).find((k) => DIRECTIONS[k] === ebook.direction) });
return { ebook, version: Number(last?.v ?? 0), content: ebook.published ? text : '' };
}
export const checkEbook = (text: string) => { const m = parseMeta(text); return typeof m === 'string' ? m : null; };
// publishing: the details become the e-book's and it is listed
export async function applyEbook(client: PoolClient, id: number, text: string) {
const m = parseMeta(text) as Meta;
const b = (await client.query('SELECT kind, file FROM ebooks WHERE id = $1', [id])).rows[0];
const co = list(m.coauthors), linked = await poetsNamed(co, client);
await client.query(
`UPDATE ebooks SET title = $2, source = $3, licence = $4, note = $5, search_text = $6, writer = $7, language = $8, direction = $9,
coauthors = $10, coauthor_ids = $11, published = true WHERE id = $1`,
[id, m.title, m.source || null, m.licence || null, m.note || null,
await searchText([m.title, m.writer, ...co, ...list(m.tags)].join(' '), b.kind, b.file), m.writer || null, m.language,
DIRECTIONS[m.direction], co, [...new Set([...linked.values()].map((p) => p.id))]]);
// extra tags: free tags (ٹیگ), so tag pages list the book
await client.query(`DELETE FROM entity_tags WHERE entity = 'ebook' AND entity_id = $1`, [id]);
for (const name of list(m.tags)) {
const tagId = (await client.query(`INSERT INTO tags (type, name) VALUES ('ٹیگ', $1) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name])).rows[0].id;
await client.query(`INSERT INTO entity_tags (tag_id, entity, entity_id) VALUES ($1, 'ebook', $2) ON CONFLICT DO NOTHING`, [tagId, id]);
}
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
}
// divan-data/divan/<poet>/ebooks/<id>.txt: the details and where the file is (the file itself stays in the store)
export async function writeEbook(dataDir: string, ebook: any, text: string) {
const path = join(dataDir, 'divan', ebook.poet_url.replace(/^\//, ''), 'ebooks', `${ebook.id}.txt`);
const where = ebook.kind === 'archive' ? `آرکائیو: https://archive.org/details/${ebook.archive_id}`
: `فائل: ${ebook.file} (${ebook.kind}، ${ebook.size} بائٹ)`;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${text.trim()}\n${where}\n`);
return path;
}
const viewer = async (req: FastifyRequest) => { const u = await sessionUser(req); return isModerator(u) ? u : null; };
export function ebookRoutes(app: FastifyInstance) {
// uploads arrive as the raw request body
app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));
app.post<{ Querystring: { poet?: string; name?: string } }>('/api/mod/ebooks/upload', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
if (!(await mayUpload(u, Number(req.query.poet) || 0))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' });
const tmp = join(store(), 'tmp', randomUUID());
await mkdir(dirname(tmp), { recursive: true });
let size = 0;
try {
await new Promise<void>((resolve, reject) => {
const out = createWriteStream(tmp);
const body = req.body as NodeJS.ReadableStream;
body.on('data', (c: Buffer) => { size += c.length; if (size > MAX) { body.removeAllListeners('data'); out.destroy(); reject(Object.assign(new Error('فائل ۱۰۰ میگا بائٹ سے بڑی ہے'), { code: 413 })); } else out.write(c); });
body.on('end', () => out.end(() => resolve()));
body.on('error', reject);
});
if (!size) throw Object.assign(new Error('فائل خالی ہے'), { code: 400 });
const kind = await kindOf(tmp, String(req.query.name ?? ''));
if (!kind) throw Object.assign(new Error('صرف PDF، EPUB، TXT یا DOCX فائلیں'), { code: 415 });
if (kind === 'docx') {
const { value } = await mammoth.extractRawText({ path: tmp });
await writeFile(tmp, value.replace(/\n{3,}/g, '\n\n').trim() + '\n');
size = (await stat(tmp)).size;
}
const ext = kind === 'docx' ? 'txt' : kind;
return { file: await keep(tmp, ext), kind: ext === 'txt' ? 'text' : ext, size };
} catch (e: any) {
await rm(tmp, { force: true });
return reply.code(e.code ?? 500).send({ error: e.message });
}
});
app.post<{ Body: any }>('/api/mod/ebooks', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = req.body ?? {}, poetId = Number(b.poet) || 0;
if (!(await mayUpload(u, poetId))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' });
const meta = parseMeta(metaText({ ...b, coauthors: Array.isArray(b.coauthors) ? b.coauthors.join('، ') : b.coauthors,
direction: DIRECTIONS[b.direction] ? b.direction : b.direction === 'ltr' ? 'بائیں سے دائیں' : b.direction === 'rtl' ? 'دائیں سے بائیں' : '' }));
if (typeof meta === 'string') return reply.code(400).send({ error: meta });
let kind: string, file: string | null = null, archive: string | null = null, size: number | null = null;
if (b.archive) {
archive = archiveId(b.archive);
if (!archive) return reply.code(400).send({ error: 'آرکائیو کا ربط درست نہیں (archive.org/details/…)' });
kind = 'archive';
} else {
file = /^[0-9a-f]{64}\.(pdf|epub|txt)$/.test(b.file ?? '') ? b.file : null;
const st = file && (await stat(pathOf(file)).catch(() => null));
if (!file || !st) return reply.code(400).send({ error: 'پہلے فائل اپ لوڈ کریں' });
kind = file.endsWith('.txt') ? 'text' : file.slice(-4).replace('.', '');
size = st.size;
}
const content = metaText(meta);
const client = await pool.connect();
try {
await client.query('BEGIN');
const id = (await client.query(
`INSERT INTO ebooks (poet_id, title, kind, file, archive_id, size, source, licence, note, created_by, writer, language, direction, coauthors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`,
[poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id,
meta.writer || null, meta.language, DIRECTIONS[meta.direction], list(meta.coauthors)])).rows[0].id;
const rev = (await client.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email, summary)
VALUES ('ebook', $1, 0, '', $2, 'draft', $3, $4, $5) RETURNING id`, [id, content, u.id, u.email, 'نئی ای بک'])).rows[0].id;
await client.query(`INSERT INTO revision_events (revision_id, actor_id, actor_email, action) VALUES ($1, $2, $3, 'created')`, [rev, u.id, u.email]);
await client.query('COMMIT');
return { id, revision: Number(rev) };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.get<{ Querystring: { poet?: string } }>('/api/ebooks', async (req) => (await pool.query(
`SELECT id, title, kind, size, source, cover IS NOT NULL AS has_cover, archive_id, language, poet_id <> $1 AS co
FROM ebooks WHERE (poet_id = $1 OR $1 = ANY(coauthor_ids)) AND published ORDER BY title`,
[Number(req.query.poet) || 0])).rows);
app.post<{ Params: { id: string } }>('/api/mod/ebooks/:id/cover', { bodyLimit: 5 * 1024 * 1024 }, async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = (await pool.query('SELECT id, poet_id FROM ebooks WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
if (!b) return reply.code(404).send({ error: 'کتاب نہیں ملی' });
if (!(await mayUpload(u, b.poet_id))) return reply.code(403).send({ error: 'اس کتاب کا سرورق بدلنے کی اجازت نہیں' });
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req.body as AsyncIterable<Buffer>) {
size += c.length;
if (size > 5 * 1024 * 1024) return reply.code(413).send({ error: 'تصویر ۵ میگا بائٹ سے بڑی ہے' });
chunks.push(c);
}
const img = Buffer.concat(chunks), ext = imageOf(img);
if (!ext) return reply.code(415).send({ error: 'صرف JPEG، PNG یا WebP تصویر' });
const tmp = join(store(), 'tmp', randomUUID());
await mkdir(dirname(tmp), { recursive: true });
await writeFile(tmp, img);
const cover = await keep(tmp, ext);
await pool.query('UPDATE ebooks SET cover = $2 WHERE id = $1', [b.id, cover]);
return { cover };
});
const ebook = async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const b = (await pool.query(
'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [Number(req.params.id) || 0])).rows[0];
if (!b || (!b.published && !(await viewer(req)))) return void reply.code(404).send({ error: 'کتاب نہیں ملی' });
return b;
};
app.get<{ Params: { id: string } }>('/api/ebook/:id', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
const { search_text, created_by, ...rest } = b;
// co-authors: linked to their pages when they are poets on the site
const linked = await poetsNamed(b.coauthors);
const tags = (await pool.query(`SELECT t.type, t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [b.id])).rows;
return { ...rest, coauthors: b.coauthors.map((name: string) => ({ name, url: linked.get(name)?.url ?? null })), tags,
text: b.kind === 'text' ? (await textOf(b.file)).slice(0, 3_000_000) : undefined };
});
app.get<{ Params: { id: string } }>('/api/ebook/:id/cover', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
if (!b.cover) return reply.code(404).send({ error: 'سرورق نہیں' });
reply.header('content-type', TYPES[b.cover.split('.').pop()]).header('cache-control', b.published ? 'public, max-age=604800' : 'private, no-store');
return reply.send(createReadStream(pathOf(b.cover)));
});
app.get<{ Params: { id: string } }>('/api/ebook/:id/file', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
if (!b.file) return reply.code(404).send({ error: 'اس کتاب کی فائل نہیں' });
const ext = b.file.split('.').pop(), size = (await stat(pathOf(b.file))).size;
reply.header('content-type', TYPES[ext]).header('accept-ranges', 'bytes')
.header('content-disposition', `inline; filename="divan-${b.id}.${ext}"`)
.header('cache-control', b.published ? 'public, max-age=86400' : 'private, no-store');
// a byte range (the PDF reader asks for the pages it shows, not the whole file)
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ''));
if (m && (m[1] || m[2])) {
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2])), end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start > end || start >= size) return reply.code(416).header('content-range', `bytes */${size}`).send();
reply.code(206).header('content-range', `bytes ${start}-${end}/${size}`).header('content-length', String(end - start + 1));
return reply.send(createReadStream(pathOf(b.file), { start, end }));
}
reply.header('content-length', String(size));
return reply.send(createReadStream(pathOf(b.file)));
});
}