diff --git a/backend/src/index.ts b/backend/src/index.ts index 865d8f5..c089dbc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,78 +1,89 @@ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import morgan from 'morgan'; -import rateLimit from 'express-rate-limit'; -import { createServer } from 'http'; -import { Server } from 'socket.io'; -import dotenv from 'dotenv'; +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import morgan from "morgan"; +import rateLimit from "express-rate-limit"; +import { createServer } from "http"; +import { Server } from "socket.io"; +import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; -dotenv.config(); +const envPath = + ["../.env", "../../.env", ".env"] + .map((p) => path.resolve(process.cwd(), p)) + .find((p) => fs.existsSync(p)) || ".env"; -import { prisma } from './utils/prisma'; -import { errorHandler } from './middleware/errorHandler'; -import { authRouter } from './routes/auth'; -import { userRouter } from './routes/users'; -import { walletRouter } from './routes/wallet'; -import { transactionsRouter } from './routes/transactions'; -import { adminRouter } from './routes/admin'; -import { tautulliRouter } from './routes/tautulli'; -import { overseerRouter } from './routes/overseer'; -import { webhookRouter } from './routes/webhooks'; -import { setupSocketHandlers } from './services/socket'; +dotenv.config({ path: envPath }); + +import { prisma } from "./utils/prisma"; +import { errorHandler } from "./middleware/errorHandler"; +import { authRouter } from "./routes/auth"; +import { userRouter } from "./routes/users"; +import { walletRouter } from "./routes/wallet"; +import { transactionsRouter } from "./routes/transactions"; +import { adminRouter } from "./routes/admin"; +import { tautulliRouter } from "./routes/tautulli"; +import { overseerRouter } from "./routes/overseer"; +import { webhookRouter } from "./routes/webhooks"; +import { setupSocketHandlers } from "./services/socket"; const app = express(); const httpServer = createServer(app); const allowedOrigins = [ - process.env.FRONTEND_URL, - 'https://coop.hobokenchicken.com', - 'http://172.20.1.238:3000' + process.env.FRONTEND_URL, + "https://coop.hobokenchicken.com", + "http://172.20.1.238:3000", ].filter(Boolean) as string[]; const io = new Server(httpServer, { - cors: { - origin: allowedOrigins, - credentials: true - } + cors: { + origin: allowedOrigins, + credentials: true, + }, }); // Security middleware -app.use(helmet({ - crossOriginResourcePolicy: { policy: "cross-origin" } -})); -app.use(cors({ - origin: allowedOrigins, - credentials: true -})); +app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), +); +app.use( + cors({ + origin: allowedOrigins, + credentials: true, + }), +); // Rate limiting const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100 // limit each IP to 100 requests per windowMs + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs }); app.use(limiter); // Body parsing -app.use(express.json({ limit: '10mb' })); +app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true })); // Logging -app.use(morgan('combined')); +app.use(morgan("combined")); // Health check -app.get('/health', (_req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); +app.get("/health", (_req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); }); // API routes -app.use('/api/auth', authRouter); -app.use('/api/users', userRouter); -app.use('/api/wallet', walletRouter); -app.use('/api/transactions', transactionsRouter); -app.use('/api/admin', adminRouter); -app.use('/api/tautulli', tautulliRouter); -app.use('/api/overseer', overseerRouter); -app.use('/webhooks', webhookRouter); +app.use("/api/auth", authRouter); +app.use("/api/users", userRouter); +app.use("/api/wallet", walletRouter); +app.use("/api/transactions", transactionsRouter); +app.use("/api/admin", adminRouter); +app.use("/api/tautulli", tautulliRouter); +app.use("/api/overseer", overseerRouter); +app.use("/webhooks", webhookRouter); // Error handling app.use(errorHandler); @@ -84,49 +95,51 @@ setupSocketHandlers(io); const PORT = process.env.PORT || 3001; async function startServer() { - try { - // Connect to database - await prisma.$connect(); - console.log('✅ Connected to database'); - - // Ensure default settings exist - const settings = await prisma.systemSettings.findFirst(); - if (!settings) { - await prisma.systemSettings.create({ - data: { - id: 'default' - } - }); - console.log('✅ Created default system settings'); - } - - httpServer.listen(PORT, () => { - console.log(`🚀 Server running on port ${PORT}`); - console.log(`📊 API URL: ${process.env.API_URL || `http://localhost:${PORT}`}`); - }); - } catch (error) { - console.error('Failed to start server:', error); - process.exit(1); - } + try { + // Connect to database + await prisma.$connect(); + console.log("✅ Connected to database"); + + // Ensure default settings exist + const settings = await prisma.systemSettings.findFirst(); + if (!settings) { + await prisma.systemSettings.create({ + data: { + id: "default", + }, + }); + console.log("✅ Created default system settings"); + } + + httpServer.listen(PORT, () => { + console.log(`🚀 Server running on port ${PORT}`); + console.log( + `📊 API URL: ${process.env.API_URL || `http://localhost:${PORT}`}`, + ); + }); + } catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } } // Graceful shutdown -process.on('SIGTERM', async () => { - console.log('SIGTERM received, shutting down gracefully'); - await prisma.$disconnect(); - httpServer.close(() => { - console.log('Server closed'); - process.exit(0); - }); +process.on("SIGTERM", async () => { + console.log("SIGTERM received, shutting down gracefully"); + await prisma.$disconnect(); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); }); -process.on('SIGINT', async () => { - console.log('SIGINT received, shutting down gracefully'); - await prisma.$disconnect(); - httpServer.close(() => { - console.log('Server closed'); - process.exit(0); - }); +process.on("SIGINT", async () => { + console.log("SIGINT received, shutting down gracefully"); + await prisma.$disconnect(); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); }); startServer(); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index de6b438..5b4fc65 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,192 +1,211 @@ -import { Router } from 'express'; -import axios from 'axios'; -import jwt from 'jsonwebtoken'; -import { prisma } from '../utils/prisma'; -import { asyncHandler } from '../middleware/errorHandler'; +import { Router } from "express"; +import axios from "axios"; +import jwt from "jsonwebtoken"; +import { prisma } from "../utils/prisma"; +import { asyncHandler } from "../middleware/errorHandler"; const router = Router(); -// Plex OAuth configuration -const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || ''; -const PLEX_CLIENT_SECRET = process.env.PLEX_CLIENT_SECRET || ''; -const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || ''; -const JWT_SECRET = process.env.JWT_SECRET || 'secret'; +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"; -// Step 1: Get Plex OAuth URL -router.get('/plex/url', asyncHandler(async (_req, res) => { - const params = new URLSearchParams({ - client_id: PLEX_CLIENT_ID, - redirect_uri: PLEX_REDIRECT_URI, - response_type: 'code', - scope: 'openid profile', - 'X-Plex-Product': 'CoopCoins', - 'X-Plex-Client-Identifier': PLEX_CLIENT_ID, - 'X-Plex-Device': 'Web Browser', - 'X-Plex-Platform': 'Web' - }); - - const authUrl = `https://app.plex.tv/auth#?${params.toString()}`; - - res.json({ authUrl }); -})); +if (!PLEX_CLIENT_ID) { + console.error("ERROR: PLEX_CLIENT_ID not set"); +} -// Step 2: Handle Plex OAuth callback -router.post('/plex/callback', asyncHandler(async (req, res) => { - const { code } = req.body; - - if (!code) { - return res.status(400).json({ error: 'Authorization code required' }); - } - - // Exchange code for Plex token - const tokenResponse = await axios.post( - 'https://plex.tv/api/v2/oauth/token', - { - code, - client_id: PLEX_CLIENT_ID, - client_secret: PLEX_CLIENT_SECRET, - grant_type: 'authorization_code' - }, - { - headers: { - 'Accept': 'application/json', - 'X-Plex-Client-Identifier': PLEX_CLIENT_ID - } - } - ); - - const plexToken = tokenResponse.data.access_token; - - // Get user info from Plex - const userResponse = await axios.get('https://plex.tv/api/v2/user', { - headers: { - 'X-Plex-Token': plexToken, - 'X-Plex-Client-Identifier': PLEX_CLIENT_ID, - 'Accept': 'application/json' - } - }); - - const plexUser = userResponse.data; - - // Check if user exists, create if not - let user = await prisma.user.findUnique({ - where: { plexId: plexUser.id } - }); - - if (!user) { - user = await prisma.user.create({ - data: { - plexId: plexUser.id, - plexUsername: plexUser.username || plexUser.email, - email: plexUser.email, - // Check if user is Plex admin (implement your logic) - isAdmin: false - } - }); - } else { - // Update user info - user = await prisma.user.update({ - where: { id: user.id }, - data: { - plexUsername: plexUser.username || plexUser.email, - email: plexUser.email - } - }); - } - - // Create session - const session = await prisma.session.create({ - data: { - userId: user.id, - token: jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - } - }); - - // Generate JWT - 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, - walletAddress: user.walletAddress, - totalEarned: user.totalEarned, - totalSpent: user.totalSpent - } - }); -})); +// Step 1: Generate Plex PIN and return auth URL +router.get( + "/plex/url", + asyncHandler(async (_req, res) => { + if (!PLEX_CLIENT_ID) { + return res.status(500).json({ error: "Plex not configured" }); + } -// Verify token -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' }); - } - - const token = authHeader.substring(7); - - try { - const decoded = jwt.verify(token, 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, - walletAddress: user.walletAddress, - totalEarned: user.totalEarned, - totalSpent: user.totalSpent - } - }); - } catch (error) { - res.status(401).json({ error: 'Invalid token' }); - } -})); + 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", + }, + }, + ); -// Logout -router.post('/logout', asyncHandler(async (req, res) => { - const authHeader = req.headers.authorization; - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.substring(7); - - try { - const decoded = jwt.verify(token, JWT_SECRET) as any; - - await prisma.session.deleteMany({ - where: { userId: decoded.userId } - }); - } catch { - // Invalid token, ignore - } - } - - res.json({ message: 'Logged out successfully' }); -})); + 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 }); + }), +); + +// Step 2: Exchange PIN for Plex token and create user session +router.post( + "/plex/callback", + asyncHandler(async (req, res) => { + const { pinId } = req.body; + + if (!pinId) { + return res.status(400).json({ error: "PIN ID required" }); + } + + // Check PIN status to get auth token + 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 plexToken = pin.authToken; + + // Get user info from Plex + const userResponse = await axios.get("https://plex.tv/api/v2/user", { + headers: { + "X-Plex-Token": plexToken, + "X-Plex-Client-Identifier": PLEX_CLIENT_ID, + Accept: "application/json", + }, + }); + + const plexUser = userResponse.data; + + // Find or create user + let user = await prisma.user.findUnique({ + where: { plexId: plexUser.id }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { + plexId: 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, + }, + }); + } + + // Create session + const session = await prisma.session.create({ + data: { + userId: user.id, + token: jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "7d" }), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + // Generate JWT + 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, + walletAddress: user.walletAddress, + 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" }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, 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, + walletAddress: user.walletAddress, + totalEarned: user.totalEarned, + totalSpent: user.totalSpent, + }, + }); + } catch (error) { + res.status(401).json({ error: "Invalid token" }); + } + }), +); + +router.post( + "/logout", + asyncHandler(async (req, res) => { + const authHeader = req.headers.authorization; + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as any; + + await prisma.session.deleteMany({ + where: { userId: decoded.userId }, + }); + } catch { + // Ignore invalid tokens on logout + } + } + + res.json({ message: "Logged out successfully" }); + }), +); export { router as authRouter }; diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index 49bb9e9..418365d 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -1,55 +1,56 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { authApi } from '@/lib/api'; -import { useStore } from '@/lib/store'; -import { Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { authApi } from "@/lib/api"; +import { useStore } from "@/lib/store"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; export default function AuthCallbackPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { setUser, setToken } = useStore(); - const [status, setStatus] = useState('Completing sign in...'); + const router = useRouter(); + const searchParams = useSearchParams(); + const { setUser, setToken } = useStore(); + const [status, setStatus] = useState("Completing sign in..."); - useEffect(() => { - const code = searchParams.get('code'); - - if (!code) { - toast.error('Invalid authentication response'); - router.push('/login'); - return; - } + useEffect(() => { + const pinId = sessionStorage.getItem("plexPinId"); - const handleCallback = async () => { - try { - const response = await authApi.plexCallback(code); - const { user, token } = response.data; - - localStorage.setItem('token', token); - setUser(user); - setToken(token); - - toast.success(`Welcome, ${user.plexUsername}!`); - router.push('/dashboard'); - } catch (error) { - setStatus('Authentication failed'); - toast.error('Authentication failed'); - console.error(error); - setTimeout(() => router.push('/login'), 2000); - } - }; + if (!pinId) { + toast.error("Invalid authentication response"); + router.push("/login"); + return; + } - handleCallback(); - }, [searchParams, router, setUser, setToken]); + const handleCallback = async () => { + try { + const response = await authApi.plexCallback(pinId); + const { user, token } = response.data; - return ( -
{status}
-{status}
+- Sign in with your Plex account to start earning rewards -
-+ Sign in with your Plex account to start earning rewards +
+