import axios from "axios"; import crypto from "crypto"; import { Router } from "express"; import jwt from "jsonwebtoken"; import { asyncHandler } from "../middleware/errorHandler"; import { prisma } from "../utils/prisma"; const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!"; const TAUTULLI_URL = process.env.TAUTULLI_URL || ""; const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || ""; function encrypt(text: string): string { const iv = crypto.randomBytes(16); const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest(); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); let encrypted = cipher.update(text, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; } const router = Router(); const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || ""; const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || ""; const JWT_SECRET = process.env.JWT_SECRET || "secret"; router.get( "/plex/url", asyncHandler(async (_req, res) => { if (!PLEX_CLIENT_ID) return res.status(500).json({ error: "Plex not configured" }); const pinResponse = await axios.post( "https://plex.tv/api/v2/pins?strong=true", null, { headers: { Accept: "application/json", "X-Plex-Client-Identifier": PLEX_CLIENT_ID, "X-Plex-Product": "CoopCoins", "X-Plex-Version": "1.0.0", "X-Plex-Device": "Web Browser", "X-Plex-Platform": "Web", }, }, ); const pin = pinResponse.data; const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`; res.json({ authUrl, pinId: pin.id }); }), ); router.post( "/plex/callback", asyncHandler(async (req, res) => { const { pinId } = req.body; if (!pinId) return res.status(400).json({ error: "PIN ID required" }); const pinResponse = await axios.get( `https://plex.tv/api/v2/pins/${pinId}`, { headers: { Accept: "application/json", "X-Plex-Client-Identifier": PLEX_CLIENT_ID, }, }, ); const pin = pinResponse.data; if (!pin.authToken) return res.status(400).json({ error: "Authentication not completed" }); const userResponse = await axios.get("https://plex.tv/api/v2/user", { headers: { "X-Plex-Token": pin.authToken, "X-Plex-Client-Identifier": PLEX_CLIENT_ID, Accept: "application/json", }, }); const plexUser = userResponse.data; let user = await prisma.user.findUnique({ where: { plexId: String(plexUser.id) }, }); const isNewUser = !user; if (!user) { user = await prisma.user.create({ data: { plexId: String(plexUser.id), plexUsername: plexUser.username || plexUser.email, email: plexUser.email, isAdmin: false, }, }); } else { user = await prisma.user.update({ where: { id: user.id }, data: { plexUsername: plexUser.username || plexUser.email, email: plexUser.email, }, }); } await prisma.session.deleteMany({ where: { userId: user.id } }); const sessionToken = jwt.sign( { userId: user.id, nonce: Date.now() }, JWT_SECRET, { expiresIn: "7d" }, ); const session = await prisma.session.create({ data: { userId: user.id, token: sessionToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); if (isNewUser) await backfillUserHistory(user); const token = jwt.sign( { userId: user.id, plexId: user.plexId, isAdmin: user.isAdmin }, JWT_SECRET, { expiresIn: "7d" }, ); res.json({ token, sessionToken: session.token, user: { id: user.id, plexId: user.plexId, plexUsername: user.plexUsername, email: user.email, isAdmin: user.isAdmin, totalEarned: user.totalEarned, totalSpent: user.totalSpent, }, }); }), ); router.get( "/verify", asyncHandler(async (req, res) => { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) return res.status(401).json({ error: "No token provided" }); try { const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any; const user = await prisma.user.findUnique({ where: { id: decoded.userId }, }); if (!user || !user.isActive) return res.status(401).json({ error: "User not found or inactive" }); res.json({ user: { id: user.id, plexId: user.plexId, plexUsername: user.plexUsername, email: user.email, isAdmin: user.isAdmin, totalEarned: user.totalEarned, totalSpent: user.totalSpent, }, }); } catch { res.status(401).json({ error: "Invalid token" }); } }), ); router.post( "/logout", asyncHandler(async (req, res) => { const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer ")) { try { const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any; await prisma.session.deleteMany({ where: { userId: decoded.userId } }); } catch {} } res.json({ message: "Logged out successfully" }); }), ); async function backfillUserHistory(user: { id: string; plexId: string; plexUsername: string; }) { if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return; try { const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" }, }); const tautulliUsers = usersResponse.data?.response?.data || []; const tautulliUser = tautulliUsers.find( (u: any) => u.username?.toLowerCase() === user.plexUsername.toLowerCase() || u.email?.toLowerCase() === user.plexUsername.toLowerCase() || u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(), ); if (!tautulliUser) return; const startDate = (() => { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().split("T")[0]; })(); const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { params: { apikey: TAUTULLI_API_KEY, cmd: "get_history", user_id: tautulliUser.user_id, start_date: startDate, length: 1000, }, }); const historyData = historyResponse.data?.response?.data?.data || []; if (!Array.isArray(historyData) || historyData.length === 0) return; const settings = await prisma.systemSettings.findFirst(); const creditsPerMinute = settings?.creditsPerMinute || 2; const minWatchPercent = settings?.minWatchPercent || 80; const minWatchMinutes = settings?.minWatchMinutes || 5; let totalCredits = 0; let totalMinutes = 0; for (const item of historyData) { const sessionId = item.reference_id?.toString() || item.id?.toString(); if (!sessionId) continue; if (await prisma.watchEvent.findUnique({ where: { sessionId } })) continue; const percentComplete = item.percent_complete || 0; const watchDurationMinutes = Math.floor( (item.stopped - item.started) / 60, ); if ( percentComplete < minWatchPercent || watchDurationMinutes < minWatchMinutes ) continue; const creditsEarned = watchDurationMinutes * creditsPerMinute; const watchEvent = await prisma.watchEvent.create({ data: { userId: user.id, sessionId, ratingKey: item.rating_key?.toString() || "", contentType: item.media_type || "movie", title: item.title || item.full_title || "Unknown", grandparentTitle: item.grandparent_title || null, duration: item.stopped - item.started, percentComplete: Math.floor(percentComplete), creditsEarned, isProcessed: true, watchedAt: new Date(item.stopped * 1000), }, }); await prisma.transaction.create({ data: { userId: user.id, type: "EARN", amount: creditsEarned, watchEventId: watchEvent.id, description: `Watched ${item.title || item.full_title || "Unknown"}`, contentTitle: item.title || item.full_title || "Unknown", }, }); totalCredits += creditsEarned; totalMinutes += watchDurationMinutes; } if (totalCredits > 0) await prisma.user.update({ where: { id: user.id }, data: { totalEarned: { increment: totalCredits }, watchTimeMinutes: { increment: totalMinutes }, }, }); } catch (error) { console.error( `Failed to backfill history for ${user.plexUsername}:`, error, ); } } export { router as authRouter };