Files
kovi/lib/security.mjs
T
2026-09-25 15:29:54 +01:00

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