Files
ReadMeABook/src/app/api/admin/settings/download-clients/[id]/route.ts
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

203 lines
7.3 KiB
TypeScript

/**
* Component: Admin Download Client by ID API
* Documentation: documentation/phase3/download-clients.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
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');
/**
* PUT - Update download client by ID
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Await params in Next.js 15+
const { id } = await params;
const body = await request.json();
const {
name,
enabled,
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
category,
customPath,
postImportCategory,
} = body;
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const clients = await manager.getAllClients();
const clientIndex = clients.findIndex(c => c.id === id);
if (clientIndex === -1) {
return NextResponse.json(
{ error: 'Download client not found' },
{ status: 404 }
);
}
const existingClient = clients[clientIndex];
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
// Build updated client (preserve fields not in request)
const updatedClient: DownloadClientConfig = {
...existingClient,
name: name !== undefined ? name : existingClient.name,
enabled: enabled !== undefined ? enabled : existingClient.enabled,
url: url !== undefined ? url : existingClient.url,
username: username !== undefined ? username : existingClient.username,
password: password === '********' ? existingClient.password : (password || existingClient.password),
disableSSLVerify: disableSSLVerify !== undefined ? disableSSLVerify : existingClient.disableSSLVerify,
remotePathMappingEnabled: remotePathMappingEnabled !== undefined ? remotePathMappingEnabled : existingClient.remotePathMappingEnabled,
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
};
// Validate path mapping if enabled
if (updatedClient.remotePathMappingEnabled) {
if (!updatedClient.remotePath || !updatedClient.localPath) {
return NextResponse.json(
{ error: 'Remote path and local path are required when path mapping is enabled' },
{ status: 400 }
);
}
}
// Test connection if credentials/URL changed (skip if disabling client)
const isDisabling = enabled === false;
if (
!isDisabling &&
(
url !== undefined ||
username !== undefined ||
(password && password !== '********') ||
disableSSLVerify !== undefined
)
) {
const testResult = await manager.testConnection(updatedClient);
if (!testResult.success) {
return NextResponse.json(
{ error: `Connection test failed: ${testResult.message}` },
{ status: 400 }
);
}
}
// 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(encryptedClients) },
]);
// Invalidate cache
invalidateDownloadClientManager();
logger.info('Download client updated', { id, type: updatedClient.type, name: updatedClient.name });
return NextResponse.json({
message: 'Download client updated successfully',
client: {
...updatedClient,
password: '********',
},
});
} catch (error) {
logger.error('Failed to update download client', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'Failed to update download client' },
{ status: 500 }
);
}
});
});
}
/**
* DELETE - Remove download client by ID
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Await params in Next.js 15+
const { id } = await params;
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const clients = await manager.getAllClients();
const clientIndex = clients.findIndex(c => c.id === id);
if (clientIndex === -1) {
return NextResponse.json(
{ error: 'Download client not found' },
{ status: 404 }
);
}
const deletedClient = clients[clientIndex];
// 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(encryptedClients) },
]);
// Invalidate cache
invalidateDownloadClientManager();
logger.info('Download client deleted', { id, type: deletedClient.type, name: deletedClient.name });
return NextResponse.json({
message: 'Download client deleted successfully',
});
} catch (error) {
logger.error('Failed to delete download client', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'Failed to delete download client' },
{ status: 500 }
);
}
});
});
}