mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.ID');
|
||||
@@ -97,10 +98,15 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
// Update clients array
|
||||
// Update clients array and encrypt passwords before saving
|
||||
clients[clientIndex] = updatedClient;
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedClients = clients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(clients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
@@ -153,10 +159,15 @@ export async function DELETE(
|
||||
|
||||
const deletedClient = clients[clientIndex];
|
||||
|
||||
// Remove client from array
|
||||
// Remove client from array and encrypt passwords before saving
|
||||
const updatedClients = clients.filter(c => c.id !== id);
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedClients = updatedClients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -111,7 +112,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Create new client config
|
||||
// Create new client config for testing (with plaintext password)
|
||||
// qBittorrent credentials are optional (supports IP whitelist auth)
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
@@ -120,7 +121,7 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
url,
|
||||
username: username || '',
|
||||
password: password || '',
|
||||
password: password || '', // Plaintext for connection test
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||
remotePath: remotePath || undefined,
|
||||
@@ -137,10 +138,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt all passwords before saving (existing clients come decrypted from getAllClients)
|
||||
const encryptionService = getEncryptionService();
|
||||
const allClients = [...existingClients, newClient];
|
||||
const encryptedClients = allClients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
|
||||
// Save updated clients array
|
||||
const updatedClients = [...existingClients, newClient];
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Paths');
|
||||
@@ -84,6 +85,14 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
const configService = getConfigService();
|
||||
configService.clearCache('download_dir');
|
||||
configService.clearCache('media_dir');
|
||||
configService.clearCache('audiobook_path_template');
|
||||
configService.clearCache('metadata_tagging_enabled');
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
|
||||
// Invalidate qBittorrent service singleton to force reload of download_dir
|
||||
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||
invalidateQBittorrentService();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.AdminPlexSettings');
|
||||
@@ -33,10 +34,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Only update token if it's not the masked value
|
||||
if (!token.startsWith('••••')) {
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedToken = encryptionService.encrypt(token);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: token },
|
||||
create: { key: 'plex_token', value: token },
|
||||
update: { value: encryptedToken, encrypted: true },
|
||||
create: { key: 'plex_token', value: encryptedToken, encrypted: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,10 +62,10 @@ export async function PUT(request: NextRequest) {
|
||||
const plexService = getPlexService();
|
||||
const actualToken = token.startsWith('••••') ? null : token;
|
||||
|
||||
// Get token from DB if it was masked
|
||||
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
}))?.value;
|
||||
// Get token from DB if it was masked (decrypted via ConfigService)
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const tokenToUse = actualToken || await configService.get('plex_token');
|
||||
|
||||
if (tokenToUse) {
|
||||
const serverInfo = await plexService.testConnection(url, tokenToUse);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
||||
@@ -32,10 +33,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Only update API key if it's not the masked value
|
||||
if (!apiKey.startsWith('••••')) {
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedApiKey = encryptionService.encrypt(apiKey);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: apiKey },
|
||||
create: { key: 'prowlarr_api_key', value: apiKey },
|
||||
update: { value: encryptedApiKey, encrypted: true },
|
||||
create: { key: 'prowlarr_api_key', value: encryptedApiKey, encrypted: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -43,21 +44,24 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If password is masked, fetch the actual value from database
|
||||
// If password is masked, fetch the actual value from download client manager (decrypted)
|
||||
let actualPassword = password;
|
||||
if (password && password.startsWith('••••')) {
|
||||
const storedPassword = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_client_password' },
|
||||
});
|
||||
if (password && (password.startsWith('••••') || password === '********')) {
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const clients = await manager.getAllClients();
|
||||
|
||||
if (!storedPassword?.value) {
|
||||
// Find the first client of matching type to get its password
|
||||
const matchingClient = clients.find(c => c.type === type);
|
||||
|
||||
if (!matchingClient?.password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored password/API key found. Please re-enter it.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualPassword = storedPassword.value;
|
||||
actualPassword = matchingClient.password;
|
||||
}
|
||||
|
||||
// Validate required fields per client type and test connection
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If token is masked, fetch the actual value from database
|
||||
// If token is masked, fetch the actual value from database (decrypted)
|
||||
let actualToken = token;
|
||||
if (token.startsWith('••••')) {
|
||||
const storedToken = await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
});
|
||||
const configService = getConfigService();
|
||||
const storedToken = await configService.get('plex_token');
|
||||
|
||||
if (!storedToken?.value) {
|
||||
if (!storedToken) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualToken = storedToken.value;
|
||||
actualToken = storedToken;
|
||||
}
|
||||
|
||||
const plexService = getPlexService();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If API key is masked, fetch the actual value from database
|
||||
// If API key is masked, fetch the actual value from database (decrypted)
|
||||
let actualApiKey = apiKey;
|
||||
if (apiKey.startsWith('••••')) {
|
||||
const storedApiKey = await prisma.configuration.findUnique({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
});
|
||||
const configService = getConfigService();
|
||||
const storedApiKey = await configService.get('prowlarr_api_key');
|
||||
|
||||
if (!storedApiKey?.value) {
|
||||
if (!storedApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualApiKey = storedApiKey.value;
|
||||
actualApiKey = storedApiKey;
|
||||
}
|
||||
|
||||
// Create a new ProwlarrService instance with test credentials
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -149,12 +150,19 @@ export async function GET(request: NextRequest) {
|
||||
if (homeUsers.length > 1) {
|
||||
logger.info('Account has multiple home profiles, redirecting to profile selection');
|
||||
|
||||
// SECURITY: Store the Plex token server-side instead of exposing it to the client
|
||||
// The token is keyed by pinId and will be retrieved during profile selection/switch
|
||||
const tokenCache = getAuthTokenCache();
|
||||
tokenCache.set(pinId, authToken);
|
||||
logger.debug('Plex token cached for profile selection', { pinId });
|
||||
|
||||
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
|
||||
const accept = request.headers.get('accept') || '';
|
||||
const isBrowserRequest = accept.includes('text/html');
|
||||
|
||||
if (isBrowserRequest) {
|
||||
// For browser requests (mobile), construct redirect URL with session data
|
||||
// For browser requests (mobile), construct redirect URL
|
||||
// Token is stored server-side, only pinId is passed to client
|
||||
const host = request.headers.get('host') || 'localhost:3030';
|
||||
const protocol = request.headers.get('x-forwarded-proto') ||
|
||||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||
@@ -162,7 +170,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
logger.debug('Redirecting to profile selection', { selectProfileUrl });
|
||||
|
||||
// Return HTML page with JavaScript to store token in sessionStorage and redirect
|
||||
// Return HTML page that redirects to profile selection
|
||||
// Note: Token is NOT included - it's stored server-side for security
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -172,9 +181,7 @@ export async function GET(request: NextRequest) {
|
||||
<body>
|
||||
<p>Loading profiles...</p>
|
||||
<script>
|
||||
// Store main account token in session storage for profile selection page
|
||||
sessionStorage.setItem('plex_main_token', '${authToken}');
|
||||
// Redirect to profile selection
|
||||
// Redirect to profile selection (token is stored server-side)
|
||||
window.location.href = '${selectProfileUrl}';
|
||||
</script>
|
||||
</body>
|
||||
@@ -189,12 +196,12 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} else {
|
||||
// For AJAX requests (desktop popup), return JSON with redirect instruction
|
||||
// Note: Token is NOT included - it's stored server-side for security
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authorized: true,
|
||||
requiresProfileSelection: true,
|
||||
redirectUrl: `/auth/select-profile?pinId=${pinId}`,
|
||||
mainAccountToken: authToken, // Client will store this temporarily
|
||||
homeUsers: homeUsers.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
|
||||
@@ -12,16 +13,36 @@ const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
|
||||
/**
|
||||
* GET /api/auth/plex/home-users
|
||||
* Get list of Plex Home profiles for authenticated user
|
||||
*
|
||||
* Authentication: Provide X-Plex-Pin-Id header with the PIN ID from OAuth flow.
|
||||
* The Plex token is retrieved from server-side cache for security.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authToken = request.headers.get('X-Plex-Token');
|
||||
// Get pinId from header - token is stored server-side for security
|
||||
const pinId = request.headers.get('X-Plex-Pin-Id');
|
||||
|
||||
if (!authToken) {
|
||||
if (!pinId) {
|
||||
logger.warn('Missing X-Plex-Pin-Id header');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Missing authentication token',
|
||||
message: 'Missing PIN ID. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieve the Plex token from server-side cache
|
||||
const tokenCache = getAuthTokenCache();
|
||||
const authToken = tokenCache.get(pinId);
|
||||
|
||||
if (!authToken) {
|
||||
logger.warn('Token not found or expired for pinId', { pinId });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'SessionExpired',
|
||||
message: 'Your session has expired. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
@@ -30,6 +51,8 @@ export async function GET(request: NextRequest) {
|
||||
const plexService = getPlexService();
|
||||
const users = await plexService.getHomeUsers(authToken);
|
||||
|
||||
logger.debug('Home users retrieved', { pinId, userCount: users.length });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -15,23 +16,41 @@ const logger = RMABLogger.create('API.PlexSwitchProfile');
|
||||
/**
|
||||
* POST /api/auth/plex/switch-profile
|
||||
* Switch to a Plex Home profile and complete authentication
|
||||
*
|
||||
* Authentication: Provide pinId in request body. The Plex token is
|
||||
* retrieved from server-side cache for security.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const mainAccountToken = request.headers.get('X-Plex-Token');
|
||||
const body = await request.json();
|
||||
const { userId, pin, pinId, profileInfo } = body;
|
||||
|
||||
if (!mainAccountToken) {
|
||||
// Validate pinId is provided
|
||||
if (!pinId) {
|
||||
logger.warn('Missing pinId in request body');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Missing authentication token',
|
||||
message: 'Missing PIN ID. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, pin, pinId, profileInfo } = body;
|
||||
// Retrieve the Plex token from server-side cache
|
||||
const tokenCache = getAuthTokenCache();
|
||||
const mainAccountToken = tokenCache.get(pinId);
|
||||
|
||||
if (!mainAccountToken) {
|
||||
logger.warn('Token not found or expired for pinId', { pinId });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'SessionExpired',
|
||||
message: 'Your session has expired. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
@@ -155,6 +174,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
// Clean up the cached Plex token - authentication is complete
|
||||
tokenCache.delete(pinId);
|
||||
logger.debug('Cached Plex token cleaned up after successful auth', { pinId });
|
||||
|
||||
// Return tokens and user info
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
import { runCredentialMigration } from '@/lib/services/credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Init');
|
||||
@@ -18,6 +19,9 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
logger.info('Initializing application services...');
|
||||
|
||||
// Run credential migration (encrypts any plaintext credentials)
|
||||
await runCredentialMigration();
|
||||
|
||||
// Initialize scheduler service
|
||||
const schedulerService = getSchedulerService();
|
||||
await schedulerService.start();
|
||||
|
||||
@@ -193,10 +193,11 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'plex_url', value: plex.url },
|
||||
});
|
||||
|
||||
const encryptedPlexToken = encryptionService.encrypt(plex.token);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: plex.token },
|
||||
create: { key: 'plex_token', value: plex.token },
|
||||
update: { value: encryptedPlexToken, encrypted: true },
|
||||
create: { key: 'plex_token', value: encryptedPlexToken, encrypted: true },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
@@ -375,10 +376,11 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'prowlarr_url', value: prowlarr.url },
|
||||
});
|
||||
|
||||
const encryptedProwlarrApiKey = encryptionService.encrypt(prowlarr.api_key);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: prowlarr.api_key },
|
||||
create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
|
||||
update: { value: encryptedProwlarrApiKey, encrypted: true },
|
||||
create: { key: 'prowlarr_api_key', value: encryptedProwlarrApiKey, encrypted: true },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
@@ -414,10 +416,16 @@ export async function POST(request: NextRequest) {
|
||||
throw new Error('Invalid download client configuration');
|
||||
}
|
||||
|
||||
// Encrypt passwords in download clients
|
||||
const encryptedClients = downloadClientsArray.map(client => ({
|
||||
...client,
|
||||
password: client.password ? encryptionService.encrypt(client.password) : '',
|
||||
}));
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_clients' },
|
||||
update: { value: JSON.stringify(downloadClientsArray) },
|
||||
create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) },
|
||||
update: { value: JSON.stringify(encryptedClients) },
|
||||
create: { key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
});
|
||||
|
||||
// Path configuration
|
||||
|
||||
@@ -38,42 +38,45 @@ function SelectProfileContent() {
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Get token from session storage (set by OAuth callback)
|
||||
const mainAccountToken = typeof window !== 'undefined' ? sessionStorage.getItem('plex_main_token') : null;
|
||||
// Get pinId from URL - the Plex token is stored server-side for security
|
||||
const pinId = searchParams.get('pinId');
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainAccountToken || !pinId) {
|
||||
if (!pinId) {
|
||||
setError('Invalid session. Please try logging in again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch home users
|
||||
// Fetch home users using pinId (token is looked up server-side)
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/plex/home-users', {
|
||||
headers: {
|
||||
'X-Plex-Token': mainAccountToken,
|
||||
'X-Plex-Pin-Id': pinId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profiles');
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
throw new Error(errorData.message || 'Session expired. Please try logging in again.');
|
||||
}
|
||||
throw new Error(errorData.message || 'Failed to fetch profiles');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfiles(data.users || []);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch profiles:', err);
|
||||
setError('Failed to load profiles. Please try again.');
|
||||
setError(err.message || 'Failed to load profiles. Please try again.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfiles();
|
||||
}, [mainAccountToken, pinId]);
|
||||
}, [pinId]);
|
||||
|
||||
const handleProfileSelect = async (profile: PlexHomeUser) => {
|
||||
setSelectedProfile(profile.id);
|
||||
@@ -97,7 +100,10 @@ function SelectProfileContent() {
|
||||
};
|
||||
|
||||
const completeProfileSelection = async (profileId: string, profilePin?: string) => {
|
||||
if (!mainAccountToken) return;
|
||||
if (!pinId) {
|
||||
setError('Session expired. Please try logging in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setPinError(null);
|
||||
@@ -111,11 +117,11 @@ function SelectProfileContent() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch profile using pinId - token is looked up server-side for security
|
||||
const response = await fetch('/api/auth/plex/switch-profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Plex-Token': mainAccountToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: profileId,
|
||||
@@ -134,8 +140,15 @@ function SelectProfileContent() {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
setPinError('Invalid PIN. Please try again.');
|
||||
setPin('');
|
||||
// Check if it's a PIN error or session expiry
|
||||
if (data.error === 'InvalidPIN') {
|
||||
setPinError('Invalid PIN. Please try again.');
|
||||
setPin('');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// Session expired
|
||||
setError(data.message || 'Session expired. Please try logging in again.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -150,9 +163,6 @@ function SelectProfileContent() {
|
||||
// Update auth context
|
||||
setAuthData(data.user, data.accessToken);
|
||||
|
||||
// Clear session storage
|
||||
sessionStorage.removeItem('plex_main_token');
|
||||
|
||||
// Redirect to home
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -173,10 +173,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (data.success && data.authorized) {
|
||||
// Check if profile selection is required (Plex Home accounts)
|
||||
if (data.requiresProfileSelection) {
|
||||
// Store main account token temporarily for profile selection
|
||||
sessionStorage.setItem('plex_main_token', data.mainAccountToken);
|
||||
|
||||
// Redirect to profile selection page
|
||||
// Note: Plex token is stored server-side for security, not in sessionStorage
|
||||
window.location.href = data.redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1027,8 +1027,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
pathMappingEnabled: clientConfig.remotePathMappingEnabled,
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
|
||||
// Validate required fields (only URL is required - username/password optional for whitelist users)
|
||||
if (!clientConfig.url) {
|
||||
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
@@ -1045,8 +1045,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
logger.info('[QBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
clientConfig.url,
|
||||
clientConfig.username,
|
||||
clientConfig.password,
|
||||
clientConfig.username || '',
|
||||
clientConfig.password || '',
|
||||
downloadDir,
|
||||
clientConfig.category || 'readmeabook',
|
||||
clientConfig.disableSSLVerify,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -296,6 +297,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
@@ -776,6 +789,18 @@ async function processEbookOrganization(
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -676,9 +676,14 @@ export async function mergeChapters(
|
||||
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
|
||||
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
|
||||
|
||||
// Add book metadata
|
||||
// Add book metadata (escape for double-quoted shell context)
|
||||
// Single quotes do NOT need escaping inside double quotes - they are literal
|
||||
const escapeMetadata = (val: string): string =>
|
||||
val.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
val
|
||||
.replace(/\\/g, '\\\\') // Backslashes first
|
||||
.replace(/"/g, '\\"') // Double quotes
|
||||
.replace(/`/g, '\\`') // Backticks
|
||||
.replace(/\$/g, '\\$'); // Dollar signs
|
||||
|
||||
args.push('-metadata', `title="${escapeMetadata(options.title)}"`);
|
||||
args.push('-metadata', `album="${escapeMetadata(options.title)}"`);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Cleanup Helpers Utility
|
||||
* Documentation: documentation/phase3/sabnzbd.md
|
||||
*
|
||||
* Provides utilities for cleaning up after file organization,
|
||||
* including removal of empty parent directories.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('CleanupHelpers');
|
||||
|
||||
/**
|
||||
* Options for removeEmptyParentDirectories
|
||||
*/
|
||||
export interface RemoveEmptyParentOptions {
|
||||
/** The boundary path - will never delete this directory or its parents */
|
||||
boundaryPath: string;
|
||||
/** Optional logger context for job-aware logging */
|
||||
logContext?: { jobId: string; context: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes empty parent directories after a file/directory has been deleted.
|
||||
*
|
||||
* This function walks up the directory tree from the deleted path, removing
|
||||
* any empty directories until it encounters a non-empty directory or reaches
|
||||
* the configured boundary path.
|
||||
*
|
||||
* Use case: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
|
||||
* After deleting the download folder, the category folder (readmeabook) may
|
||||
* be left empty. This function cleans up those empty parent folders.
|
||||
*
|
||||
* Safety features:
|
||||
* - Will NEVER delete the boundary path itself (e.g., download_dir)
|
||||
* - Will NEVER delete above the boundary path
|
||||
* - Gracefully handles ENOENT (already deleted)
|
||||
* - Gracefully handles permission errors (logs warning, continues)
|
||||
* - Stops immediately when a non-empty directory is encountered
|
||||
*
|
||||
* @param deletedPath - The path that was just deleted (file or directory)
|
||||
* @param options - Configuration options including boundary path
|
||||
* @returns Object with details about what was cleaned up
|
||||
*
|
||||
* @example
|
||||
* // After deleting /downloads/readmeabook/My.Audiobook.Name
|
||||
* await removeEmptyParentDirectories(
|
||||
* '/downloads/readmeabook/My.Audiobook.Name',
|
||||
* { boundaryPath: '/downloads' }
|
||||
* );
|
||||
* // This will remove /downloads/readmeabook if it's empty
|
||||
* // but will never touch /downloads
|
||||
*/
|
||||
export async function removeEmptyParentDirectories(
|
||||
deletedPath: string,
|
||||
options: RemoveEmptyParentOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
removedDirectories: string[];
|
||||
stoppedAt?: string;
|
||||
stoppedReason?: 'non_empty' | 'boundary_reached' | 'root_reached' | 'error';
|
||||
error?: string;
|
||||
}> {
|
||||
const log = options.logContext
|
||||
? RMABLogger.forJob(options.logContext.jobId, options.logContext.context)
|
||||
: logger;
|
||||
|
||||
const removedDirectories: string[] = [];
|
||||
|
||||
try {
|
||||
// Normalize paths for consistent comparison
|
||||
const normalizedBoundary = normalizePath(options.boundaryPath);
|
||||
let currentPath = normalizePath(path.dirname(deletedPath));
|
||||
|
||||
log.debug('Starting empty parent directory cleanup', {
|
||||
deletedPath,
|
||||
boundaryPath: options.boundaryPath,
|
||||
normalizedBoundary,
|
||||
startingFrom: currentPath,
|
||||
});
|
||||
|
||||
// Walk up the directory tree
|
||||
while (true) {
|
||||
// Safety check: Have we reached the filesystem root?
|
||||
const parentPath = normalizePath(path.dirname(currentPath));
|
||||
if (parentPath === currentPath) {
|
||||
log.debug('Reached filesystem root, stopping cleanup');
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'root_reached',
|
||||
};
|
||||
}
|
||||
|
||||
// Safety check: Have we reached or passed the boundary?
|
||||
if (!isPathBelowBoundary(currentPath, normalizedBoundary)) {
|
||||
log.debug('Reached boundary path, stopping cleanup', {
|
||||
currentPath,
|
||||
boundaryPath: normalizedBoundary,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'boundary_reached',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the directory is empty
|
||||
const isEmpty = await isDirectoryEmpty(currentPath);
|
||||
|
||||
if (isEmpty === null) {
|
||||
// Directory doesn't exist (ENOENT) - move to parent
|
||||
log.debug(`Directory does not exist, moving to parent: ${currentPath}`);
|
||||
currentPath = parentPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEmpty) {
|
||||
// Directory is not empty - stop here
|
||||
log.debug(`Directory not empty, stopping cleanup: ${currentPath}`);
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'non_empty',
|
||||
};
|
||||
}
|
||||
|
||||
// Directory is empty - try to remove it
|
||||
try {
|
||||
await fs.rmdir(currentPath);
|
||||
removedDirectories.push(currentPath);
|
||||
log.info(`Removed empty directory: ${currentPath}`);
|
||||
} catch (removeError) {
|
||||
const errorCode = (removeError as NodeJS.ErrnoException).code;
|
||||
|
||||
if (errorCode === 'ENOENT') {
|
||||
// Already deleted (race condition) - continue to parent
|
||||
log.debug(`Directory already deleted: ${currentPath}`);
|
||||
} else if (errorCode === 'ENOTEMPTY') {
|
||||
// Directory became non-empty (race condition) - stop
|
||||
log.debug(`Directory became non-empty: ${currentPath}`);
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'non_empty',
|
||||
};
|
||||
} else if (errorCode === 'EACCES' || errorCode === 'EPERM') {
|
||||
// Permission error - log warning and stop
|
||||
log.warn(`Permission denied removing directory: ${currentPath}`, {
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
});
|
||||
return {
|
||||
success: true, // Partial success - we cleaned what we could
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'error',
|
||||
error: `Permission denied: ${currentPath}`,
|
||||
};
|
||||
} else {
|
||||
// Unexpected error - log and stop
|
||||
log.error(`Failed to remove directory: ${currentPath}`, {
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
errorCode,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'error',
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent directory
|
||||
currentPath = parentPath;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Unexpected error during empty parent cleanup', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
deletedPath,
|
||||
boundaryPath: options.boundaryPath,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
removedDirectories,
|
||||
stoppedReason: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a directory is empty
|
||||
*
|
||||
* @param dirPath - Path to the directory
|
||||
* @returns true if empty, false if not empty, null if directory doesn't exist
|
||||
*/
|
||||
async function isDirectoryEmpty(dirPath: string): Promise<boolean | null> {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath);
|
||||
return entries.length === 0;
|
||||
} catch (error) {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (errorCode === 'ENOENT') {
|
||||
// Directory doesn't exist
|
||||
return null;
|
||||
}
|
||||
|
||||
if (errorCode === 'ENOTDIR') {
|
||||
// Path is a file, not a directory
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is strictly below (inside) the boundary path
|
||||
*
|
||||
* A path is below the boundary if:
|
||||
* - It's longer than the boundary path
|
||||
* - It starts with the boundary path followed by a path separator
|
||||
*
|
||||
* @param testPath - The path to test (must be normalized)
|
||||
* @param boundaryPath - The boundary path (must be normalized)
|
||||
* @returns true if testPath is strictly below boundaryPath
|
||||
*/
|
||||
function isPathBelowBoundary(testPath: string, boundaryPath: string): boolean {
|
||||
// Ensure both paths don't have trailing slashes for comparison
|
||||
const normalizedTest = testPath.replace(/\/+$/, '');
|
||||
const normalizedBoundary = boundaryPath.replace(/\/+$/, '');
|
||||
|
||||
// Path must be strictly below boundary, not equal to it
|
||||
if (normalizedTest === normalizedBoundary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if test path is under boundary path
|
||||
// Must start with boundary + separator to avoid matching /downloads2 when boundary is /downloads
|
||||
return normalizedTest.startsWith(normalizedBoundary + '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a file path for consistent comparison
|
||||
*
|
||||
* @param filePath - Path to normalize
|
||||
* @returns Normalized path with forward slashes and no trailing slash
|
||||
*/
|
||||
function normalizePath(filePath: string): string {
|
||||
// Convert backslashes to forward slashes
|
||||
let normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Use path.normalize to handle redundant separators and ..
|
||||
normalized = path.normalize(normalized);
|
||||
|
||||
// Convert backslashes again (path.normalize might add them on Windows)
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root '/')
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -165,16 +165,22 @@ export async function tagMultipleFiles(
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape metadata values for shell command
|
||||
* Removes quotes and special characters that could break the command
|
||||
* Escape metadata values for shell command (double-quoted context)
|
||||
*
|
||||
* In double-quoted shell strings, only these characters need escaping:
|
||||
* - Backslashes (must be first to avoid double-escaping)
|
||||
* - Double quotes
|
||||
* - Backticks (command substitution)
|
||||
* - Dollar signs (variable expansion)
|
||||
*
|
||||
* Single quotes do NOT need escaping inside double quotes - they are literal.
|
||||
*/
|
||||
function escapeMetadata(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes FIRST (before other escapes add backslashes)
|
||||
.replace(/"/g, '\\"') // Escape double quotes
|
||||
.replace(/'/g, "\\'") // Escape single quotes
|
||||
.replace(/`/g, '\\`') // Escape backticks
|
||||
.replace(/\$/g, '\\$') // Escape dollar signs
|
||||
.replace(/\\/g, '\\\\'); // Escape backslashes
|
||||
.replace(/`/g, '\\`') // Escape backticks (prevents command substitution)
|
||||
.replace(/\$/g, '\\$'); // Escape dollar signs (prevents variable expansion)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user