Files
bookorbit/server/src/config/config.ts
T
neon b50b8da56c feat(metadata): add external HTTP metadata providers
Register book-metadata-provider services by address, with no code change per
provider; Settings lists ready-made ones to install. Ranking prefers a record of
the book's own medium. LubimyCzytac moves out to its own provider and Aladin,
whose API closes, is retired; their ids stay. Adds migrations 0129 and 0130.
See docs/METADATA_PROVIDERS.md.

Ref #1405
2026-10-23 06:11:04 +00:00

147 lines
7.4 KiB
TypeScript

import { registerAs } from '@nestjs/config';
import { join, resolve } from 'path';
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST?.trim() || '0.0.0.0',
appUrl: process.env.APP_URL ?? DEV_CLIENT_ORIGIN,
// Private-use scheme the iOS app registers with ASWebAuthenticationSession. Compiled into the
// client binary, so it is the same for every provider on every deployment; configurable only so
// a rebranded fork can change it.
nativeRedirectUri: process.env.NATIVE_REDIRECT_URI?.trim() || 'bookorbit://oauth2-callback',
version: process.env.APP_VERSION ?? 'Local build',
githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit',
githubReleasesToken: process.env.GITHUB_RELEASES_TOKEN?.trim() || undefined,
metadataProviderDirectoryUrl: parseDirectoryUrl(process.env.METADATA_PROVIDER_DIRECTORY_URL),
oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false),
swaggerEnabled: parseBooleanFlag(process.env.SWAGGER_ENABLED, false),
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
koreaderPluginSourcePath: process.env.KOREADER_PLUGIN_PATH?.trim() || undefined,
}));
export const dbConfig = registerAs('db', () => ({
url: process.env.DATABASE_URL ?? 'postgres://bookorbit:bookorbit@localhost:5432/bookorbit',
}));
export const authConfig = registerAs('auth', () => ({
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
setupBootstrapToken: process.env.SETUP_BOOTSTRAP_TOKEN ?? '',
refreshRotationGraceMs: parsePositiveInteger(process.env.AUTH_REFRESH_ROTATION_GRACE_MS, 30_000),
passwordLoginEnabled: !parseBooleanFlag(process.env.DISABLE_LOCAL_AUTH, false),
}));
export const storageConfig = registerAs('storage', () => {
const appDataPath = resolve(process.env.APP_DATA_PATH ?? '/data');
const bookDockPath = process.env.BOOK_DOCK_PATH?.trim();
const libraryBrowseRoot = process.env.LIBRARY_BROWSE_ROOT?.trim();
return {
appDataPath,
bookDockPath: resolve(bookDockPath || join(appDataPath, 'book-dock')),
libraryBrowseRoot: resolve(libraryBrowseRoot || '/'),
};
});
export const coverSlotsConfig = registerAs('coverSlots', () => ({
backfillMode: process.env.COVER_SLOTS_BACKFILL_MODE?.trim().toLowerCase() || (process.env.NODE_ENV === 'test' ? 'skip' : 'background'),
}));
export const fileWriteConfig = registerAs('fileWrite', () => ({
debounceMs: parsePositiveInteger(process.env.FILE_WRITE_DEBOUNCE_MS, 3_000),
maxConcurrentWrites: parsePositiveInteger(process.env.FILE_WRITE_MAX_CONCURRENT_WRITES, 2),
}));
export const djvuConfig = registerAs('djvu', () => ({
maxConcurrent: parsePositiveInteger(process.env.DJVU_MAX_CONCURRENT, 2),
timeoutMs: parsePositiveInteger(process.env.DJVU_TIMEOUT_MS, 30_000),
maxSourceBytes: parsePositiveInteger(process.env.DJVU_MAX_SOURCE_BYTES, 256 * 1024 * 1024),
cacheBytes: parsePositiveInteger(process.env.DJVU_CACHE_BYTES, 64 * 1024 * 1024),
}));
export const audiolessEpubConfig = registerAs('audiolessEpub', () => ({
maxConcurrentBuilds: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_CONCURRENT_BUILDS, 2),
maxSourceEntries: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_SOURCE_ENTRIES, 50_000),
maxMetadataBytes: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_METADATA_BYTES, 16 * 1024 * 1024),
maxOutputBytes: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_OUTPUT_BYTES, 2 * 1024 * 1024 * 1024),
}));
export const readAloudConfig = registerAs('readAloud', () => {
const appDataPath = resolve(process.env.APP_DATA_PATH ?? '/data');
return {
workerUrl: process.env.READ_ALOUD_WORKER_URL?.trim().replace(/\/$/, '') || undefined,
workerToken: process.env.READ_ALOUD_WORKER_TOKEN?.trim() || undefined,
workRoot: join(appDataPath, 'read-aloud', 'jobs'),
maxOutputBytes: parsePositiveInteger(process.env.READ_ALOUD_MAX_OUTPUT_BYTES, 20 * 1024 * 1024 * 1024),
requestTimeoutMs: parsePositiveInteger(process.env.READ_ALOUD_WORKER_REQUEST_TIMEOUT_MS, 30_000),
};
});
/** External audio tools. The binaries are looked up on PATH unless an absolute path is given. */
export const audioToolsConfig = registerAs('audioTools', () => ({
ffmpegPath: process.env.FFMPEG_PATH?.trim() || 'ffmpeg',
ffprobePath: process.env.FFPROBE_PATH?.trim() || 'ffprobe',
}));
export const emailConfig = registerAs('email', () => ({
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
}));
export const migrationConfig = registerAs('migration', () => ({
encryptionKey: process.env.MIGRATION_ENCRYPTION_KEY ?? '',
importRoot: process.env.MIGRATION_IMPORT_ROOT?.trim() ? resolve(process.env.MIGRATION_IMPORT_ROOT) : undefined,
}));
/**
* Download-client and (from phase 3) indexer credentials. Unlike the email and migration keys,
* `RequestCredentialService` refuses to store a secret when this is unset rather than falling
* back to plaintext.
*/
export const bookRequestConfig = registerAs('bookRequest', () => ({
encryptionKey: process.env.BOOK_REQUEST_ENCRYPTION_KEY ?? '',
}));
export const podcastConfig = registerAs('podcast', () => ({
encryptionKey: process.env.PODCAST_ENCRYPTION_KEY?.trim() || process.env.JWT_SECRET || 'change-me-in-production',
maxFeedBytes: parsePositiveInteger(process.env.PODCAST_MAX_FEED_BYTES, 10 * 1024 * 1024),
maxEpisodeBytes: parsePositiveInteger(process.env.PODCAST_MAX_EPISODE_BYTES, 2 * 1024 * 1024 * 1024),
maxConcurrentDownloads: parsePositiveInteger(process.env.PODCAST_MAX_CONCURRENT_DOWNLOADS, 2),
requestTimeoutMs: parsePositiveInteger(process.env.PODCAST_REQUEST_TIMEOUT_MS, 30_000),
maxDownloadDurationMs: parsePositiveInteger(process.env.PODCAST_MAX_DOWNLOAD_DURATION_MS, 6 * 60 * 60_000),
}));
export const oidcRuntimeConfig = registerAs('oidcRuntime', () => ({
stateTtlMs: parsePositiveInteger(process.env.OIDC_STATE_TTL_SECS, 300) * 1000,
discoveryCacheTtlMs: parsePositiveInteger(process.env.OIDC_DISCOVERY_CACHE_TTL_SECS, 3600) * 1000,
jwksCacheTtlMs: parsePositiveInteger(process.env.OIDC_JWKS_CACHE_TTL_SECS, 21600) * 1000,
clockToleranceSecs: parsePositiveInteger(process.env.OIDC_CLOCK_TOLERANCE_SECS, 30),
tokenExchangeTimeoutMs: parsePositiveInteger(process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS, 10_000),
}));
function parsePositiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return Math.floor(parsed);
}
function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean {
const normalized = value?.trim().toLowerCase();
if (!normalized) return fallback;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
const DEFAULT_METADATA_PROVIDER_DIRECTORY_URL = 'https://raw.githubusercontent.com/bookorbit/bookorbit-metadata-providers/main/directory.json';
/** Null when the online list of ready-made metadata providers is turned off. */
function parseDirectoryUrl(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return DEFAULT_METADATA_PROVIDER_DIRECTORY_URL;
return trimmed.toLowerCase() === 'off' ? null : trimmed;
}