mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Component: Setup Wizard Complete API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const {
|
||||
backendMode,
|
||||
admin,
|
||||
plex,
|
||||
audiobookshelf,
|
||||
authMethod,
|
||||
oidc,
|
||||
registration,
|
||||
prowlarr,
|
||||
downloadClient,
|
||||
paths,
|
||||
bookdate,
|
||||
} = await request.json();
|
||||
|
||||
// Validate backend mode
|
||||
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid or missing backend mode' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required fields based on backend mode
|
||||
if (backendMode === 'plex') {
|
||||
if (
|
||||
!admin?.username ||
|
||||
!admin?.password ||
|
||||
!plex?.url ||
|
||||
!plex?.token ||
|
||||
!plex?.audiobook_library_id
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required Plex configuration fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Audiobookshelf mode
|
||||
if (
|
||||
!audiobookshelf?.server_url ||
|
||||
!audiobookshelf?.api_token ||
|
||||
!audiobookshelf?.library_id
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required Audiobookshelf configuration fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!authMethod || !['oidc', 'manual', 'both'].includes(authMethod)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid or missing authentication method' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate common required fields
|
||||
if (
|
||||
!prowlarr?.url ||
|
||||
!prowlarr?.api_key ||
|
||||
!prowlarr?.indexers ||
|
||||
!Array.isArray(prowlarr.indexers) ||
|
||||
prowlarr.indexers.length === 0 ||
|
||||
!downloadClient?.type ||
|
||||
!downloadClient?.url ||
|
||||
!downloadClient?.username ||
|
||||
!downloadClient?.password ||
|
||||
!paths?.download_dir ||
|
||||
!paths?.media_dir
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required configuration fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create admin user (for Plex mode or ABS + Manual auth)
|
||||
let adminUser: any = null;
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
|
||||
if (backendMode === 'plex' || (backendMode === 'audiobookshelf' && admin)) {
|
||||
if (!admin?.username || !admin?.password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Admin credentials required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||
|
||||
adminUser = await prisma.user.create({
|
||||
data: {
|
||||
plexId: `local-${admin.username}`,
|
||||
plexUsername: admin.username,
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
||||
avatarUrl: null,
|
||||
authToken: encryptedPassword, // Store encrypted hashed password
|
||||
authProvider: backendMode === 'plex' ? 'plex' : 'local',
|
||||
registrationStatus: 'approved',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate JWT tokens for auto-login
|
||||
accessToken = generateAccessToken({
|
||||
sub: adminUser.id,
|
||||
plexId: adminUser.plexId,
|
||||
username: adminUser.plexUsername,
|
||||
role: adminUser.role,
|
||||
});
|
||||
|
||||
refreshToken = generateRefreshToken(adminUser.id);
|
||||
}
|
||||
|
||||
// Save configuration to database
|
||||
// Use upsert to handle both initial setup and updates
|
||||
const encryptionService = getEncryptionService();
|
||||
|
||||
// Save backend mode
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'system.backend_mode' },
|
||||
update: { value: backendMode },
|
||||
create: { key: 'system.backend_mode', value: backendMode },
|
||||
});
|
||||
|
||||
if (backendMode === 'plex') {
|
||||
// Plex configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_url' },
|
||||
update: { value: plex.url },
|
||||
create: { key: 'plex_url', value: plex.url },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: plex.token },
|
||||
create: { key: 'plex_token', value: plex.token },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_audiobook_library_id' },
|
||||
update: { value: plex.audiobook_library_id },
|
||||
create: { key: 'plex_audiobook_library_id', value: plex.audiobook_library_id },
|
||||
});
|
||||
|
||||
// Get and save machine identifier (for server-specific access tokens)
|
||||
// Fetch from Plex if not provided by frontend
|
||||
let machineIdentifier = plex.machine_identifier;
|
||||
if (!machineIdentifier) {
|
||||
try {
|
||||
const plexService = getPlexService();
|
||||
const serverInfo = await plexService.testConnection(plex.url, plex.token);
|
||||
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
|
||||
machineIdentifier = serverInfo.info.machineIdentifier;
|
||||
console.log('[Setup] Fetched machineIdentifier:', machineIdentifier);
|
||||
} else {
|
||||
console.warn('[Setup] Could not fetch machineIdentifier');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Setup] Error fetching machineIdentifier:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (machineIdentifier) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_machine_identifier' },
|
||||
update: { value: machineIdentifier },
|
||||
create: { key: 'plex_machine_identifier', value: machineIdentifier },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Audiobookshelf configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'audiobookshelf.server_url' },
|
||||
update: { value: audiobookshelf.server_url },
|
||||
create: { key: 'audiobookshelf.server_url', value: audiobookshelf.server_url },
|
||||
});
|
||||
|
||||
const encryptedAbsToken = encryptionService.encrypt(audiobookshelf.api_token);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'audiobookshelf.api_token' },
|
||||
update: { value: encryptedAbsToken, encrypted: true },
|
||||
create: { key: 'audiobookshelf.api_token', value: encryptedAbsToken, encrypted: true },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'audiobookshelf.library_id' },
|
||||
update: { value: audiobookshelf.library_id },
|
||||
create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id },
|
||||
});
|
||||
|
||||
// OIDC configuration (if enabled)
|
||||
if (authMethod === 'oidc' || authMethod === 'both') {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'oidc.enabled' },
|
||||
update: { value: 'true' },
|
||||
create: { key: 'oidc.enabled', value: 'true' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'oidc.provider_name' },
|
||||
update: { value: oidc.provider_name },
|
||||
create: { key: 'oidc.provider_name', value: oidc.provider_name },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'oidc.issuer_url' },
|
||||
update: { value: oidc.issuer_url },
|
||||
create: { key: 'oidc.issuer_url', value: oidc.issuer_url },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'oidc.client_id' },
|
||||
update: { value: oidc.client_id },
|
||||
create: { key: 'oidc.client_id', value: oidc.client_id },
|
||||
});
|
||||
|
||||
const encryptedClientSecret = encryptionService.encrypt(oidc.client_secret);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'oidc.client_secret' },
|
||||
update: { value: encryptedClientSecret, encrypted: true },
|
||||
create: { key: 'oidc.client_secret', value: encryptedClientSecret, encrypted: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Manual registration configuration (if enabled)
|
||||
if (authMethod === 'manual' || authMethod === 'both') {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'auth.registration_enabled' },
|
||||
update: { value: 'true' },
|
||||
create: { key: 'auth.registration_enabled', value: 'true' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'auth.require_admin_approval' },
|
||||
update: { value: registration.require_admin_approval ? 'true' : 'false' },
|
||||
create: {
|
||||
key: 'auth.require_admin_approval',
|
||||
value: registration.require_admin_approval ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prowlarr configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_url' },
|
||||
update: { value: prowlarr.url },
|
||||
create: { key: 'prowlarr_url', value: prowlarr.url },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: prowlarr.api_key },
|
||||
create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_indexers' },
|
||||
update: { value: JSON.stringify(prowlarr.indexers) },
|
||||
create: { key: 'prowlarr_indexers', value: JSON.stringify(prowlarr.indexers) },
|
||||
});
|
||||
|
||||
// Download client configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_type' },
|
||||
update: { value: downloadClient.type },
|
||||
create: { key: 'download_client_type', value: downloadClient.type },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_url' },
|
||||
update: { value: downloadClient.url },
|
||||
create: { key: 'download_client_url', value: downloadClient.url },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_username' },
|
||||
update: { value: downloadClient.username },
|
||||
create: { key: 'download_client_username', value: downloadClient.username },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_password' },
|
||||
update: { value: downloadClient.password },
|
||||
create: { key: 'download_client_password', value: downloadClient.password },
|
||||
});
|
||||
|
||||
// Path configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
update: { value: paths.download_dir },
|
||||
create: { key: 'download_dir', value: paths.download_dir },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'media_dir' },
|
||||
update: { value: paths.media_dir },
|
||||
create: { key: 'media_dir', value: paths.media_dir },
|
||||
});
|
||||
|
||||
// Metadata tagging configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'metadata_tagging_enabled' },
|
||||
update: { value: String(paths.metadata_tagging_enabled ?? true) },
|
||||
create: {
|
||||
key: 'metadata_tagging_enabled',
|
||||
value: String(paths.metadata_tagging_enabled ?? true),
|
||||
category: 'automation',
|
||||
description: 'Automatically tag audio files with correct metadata during file organization'
|
||||
},
|
||||
});
|
||||
|
||||
// BookDate configuration (optional, global for all users)
|
||||
// Note: libraryScope and customPrompt are now per-user settings, not required here
|
||||
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
|
||||
console.log('[Setup] Saving global BookDate configuration');
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedApiKey = encryptionService.encrypt(bookdate.apiKey);
|
||||
|
||||
// Check if global config already exists
|
||||
const existingConfig = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (existingConfig) {
|
||||
// Update existing global config
|
||||
await prisma.bookDateConfig.update({
|
||||
where: { id: existingConfig.id },
|
||||
data: {
|
||||
provider: bookdate.provider,
|
||||
apiKey: encryptedApiKey,
|
||||
model: bookdate.model,
|
||||
libraryScope: 'full', // Default value for backwards compatibility
|
||||
customPrompt: null,
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new global config
|
||||
await prisma.bookDateConfig.create({
|
||||
data: {
|
||||
provider: bookdate.provider,
|
||||
apiKey: encryptedApiKey,
|
||||
model: bookdate.model,
|
||||
libraryScope: 'full', // Default value for backwards compatibility
|
||||
customPrompt: null,
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Setup] Global BookDate configuration saved');
|
||||
} else {
|
||||
console.log('[Setup] BookDate configuration skipped (missing provider, apiKey, or model)');
|
||||
}
|
||||
|
||||
// Mark setup as complete
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'setup_completed' },
|
||||
update: { value: 'true' },
|
||||
create: { key: 'setup_completed', value: 'true' },
|
||||
});
|
||||
|
||||
// Enable auto jobs (Plex Library Scan and Audible Data Refresh)
|
||||
await prisma.scheduledJob.updateMany({
|
||||
where: {
|
||||
type: {
|
||||
in: ['plex_library_scan', 'audible_refresh'],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Setup] Auto jobs enabled');
|
||||
|
||||
console.log('[Setup] Configuration saved successfully');
|
||||
|
||||
// Return response with tokens if admin user was created
|
||||
if (adminUser && accessToken && refreshToken) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Setup completed successfully',
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: adminUser.id,
|
||||
plexId: adminUser.plexId,
|
||||
username: adminUser.plexUsername,
|
||||
email: adminUser.plexEmail,
|
||||
role: adminUser.role,
|
||||
avatarUrl: adminUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// OIDC-only mode - no admin user created yet
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Setup completed successfully. First OIDC login will become admin.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Setup] Failed to save configuration:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save configuration',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Component: Setup Status Check API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/setup/status
|
||||
* Returns whether initial setup has been completed
|
||||
* Used by middleware for routing logic
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'setup_completed' },
|
||||
});
|
||||
|
||||
const setupComplete = config?.value === 'true';
|
||||
|
||||
return NextResponse.json({
|
||||
setupComplete,
|
||||
});
|
||||
} catch (error) {
|
||||
// If database is not ready or table doesn't exist, setup is not complete
|
||||
console.error('[Setup Status] Check failed:', error);
|
||||
return NextResponse.json({
|
||||
setupComplete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: Test Audiobookshelf Connection
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, apiToken } = await request.json();
|
||||
|
||||
if (!serverUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server URL is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If API token is masked, try to get the saved token
|
||||
let effectiveApiToken = apiToken;
|
||||
if (!apiToken || apiToken.startsWith('••••')) {
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const savedToken = await configService.get('audiobookshelf.api_token');
|
||||
|
||||
if (!savedToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API token is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
effectiveApiToken = savedToken;
|
||||
}
|
||||
|
||||
// Test connection by fetching libraries (which also validates auth)
|
||||
const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${effectiveApiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!libResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Connection failed: ${libResponse.status} ${libResponse.statusText}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const libData = await libResponse.json();
|
||||
|
||||
// Check if response has libraries array
|
||||
if (!libData.libraries || !Array.isArray(libData.libraries)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid response from Audiobookshelf server' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const libraries = libData.libraries
|
||||
.filter((lib: any) => lib.mediaType === 'book')
|
||||
.map((lib: any) => ({
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
itemCount: lib.stats?.totalItems || 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
serverInfo: {
|
||||
name: 'Audiobookshelf',
|
||||
version: 'Connected',
|
||||
},
|
||||
libraries,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Component: Setup Wizard Test Download Client API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'All fields are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (type !== 'qbittorrent') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Only qBittorrent is currently supported' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test connection with custom credentials
|
||||
const version = await QBittorrentService.testConnectionWithCredentials(
|
||||
url,
|
||||
username,
|
||||
password
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Setup] Download client test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to download client',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Test OIDC Configuration Endpoint
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Issuer } from 'openid-client';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { issuerUrl, clientId, clientSecret } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!issuerUrl || !clientId || !clientSecret) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Issuer URL, Client ID, and Client Secret are required'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate issuer URL format
|
||||
try {
|
||||
new URL(issuerUrl);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid issuer URL format'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Attempt OIDC discovery
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
|
||||
// Validate that we got the necessary endpoints
|
||||
if (!issuer.metadata.authorization_endpoint ||
|
||||
!issuer.metadata.token_endpoint ||
|
||||
!issuer.metadata.userinfo_endpoint) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'OIDC provider is missing required endpoints'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return success with discovered metadata
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
issuer: {
|
||||
issuer: issuer.issuer,
|
||||
authorizationEndpoint: issuer.metadata.authorization_endpoint,
|
||||
tokenEndpoint: issuer.metadata.token_endpoint,
|
||||
userinfoEndpoint: issuer.metadata.userinfo_endpoint,
|
||||
jwksUri: issuer.metadata.jwks_uri,
|
||||
supportedScopes: issuer.metadata.scopes_supported || [],
|
||||
supportedResponseTypes: issuer.metadata.response_types_supported || [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Test OIDC] Discovery failed:', error);
|
||||
|
||||
// Determine error message
|
||||
let errorMessage = 'OIDC discovery failed';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
|
||||
// Provide more helpful messages for common errors
|
||||
if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'Cannot reach OIDC provider. Check the issuer URL and network connectivity.';
|
||||
} else if (errorMessage.includes('404')) {
|
||||
errorMessage = 'OIDC discovery endpoint not found. Verify the issuer URL is correct.';
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
errorMessage = 'Connection to OIDC provider timed out. Check the issuer URL.';
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Component: Setup Wizard Test Paths API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function testPath(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
// Try to access the path
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
console.log(`[Setup] Path exists: ${dirPath}`);
|
||||
} catch (accessError) {
|
||||
// Path doesn't exist, try to create it
|
||||
console.log(`[Setup] Path doesn't exist, creating: ${dirPath}`);
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
console.log(`[Setup] Successfully created path: ${dirPath}`);
|
||||
} catch (mkdirError) {
|
||||
console.error(`[Setup] Failed to create path ${dirPath}:`, mkdirError);
|
||||
// If mkdir fails, it means the parent mount doesn't exist or isn't writable
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test write permissions by creating a test file
|
||||
const testFile = path.join(dirPath, '.readmeabook-test');
|
||||
await fs.writeFile(testFile, 'test');
|
||||
|
||||
// Clean up test file
|
||||
await fs.unlink(testFile);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Setup] Path test failed for ${dirPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { downloadDir, mediaDir } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Both directory paths are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test both paths
|
||||
const downloadDirValid = await testPath(downloadDir);
|
||||
const mediaDirValid = await testPath(mediaDir);
|
||||
|
||||
const success = downloadDirValid && mediaDirValid;
|
||||
|
||||
if (!success) {
|
||||
const errors = [];
|
||||
if (!downloadDirValid) {
|
||||
errors.push('Download directory path is invalid or parent mount is not writable');
|
||||
}
|
||||
if (!mediaDirValid) {
|
||||
errors.push('Media directory path is invalid or parent mount is not writable');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
downloadDirValid,
|
||||
mediaDirValid,
|
||||
error: errors.join('. '),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
downloadDirValid,
|
||||
mediaDirValid,
|
||||
message: 'Directories are ready and writable (created if needed)',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Setup] Path validation failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Path validation failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Component: Setup Wizard Test Plex API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url, token } = await request.json();
|
||||
|
||||
if (!url || !token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'URL and token are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const plexService = getPlexService();
|
||||
|
||||
// Test connection and get server info
|
||||
const connectionResult = await plexService.testConnection(url, token);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.info) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: connectionResult.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get libraries
|
||||
const libraries = await plexService.getLibraries(url, token);
|
||||
|
||||
// Format server name safely
|
||||
const serverName = connectionResult.info
|
||||
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
|
||||
: 'Plex Server';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
serverName,
|
||||
version: connectionResult.info?.version || 'Unknown',
|
||||
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
|
||||
libraries: libraries.map((lib) => ({
|
||||
id: lib.id,
|
||||
title: lib.title,
|
||||
type: lib.type,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Setup] Plex test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Setup Wizard Test Prowlarr API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url, apiKey } = await request.json();
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'URL and API key are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new ProwlarrService instance with test credentials
|
||||
const prowlarrService = new ProwlarrService(url, apiKey);
|
||||
|
||||
// Test connection and get indexers
|
||||
const indexers = await prowlarrService.getIndexers();
|
||||
|
||||
// Only return enabled indexers
|
||||
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
indexerCount: enabledIndexers.length,
|
||||
totalIndexers: indexers.length,
|
||||
indexers: enabledIndexers.map((indexer) => ({
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
protocol: indexer.protocol,
|
||||
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Setup] Prowlarr test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user