Files
lidify/backend/src/routes/onboarding.ts
T
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

478 lines
15 KiB
TypeScript

import { Router } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import bcrypt from "bcrypt";
import { z } from "zod";
import axios from "axios";
import crypto from "crypto";
import { encryptField } from "../utils/systemSettings";
import { writeEnvFile } from "../utils/envWriter";
import { generateToken, requireAuth } from "../middleware/auth";
const router = Router();
// Validation schemas
const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6),
});
const lidarrConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const audiobookshelfConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const soulseekConfigSchema = z.object({
username: z.string().optional().or(z.literal("")),
password: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const enrichmentConfigSchema = z.object({
enabled: z.boolean(),
});
/**
* Generate a secure encryption key for settings encryption
* This is called automatically during first user registration
*/
async function ensureEncryptionKey(): Promise<void> {
// Check if encryption key already exists
if (
process.env.SETTINGS_ENCRYPTION_KEY &&
process.env.SETTINGS_ENCRYPTION_KEY !==
"default-encryption-key-change-me"
) {
logger.debug("[ONBOARDING] Encryption key already exists");
return;
}
// Generate a secure 32-byte encryption key
const encryptionKey = crypto.randomBytes(32).toString("base64");
logger.debug(
"[ONBOARDING] Generating encryption key for settings security..."
);
try {
// Write to .env file
await writeEnvFile({
SETTINGS_ENCRYPTION_KEY: encryptionKey,
});
// Update the process environment so it's available immediately
process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey;
logger.debug("[ONBOARDING] Encryption key generated and saved to .env");
} catch (error) {
logger.error("[ONBOARDING] Failed to save encryption key:", error);
throw new Error("Failed to generate encryption key");
}
}
/**
* POST /onboarding/register
* Step 1: Create user account - returns JWT token like regular login
*/
router.post("/register", async (req, res) => {
try {
logger.debug("[ONBOARDING] Register attempt for user:", req.body?.username);
const { username, password } = registerSchema.parse(req.body);
// Check if any user exists (first user becomes admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// If this is the first user, ensure encryption key is generated
if (isFirstUser) {
await ensureEncryptionKey();
}
// Check if username is taken
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
logger.debug("[ONBOARDING] Username already taken:", username);
return res.status(400).json({ error: "Username already taken" });
}
// Create user
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: isFirstUser ? "admin" : "user",
onboardingComplete: false,
},
});
// Create default user settings with optimal defaults
await prisma.userSettings.create({
data: {
userId: user.id,
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 10240, // 10GB
},
});
// Generate JWT token (same as login)
const token = generateToken({
id: user.id,
username: user.username,
role: user.role,
tokenVersion: user.tokenVersion,
});
logger.debug("[ONBOARDING] User created successfully:", user.username);
res.json({
token,
user: {
id: user.id,
username: user.username,
role: user.role,
onboardingComplete: false,
},
});
} catch (err: any) {
if (err instanceof z.ZodError) {
logger.error("[ONBOARDING] Validation error:", err.errors);
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
logger.error("Registration error:", err);
res.status(500).json({ error: "Failed to create account" });
}
});
/**
* POST /onboarding/lidarr
* Step 2a: Configure Lidarr integration
*/
router.post("/lidarr", requireAuth, async (req, res) => {
try {
const config = lidarrConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { lidarrEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(
`${config.url}/api/v1/system/status`,
{
headers: { "X-Api-Key": config.apiKey },
timeout: 5000,
}
);
if (response.status === 200) {
connectionTested = true;
logger.debug("Lidarr connection test successful");
}
} catch (error: any) {
logger.warn(
" Lidarr connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
update: {
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
logger.error("Lidarr config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/audiobookshelf
* Step 2b: Configure Audiobookshelf integration
*/
router.post("/audiobookshelf", requireAuth, async (req, res) => {
try {
const config = audiobookshelfConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { audiobookshelfEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(`${config.url}/api/me`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
timeout: 5000,
});
if (response.status === 200) {
connectionTested = true;
logger.debug("Audiobookshelf connection test successful");
}
} catch (error: any) {
logger.warn(
" Audiobookshelf connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
update: {
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
logger.error("Audiobookshelf config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/soulseek
* Step 2c: Configure Soulseek integration (direct connection via slsk-client)
*/
router.post("/soulseek", requireAuth, async (req, res) => {
try {
const config = soulseekConfigSchema.parse(req.body);
// If not enabled, clear credentials
if (!config.enabled) {
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: null,
soulseekPassword: null,
},
update: {
soulseekUsername: null,
soulseekPassword: null,
},
});
return res.json({ success: true, tested: false });
}
// If enabled, require credentials
if (!config.username || !config.password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
// Save to system settings
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
update: {
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
});
res.json({ success: true, tested: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
logger.error("Soulseek config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/enrichment
* Step 3: Configure metadata enrichment
*/
router.post("/enrichment", requireAuth, async (req, res) => {
try {
const config = enrichmentConfigSchema.parse(req.body);
// Update user settings
await prisma.user.update({
where: { id: req.user!.id },
data: {
enrichmentSettings: {
enabled: config.enabled,
lastRun: null,
},
},
});
res.json({ success: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
logger.error("Enrichment config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/complete
* Final step: Mark onboarding as complete
*/
router.post("/complete", requireAuth, async (req, res) => {
try {
await prisma.user.update({
where: { id: req.user!.id },
data: { onboardingComplete: true },
});
logger.debug("[ONBOARDING] User completed onboarding:", req.user!.id);
res.json({ success: true });
} catch (err: any) {
logger.error("Onboarding complete error:", err);
res.status(500).json({ error: "Failed to complete onboarding" });
}
});
/**
* GET /onboarding/status
* Check if user needs onboarding
*/
router.get("/status", async (req, res) => {
try {
// Check if any users exist in the system
const userCount = await prisma.user.count();
const hasAccount = userCount > 0;
// Check for JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
// If no token, return whether any users exist
if (!token) {
return res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
// Try to verify token and check onboarding status
try {
const jwt = require("jsonwebtoken");
const JWT_SECRET =
process.env.JWT_SECRET ||
"your-secret-key-change-in-production";
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { onboardingComplete: true },
});
res.json({
needsOnboarding: !user?.onboardingComplete,
hasAccount: true,
});
} catch {
// Invalid token - return basic status
res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
} catch (err: any) {
logger.error("Onboarding status error:", err);
res.status(500).json({ error: "Failed to check status" });
}
});
export default router;