mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
198d36ae56
Fix the server and web gaps found while porting the PDF and comic readers and offline downloads to Android, checked against the iOS app: - KOReader: answer a pull for a PDF or comic with the page number when a BookOrbit client wrote last, instead of a percentage with no page - comics: key the zip offset, RAR and CB7 caches by file id and modification time, so pages are not served from stale offsets after a metadata write-back rewrites the archive - comics: answer an unreadable archive with 422 rather than a page count of 0, and extract CB7 with its folders so same-named pages in different folders no longer overwrite each other - book files: send an ETag and Last-Modified on serve and download and honour If-None-Match and If-Range, clamp a range that runs past the end, answer suffix ranges with 206, and honour Range on download, which advertised it but sent the whole file - book files: leave raw file routes uncompressed, so FB2 and unknown formats keep their Content-Length - progress: reject a fractional or negative page number with 400 rather than failing with 500 at the database - types: declare the comic page-count response shared by the web and the apps - web: open a percentage-only PDF position at its estimated page, and flush pending PDF and comic progress before the reading session ends
189 lines
7.6 KiB
TypeScript
189 lines
7.6 KiB
TypeScript
import { UnsupportedMediaTypeException } from '@nestjs/common';
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
import type { IncomingHttpHeaders } from 'http';
|
|
import { Readable } from 'stream';
|
|
|
|
const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal';
|
|
const EMPTY_JSON_BODY = '{}';
|
|
const BODY_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
|
// Matches @fastify/helmet's default Strict-Transport-Security max-age (1 year).
|
|
const HSTS_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
|
const HSTS_HEADER_VALUE = `max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`;
|
|
|
|
export function parseTrustProxy(value: string | undefined): string | boolean {
|
|
const raw = value?.trim();
|
|
if (!raw) return DEFAULT_TRUST_PROXY;
|
|
|
|
const normalized = raw.toLowerCase();
|
|
if (['false', 'no', 'off'].includes(normalized)) return false;
|
|
if (['true', 'yes', 'on'].includes(normalized)) return true;
|
|
|
|
if (!Number.isNaN(Number(raw))) {
|
|
throw new Error('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
export function parseBooleanEnv(value: string | undefined, fallback = false): boolean {
|
|
const raw = value?.trim();
|
|
if (!raw) return fallback;
|
|
|
|
const normalized = raw.toLowerCase();
|
|
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
return fallback;
|
|
}
|
|
|
|
export interface CspOptions {
|
|
allowCloudflareInsights?: boolean;
|
|
}
|
|
|
|
const CLOUDFLARE_INSIGHTS_SCRIPT_SRC = 'https://static.cloudflareinsights.com';
|
|
const CLOUDFLARE_INSIGHTS_CONNECT_SRC = 'https://cloudflareinsights.com';
|
|
const DICTIONARY_CONNECT_SRC = ['https://api.dictionaryapi.dev', 'https://*.wiktionary.org'];
|
|
const TRANSLATE_CONNECT_SRC = 'https://translate.googleapis.com';
|
|
|
|
export function buildCspDirectives(options: CspOptions = {}) {
|
|
const { allowCloudflareInsights = false } = options;
|
|
|
|
const scriptSrc = ["'self'", "'wasm-unsafe-eval'", ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_SCRIPT_SRC] : [])];
|
|
const connectSrc = [
|
|
"'self'",
|
|
'ws:',
|
|
'wss:',
|
|
'https://cdn.jsdelivr.net',
|
|
...DICTIONARY_CONNECT_SRC,
|
|
TRANSLATE_CONNECT_SRC,
|
|
...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_CONNECT_SRC] : []),
|
|
];
|
|
|
|
return {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc,
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'blob:', 'https://fonts.googleapis.com'],
|
|
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
connectSrc,
|
|
mediaSrc: ["'self'", 'data:', 'blob:'],
|
|
fontSrc: ["'self'", 'data:', 'blob:', 'https://fonts.gstatic.com'],
|
|
objectSrc: ["'none'"],
|
|
frameSrc: ["'self'", 'blob:'],
|
|
frameAncestors: ["'self'"],
|
|
workerSrc: ["'self'", 'blob:'],
|
|
upgradeInsecureRequests: null,
|
|
};
|
|
}
|
|
|
|
export function buildHelmetOptions(options: CspOptions = {}) {
|
|
return {
|
|
crossOriginOpenerPolicy: false,
|
|
// Helmet sends HSTS over plain HTTP by default. The conditional hook adds it only for HTTPS.
|
|
hsts: false,
|
|
contentSecurityPolicy: {
|
|
directives: buildCspDirectives(options),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decides whether an unmatched request should be answered with the SPA shell.
|
|
*
|
|
* Serving `index.html` for everything means a hashed bundle that no longer exists after an upgrade
|
|
* answers `200 OK` with an HTML body under a `.js` URL. A 200 is storable, so both the browser HTTP
|
|
* cache and the Workbox precache keep it, dynamic imports of that chunk fail, and the broken state
|
|
* survives reloads until the user clears site data. Anything that names a file gets a real 404.
|
|
*
|
|
* Every BookOrbit route parameter is numeric, so "has an extension" reliably separates asset
|
|
* requests from client-side routes such as `/book/123/files`.
|
|
*/
|
|
export function isStaticAssetPath(url: string): boolean {
|
|
const pathname = url.split('?')[0].split('#')[0];
|
|
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
|
|
return lastSegment.includes('.');
|
|
}
|
|
|
|
export function shouldServeSpaFallback(url: string): boolean {
|
|
return !isStaticAssetPath(url);
|
|
}
|
|
|
|
export function shouldInjectEmptyJsonBody(method: string, headers: IncomingHttpHeaders): boolean {
|
|
const contentType = getHeaderValue(headers['content-type'])?.toLowerCase();
|
|
if (!BODY_METHODS.has(method.toUpperCase()) || !contentType?.startsWith('application/json')) {
|
|
return false;
|
|
}
|
|
|
|
const contentLength = getHeaderValue(headers['content-length'])?.trim();
|
|
return contentLength === undefined || contentLength === '0';
|
|
}
|
|
|
|
export function buildEmptyJsonBodyStream(headers: IncomingHttpHeaders): Readable {
|
|
headers['content-length'] = String(Buffer.byteLength(EMPTY_JSON_BODY));
|
|
return Readable.from([Buffer.from(EMPTY_JSON_BODY)]);
|
|
}
|
|
|
|
export function registerEmptyBodyContentTypeParser(fastify: FastifyInstance): void {
|
|
fastify.addContentTypeParser<string>('*', { parseAs: 'string' }, (request, body, done) => {
|
|
if (BODY_METHODS.has(request.method.toUpperCase()) && body.length === 0) {
|
|
done(null, {});
|
|
return;
|
|
}
|
|
|
|
done(new UnsupportedMediaTypeException('Unsupported Media Type'));
|
|
});
|
|
}
|
|
|
|
function getHeaderValue(value: string | string[] | undefined): string | undefined {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
// Sending Strict-Transport-Security over a plain HTTP connection is harmful: browsers cache the
|
|
// directive per-hostname and force HTTPS for that host (and, with includeSubDomains, its subdomains)
|
|
// for up to a year, breaking access to BookOrbit and any other unrelated service on the same host
|
|
// when it isn't actually served over TLS. Only emit the header for requests that are genuinely
|
|
// secure, i.e. terminated over TLS directly or forwarded by a trusted proxy (see `request.protocol`,
|
|
// which already honours the app's `TRUST_PROXY` setting).
|
|
export function isSecureProtocol(protocol: string | undefined): boolean {
|
|
return protocol === 'https';
|
|
}
|
|
|
|
export function applyConditionalHsts(request: Pick<FastifyRequest, 'protocol'>, reply: Pick<FastifyReply, 'header'>): void {
|
|
if (isSecureProtocol(request.protocol)) {
|
|
reply.header('Strict-Transport-Security', HSTS_HEADER_VALUE);
|
|
}
|
|
}
|
|
|
|
export function registerConditionalHsts(fastify: FastifyInstance): void {
|
|
fastify.addHook('onSend', (request, reply, payload, done) => {
|
|
applyConditionalHsts(request, reply);
|
|
done(null, payload);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nest's `@RouteConfig({ bodyLimit })` only reaches Fastify's user-space `config` object, while
|
|
* Fastify reads `bodyLimit` from the route options. Without copying it across, a route that asks
|
|
* for a larger body silently keeps the 1 MiB default and its DTO length limit is unreachable.
|
|
*/
|
|
export function applyDeclaredBodyLimit(route: { config?: unknown; bodyLimit?: number }): void {
|
|
const declared = (route.config as { bodyLimit?: unknown } | undefined)?.bodyLimit;
|
|
if (typeof declared === 'number' && Number.isInteger(declared) && declared > 0) route.bodyLimit = declared;
|
|
}
|
|
|
|
export function registerDeclaredBodyLimits(fastify: FastifyInstance): void {
|
|
fastify.addHook('onRoute', applyDeclaredBodyLimit);
|
|
}
|
|
|
|
/**
|
|
* `@fastify/compress` reads its per-route opt-out from the route options, which Nest's
|
|
* `@RouteConfig({ compress: false })` cannot reach. Raw file routes need it: the plugin's default
|
|
* type check gzips `+xml` and `octet-stream` bodies (FB2, unknown formats) and drops Content-Length.
|
|
* Register before the compress plugin so this hook runs first.
|
|
*/
|
|
export function applyDeclaredCompressionOptOut(route: { config?: unknown; compress?: unknown }): void {
|
|
if ((route.config as { compress?: unknown } | undefined)?.compress === false) route.compress = false;
|
|
}
|
|
|
|
export function registerDeclaredCompressionOptOuts(fastify: FastifyInstance): void {
|
|
fastify.addHook('onRoute', applyDeclaredCompressionOptOut);
|
|
}
|