69 lines
2.5 KiB
JavaScript
69 lines
2.5 KiB
JavaScript
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
|
export const nowIso = () => new Date().toISOString();
|
|
export const randomId = (bytes = 16) => randomBytes(bytes).toString('hex');
|
|
export const randomToken = () => `kv_${randomBytes(24).toString('base64url')}`;
|
|
export const tokenHash = (token) => createHash('sha256').update(token).digest('hex');
|
|
export const sha256 = (input) => createHash('sha256').update(input).digest('hex');
|
|
|
|
export function safeEqualHex(a, b) {
|
|
if (!a || !b || a.length !== b.length) return false;
|
|
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
}
|
|
|
|
export function cleanText(value, max = 500) {
|
|
if (value == null) return null;
|
|
const s = String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
return s.slice(0, max) || null;
|
|
}
|
|
|
|
export function asInt(value, { min = 0, max = Number.MAX_SAFE_INTEGER, fallback = 0 } = {}) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return fallback;
|
|
const i = Math.trunc(n);
|
|
return Math.max(min, Math.min(max, i));
|
|
}
|
|
|
|
function validIsbn10(value) {
|
|
if (!/^\d{9}[\dX]$/.test(value)) return false;
|
|
let sum = 0;
|
|
for (let i = 0; i < 10; i++) {
|
|
const d = value[i] === 'X' ? 10 : Number(value[i]);
|
|
sum += d * (10 - i);
|
|
}
|
|
return sum % 11 === 0;
|
|
}
|
|
|
|
function validIsbn13(value) {
|
|
if (!/^97[89]\d{10}$/.test(value)) return false;
|
|
let sum = 0;
|
|
for (let i = 0; i < 12; i++) sum += Number(value[i]) * (i % 2 ? 3 : 1);
|
|
return (10 - (sum % 10)) % 10 === Number(value[12]);
|
|
}
|
|
|
|
/** Extract a validated ISBN from EPUB/KOReader identifier metadata.
|
|
* KOReader's doc_props.identifiers is commonly a newline-separated string such as
|
|
* `isbn:978...`, `urn:isbn:...`, `calibre:...`, `uuid:...`.
|
|
*/
|
|
export function extractIsbn(value) {
|
|
if (!value) return null;
|
|
const raw = String(value).toUpperCase();
|
|
const candidates = raw.match(/(?:97[89][\d\s-]{9,20}\d|\d[\d\s-]{7,16}[\dX])/g) || [];
|
|
const cleaned = [...new Set(candidates.map(x => x.replace(/[^0-9X]/g, '')))];
|
|
const isbn13 = cleaned.find(x => x.length === 13 && validIsbn13(x));
|
|
if (isbn13) return isbn13;
|
|
return cleaned.find(x => x.length === 10 && validIsbn10(x)) || null;
|
|
}
|
|
|
|
export function json(res, status, body, extraHeaders = {}) {
|
|
const payload = Buffer.from(JSON.stringify(body));
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': payload.length,
|
|
'Cache-Control': 'no-store',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
...extraHeaders,
|
|
});
|
|
res.end(payload);
|
|
}
|