mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add rootless Podman fixes, and others
improve container startup for rootless Podman, plus related refactors and tests. Key changes: - Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation. - Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification. - Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests. - Update many admin/auth API routes and tests to reflect changes in settings and integrations. - Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow. These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Component: Audiobookshelf API Client
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*
|
||||
* Provides API methods for interacting with Audiobookshelf:
|
||||
* - Library scanning and item fetching
|
||||
* - Metadata matching (with ASIN for accurate Audible lookup)
|
||||
* - Item management
|
||||
*/
|
||||
|
||||
import { getConfigService } from '../config.service';
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Component: Auth Token Cache Service
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*
|
||||
* Provides secure server-side storage for Plex OAuth tokens during the
|
||||
* profile selection flow. Tokens are stored in memory with automatic
|
||||
* expiration to prevent sensitive data from being exposed in client responses.
|
||||
*
|
||||
* Security: This service exists to prevent Plex tokens from being embedded
|
||||
* in HTML responses or JSON payloads where they could be captured by
|
||||
* viewing page source or intercepting network traffic.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('AuthTokenCache');
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TTL for cached tokens (5 minutes)
|
||||
* This is sufficient time for profile selection while minimizing exposure window
|
||||
*/
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cleanup interval - run every minute to remove expired tokens
|
||||
*/
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* AuthTokenCacheService - Singleton service for secure token storage
|
||||
*
|
||||
* Uses an in-memory Map for storage. Tokens are automatically expired
|
||||
* and cleaned up. This is intentionally ephemeral - if the server restarts,
|
||||
* users in the middle of profile selection will need to re-authenticate,
|
||||
* which is acceptable for security.
|
||||
*/
|
||||
class AuthTokenCacheService {
|
||||
private cache: Map<string, CachedToken> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private ttlMs: number;
|
||||
|
||||
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a Plex token for later retrieval
|
||||
*
|
||||
* @param pinId - The Plex PIN ID (used as the lookup key)
|
||||
* @param token - The Plex OAuth token to store
|
||||
* @param ttlMs - Optional custom TTL for this token
|
||||
*/
|
||||
set(pinId: string, token: string, ttlMs?: number): void {
|
||||
const effectiveTtl = ttlMs ?? this.ttlMs;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.set(pinId, {
|
||||
token,
|
||||
createdAt: now,
|
||||
expiresAt: now + effectiveTtl,
|
||||
});
|
||||
|
||||
logger.debug('Token cached', {
|
||||
pinId,
|
||||
ttlSeconds: Math.round(effectiveTtl / 1000),
|
||||
cacheSize: this.cache.size,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a stored token by PIN ID
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns The stored token, or null if not found/expired
|
||||
*/
|
||||
get(pinId: string): string | null {
|
||||
const cached = this.cache.get(pinId);
|
||||
|
||||
if (!cached) {
|
||||
logger.debug('Token not found in cache', { pinId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
logger.debug('Token expired', { pinId });
|
||||
this.cache.delete(pinId);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('Token retrieved from cache', { pinId });
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token from the cache
|
||||
* Called after successful authentication to clean up
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns true if a token was removed, false if not found
|
||||
*/
|
||||
delete(pinId: string): boolean {
|
||||
const existed = this.cache.has(pinId);
|
||||
this.cache.delete(pinId);
|
||||
|
||||
if (existed) {
|
||||
logger.debug('Token removed from cache', { pinId, cacheSize: this.cache.size });
|
||||
}
|
||||
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token exists and is not expired
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns true if token exists and is valid
|
||||
*/
|
||||
has(pinId: string): boolean {
|
||||
const cached = this.cache.get(pinId);
|
||||
if (!cached) return false;
|
||||
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
this.cache.delete(pinId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache size (for monitoring)
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger cleanup of expired tokens
|
||||
* Called automatically on interval, but can be called manually if needed
|
||||
*/
|
||||
cleanup(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [pinId, cached] of this.cache.entries()) {
|
||||
if (now > cached.expiresAt) {
|
||||
this.cache.delete(pinId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
logger.debug('Expired tokens cleaned up', { removed, remaining: this.cache.size });
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached tokens
|
||||
* Use with caution - will force all users in profile selection to re-authenticate
|
||||
*/
|
||||
clear(): void {
|
||||
const count = this.cache.size;
|
||||
this.cache.clear();
|
||||
logger.info('Token cache cleared', { tokensRemoved: count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the automatic cleanup interval
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
// Don't start multiple intervals
|
||||
if (this.cleanupInterval) return;
|
||||
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Don't prevent Node.js from exiting
|
||||
if (this.cleanupInterval.unref) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
|
||||
logger.debug('Cleanup interval started', { intervalMs: CLEANUP_INTERVAL_MS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup interval (for testing or shutdown)
|
||||
*/
|
||||
stopCleanupInterval(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
logger.debug('Cleanup interval stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: AuthTokenCacheService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton AuthTokenCacheService instance
|
||||
*/
|
||||
export function getAuthTokenCache(): AuthTokenCacheService {
|
||||
if (!instance) {
|
||||
instance = new AuthTokenCacheService();
|
||||
logger.info('Auth token cache initialized');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing only)
|
||||
*/
|
||||
export function resetAuthTokenCache(): void {
|
||||
if (instance) {
|
||||
instance.stopCleanupInterval();
|
||||
instance.clear();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthTokenCacheService };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Component: Credential Migration Service
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*
|
||||
* One-time migration to encrypt plaintext credentials stored in the database.
|
||||
* Runs on startup and auto-detects plaintext vs encrypted values.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('CredentialMigration');
|
||||
|
||||
/**
|
||||
* Check if a value looks like it's already encrypted.
|
||||
* Encrypted values have format: base64:base64:base64 (iv:authTag:ciphertext)
|
||||
*/
|
||||
export function isEncryptedFormat(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all parts look like base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
||||
return parts.every(part => part.length > 0 && base64Regex.test(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single configuration key from plaintext to encrypted.
|
||||
* Returns true if migration was performed, false if already encrypted or not found.
|
||||
*/
|
||||
async function migrateConfigKey(key: string): Promise<boolean> {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
if (!config || !config.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if already marked as encrypted
|
||||
if (config.encrypted) {
|
||||
logger.debug(`Key "${key}" already marked as encrypted, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if value looks like it's already encrypted (format check)
|
||||
if (isEncryptedFormat(config.value)) {
|
||||
logger.debug(`Key "${key}" appears to be in encrypted format, updating flag only`);
|
||||
await prisma.configuration.update({
|
||||
where: { key },
|
||||
data: { encrypted: true },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt the plaintext value
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedValue = encryptionService.encrypt(config.value);
|
||||
|
||||
await prisma.configuration.update({
|
||||
where: { key },
|
||||
data: {
|
||||
value: encryptedValue,
|
||||
encrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Migrated credential: ${key}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate download_clients JSON to encrypt passwords within.
|
||||
* Returns true if any passwords were encrypted.
|
||||
*/
|
||||
async function migrateDownloadClients(): Promise<boolean> {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_clients' },
|
||||
});
|
||||
|
||||
if (!config || !config.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let clients: any[];
|
||||
try {
|
||||
clients = JSON.parse(config.value);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse download_clients JSON', { error });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(clients) || clients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const client of clients) {
|
||||
// Encrypt password if present and not already encrypted
|
||||
if (client.password && typeof client.password === 'string' && !isEncryptedFormat(client.password)) {
|
||||
client.password = encryptionService.encrypt(client.password);
|
||||
migratedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCount > 0) {
|
||||
await prisma.configuration.update({
|
||||
where: { key: 'download_clients' },
|
||||
data: { value: JSON.stringify(clients) },
|
||||
});
|
||||
|
||||
logger.info(`Migrated ${migratedCount} download client password(s)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the credential migration.
|
||||
* Safe to call multiple times - detects and skips already-encrypted values.
|
||||
*/
|
||||
export async function runCredentialMigration(): Promise<void> {
|
||||
logger.info('Starting credential migration check...');
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
// Migrate simple config keys
|
||||
const keysToMigrate = [
|
||||
'plex_token',
|
||||
'prowlarr_api_key',
|
||||
];
|
||||
|
||||
for (const key of keysToMigrate) {
|
||||
try {
|
||||
const migrated = await migrateConfigKey(key);
|
||||
if (migrated) {
|
||||
totalMigrated++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to migrate ${key}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate download client passwords
|
||||
try {
|
||||
const migratedClients = await migrateDownloadClients();
|
||||
if (migratedClients) {
|
||||
totalMigrated++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate download client passwords', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
if (totalMigrated > 0) {
|
||||
logger.info(`Credential migration complete: ${totalMigrated} item(s) encrypted`);
|
||||
} else {
|
||||
logger.info('Credential migration complete: no changes needed');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ConfigurationService } from './config.service';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { isEncryptedFormat } from './credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
@@ -86,8 +88,26 @@ export class DownloadClientManager {
|
||||
if (configValue) {
|
||||
try {
|
||||
const clients = JSON.parse(configValue) as DownloadClientConfig[];
|
||||
this.clientsCache = clients;
|
||||
return clients;
|
||||
|
||||
// Decrypt passwords if they're in encrypted format
|
||||
const encryptionService = getEncryptionService();
|
||||
const decryptedClients = clients.map(client => {
|
||||
if (client.password && isEncryptedFormat(client.password)) {
|
||||
try {
|
||||
return {
|
||||
...client,
|
||||
password: encryptionService.decrypt(client.password),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to decrypt password for client ${client.name}`, { error });
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
this.clientsCache = decryptedClients;
|
||||
return decryptedClients;
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse download_clients config', { error });
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user