cc8d0f6969
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
234 lines
6.3 KiB
TypeScript
234 lines
6.3 KiB
TypeScript
import { Router } from "express";
|
|
import { logger } from "../utils/logger";
|
|
import { requireAuthOrToken } from "../middleware/auth";
|
|
import { prisma } from "../utils/db";
|
|
import crypto from "crypto";
|
|
|
|
const router = Router();
|
|
|
|
// Generate a random 6-character alphanumeric code
|
|
function generateLinkCode(): string {
|
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude similar looking chars
|
|
let code = "";
|
|
for (let i = 0; i < 6; i++) {
|
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return code;
|
|
}
|
|
|
|
// Generate API key
|
|
function generateApiKey(): string {
|
|
return crypto.randomBytes(32).toString("hex");
|
|
}
|
|
|
|
// POST /device-link/generate - Generate a new device link code (requires auth)
|
|
router.post("/generate", requireAuthOrToken, async (req, res) => {
|
|
try {
|
|
const userId = req.user!.id;
|
|
|
|
// Delete any existing unused codes for this user
|
|
await prisma.deviceLinkCode.deleteMany({
|
|
where: {
|
|
userId,
|
|
usedAt: null,
|
|
},
|
|
});
|
|
|
|
// Generate a unique code
|
|
let code: string;
|
|
let attempts = 0;
|
|
do {
|
|
code = generateLinkCode();
|
|
attempts++;
|
|
if (attempts > 10) {
|
|
return res.status(500).json({ error: "Failed to generate unique code" });
|
|
}
|
|
} while (
|
|
await prisma.deviceLinkCode.findUnique({
|
|
where: { code },
|
|
})
|
|
);
|
|
|
|
// Create the code with 5-minute expiry
|
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
|
const linkCode = await prisma.deviceLinkCode.create({
|
|
data: {
|
|
code,
|
|
userId,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
code: linkCode.code,
|
|
expiresAt: linkCode.expiresAt,
|
|
expiresIn: 300, // 5 minutes in seconds
|
|
});
|
|
} catch (error) {
|
|
logger.error("Generate device link code error:", error);
|
|
res.status(500).json({ error: "Failed to generate device link code" });
|
|
}
|
|
});
|
|
|
|
// POST /device-link/verify - Verify a code and get API key (no auth required)
|
|
router.post("/verify", async (req, res) => {
|
|
try {
|
|
const { code, deviceName } = req.body;
|
|
|
|
if (!code || typeof code !== "string") {
|
|
return res.status(400).json({ error: "Code is required" });
|
|
}
|
|
|
|
// Find the code
|
|
const linkCode = await prisma.deviceLinkCode.findUnique({
|
|
where: { code: code.toUpperCase() },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (!linkCode) {
|
|
return res.status(404).json({ error: "Invalid code" });
|
|
}
|
|
|
|
if (linkCode.usedAt) {
|
|
return res.status(400).json({ error: "Code already used" });
|
|
}
|
|
|
|
if (new Date() > linkCode.expiresAt) {
|
|
return res.status(400).json({ error: "Code expired" });
|
|
}
|
|
|
|
// Generate API key for this device
|
|
const apiKey = generateApiKey();
|
|
const createdApiKey = await prisma.apiKey.create({
|
|
data: {
|
|
userId: linkCode.userId,
|
|
key: apiKey,
|
|
name: deviceName || "Mobile Device",
|
|
},
|
|
});
|
|
|
|
// Mark the link code as used
|
|
await prisma.deviceLinkCode.update({
|
|
where: { id: linkCode.id },
|
|
data: {
|
|
usedAt: new Date(),
|
|
deviceName: deviceName || "Mobile Device",
|
|
apiKeyId: createdApiKey.id,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
apiKey,
|
|
userId: linkCode.userId,
|
|
username: linkCode.user.username,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Verify device link code error:", error);
|
|
res.status(500).json({ error: "Failed to verify device link code" });
|
|
}
|
|
});
|
|
|
|
// GET /device-link/status/:code - Poll for code usage status (no auth required)
|
|
router.get("/status/:code", async (req, res) => {
|
|
try {
|
|
const { code } = req.params;
|
|
|
|
const linkCode = await prisma.deviceLinkCode.findUnique({
|
|
where: { code: code.toUpperCase() },
|
|
});
|
|
|
|
if (!linkCode) {
|
|
return res.status(404).json({ error: "Invalid code" });
|
|
}
|
|
|
|
if (new Date() > linkCode.expiresAt && !linkCode.usedAt) {
|
|
return res.json({
|
|
status: "expired",
|
|
expiresAt: linkCode.expiresAt,
|
|
});
|
|
}
|
|
|
|
if (linkCode.usedAt) {
|
|
return res.json({
|
|
status: "used",
|
|
usedAt: linkCode.usedAt,
|
|
deviceName: linkCode.deviceName,
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
status: "pending",
|
|
expiresAt: linkCode.expiresAt,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Check device link status error:", error);
|
|
res.status(500).json({ error: "Failed to check status" });
|
|
}
|
|
});
|
|
|
|
// GET /device-link/devices - List linked devices (requires auth)
|
|
router.get("/devices", requireAuthOrToken, async (req, res) => {
|
|
try {
|
|
const userId = req.user!.id;
|
|
|
|
const apiKeys = await prisma.apiKey.findMany({
|
|
where: { userId },
|
|
orderBy: { lastUsed: "desc" },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
lastUsed: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
res.json(apiKeys);
|
|
} catch (error) {
|
|
logger.error("Get devices error:", error);
|
|
res.status(500).json({ error: "Failed to get devices" });
|
|
}
|
|
});
|
|
|
|
// DELETE /device-link/devices/:id - Revoke a device (requires auth)
|
|
router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
|
|
try {
|
|
const userId = req.user!.id;
|
|
const { id } = req.params;
|
|
|
|
const apiKey = await prisma.apiKey.findFirst({
|
|
where: { id, userId },
|
|
});
|
|
|
|
if (!apiKey) {
|
|
return res.status(404).json({ error: "Device not found" });
|
|
}
|
|
|
|
await prisma.apiKey.delete({
|
|
where: { id },
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
logger.error("Revoke device error:", error);
|
|
res.status(500).json({ error: "Failed to revoke device" });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|