diff --git a/backend/src/index.ts b/backend/src/index.ts index 4c7fe4e..d5728bb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,14 +8,23 @@ import authRoutes from "./routes/auth.routes"; import videoRoutes from "./routes/video.routes"; import playlistRoutes from "./routes/playlist.routes"; import adminRoutes from "./routes/admin.routes"; +import streamRoutes from "./routes/stream.routes"; dotenv.config(); const app = express(); const PORT = parseInt(process.env.PORT ?? "4000"); +const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173") + .split(",") + .map((o) => o.trim()); + app.use(cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + origin: (origin, cb) => { + // Autoriser les requêtes sans origin (curl, Postman, same-origin) + if (!origin || allowedOrigins.includes(origin)) return cb(null, true); + cb(new Error(`CORS: origin ${origin} not allowed`)); + }, credentials: true, })); app.use(express.json()); @@ -29,6 +38,7 @@ app.use("/api/auth", authRoutes); app.use("/api/videos", videoRoutes); app.use("/api/playlists", playlistRoutes); app.use("/api/admin", adminRoutes); +app.use("/api/stream", streamRoutes); AppDataSource.initialize() .then(() => { diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index c20b501..d740823 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -342,4 +342,59 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise => } }); +// --------------------------------------------------------------------------- +// USER SUBSCRIPTIONS +// --------------------------------------------------------------------------- + +/** + * POST /api/admin/users/:id/subscriptions + * Assigne un plan à un utilisateur. + * Expire l'abonnement actif précédent si existant. + * Body: { planId, endsAt? } — endsAt ISO string, null = permanent + */ +router.post("/users/:id/subscriptions", async (req: Request, res: Response): Promise => { + const { planId, endsAt = null } = req.body as { planId?: string; endsAt?: string | null }; + + if (!planId) { + res.status(400).json({ success: false, error: "MISSING_PLAN_ID" }); + return; + } + + try { + const userRepo = AppDataSource.getRepository(User); + const planRepo = AppDataSource.getRepository(SubscriptionPlan); + const subRepo = AppDataSource.getRepository(UserSubscription); + + const [user, plan] = await Promise.all([ + userRepo.findOne({ where: { id: req.params.id } }), + planRepo.findOne({ where: { id: planId } }), + ]); + + if (!user) { res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); return; } + if (!plan) { res.status(404).json({ success: false, error: "PLAN_NOT_FOUND" }); return; } + + // Expirer l'abonnement actif précédent + await subRepo + .createQueryBuilder() + .update(UserSubscription) + .set({ status: "expired" }) + .where("userId = :userId AND status = :status", { userId: user.id, status: "active" }) + .execute(); + + const sub = subRepo.create({ + id: crypto.randomUUID(), + userId: user.id, + planId: plan.id, + status: "active", + startsAt: new Date(), + endsAt: endsAt ? new Date(endsAt) : null, + }); + + await subRepo.save(sub); + res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + export default router; diff --git a/backend/src/routes/stream.routes.ts b/backend/src/routes/stream.routes.ts new file mode 100644 index 0000000..9ca029b --- /dev/null +++ b/backend/src/routes/stream.routes.ts @@ -0,0 +1,136 @@ +import { Router, Request, Response } from "express"; +import path from "path"; +import fs from "fs"; +import { AppDataSource } from "../config/data-source"; +import { Video } from "../entities/Video"; +import { User } from "../entities/User"; +import { UserSubscription } from "../entities/UserSubscription"; + +const router = Router(); + +const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads"); + +async function getUserLevel(token: string | undefined): Promise { + if (!token) return 0; + + try { + const superOAuthUrl = process.env.SUPER_OAUTH_URL; + if (!superOAuthUrl) return 0; + + const res = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + + const data = await res.json() as { + success: boolean; + data?: { valid: boolean; user?: { id: string } }; + }; + + if (!data.data?.valid || !data.data.user) return 0; + + const superOAuthId = data.data.user.id; + const dbUser = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId } }); + if (!dbUser) return 0; + + const sub = await AppDataSource.getRepository(UserSubscription).findOne({ + where: { userId: dbUser.id, status: "active" }, + relations: ["plan"], + order: { startsAt: "DESC" }, + }); + + return sub?.plan.level ?? 0; + } catch { + return 0; + } +} + +/** + * GET /api/stream/:key + * Sert un fichier local (storageType="local") avec contrôle d'accès. + * :key est le chemin relatif stocké en DB (ex: "uploads/my-video.mp4"). + * + * Support Range requests pour la seekabilité vidéo. + */ +router.get("/:key(*)", async (req: Request, res: Response): Promise => { + const key = req.params.key; + + // Sécurité : interdire path traversal + const resolved = path.resolve(UPLOADS_DIR, key); + if (!resolved.startsWith(path.resolve(UPLOADS_DIR))) { + res.status(400).json({ success: false, error: "INVALID_KEY" }); + return; + } + + try { + // Vérifier que la vidéo existe en DB et récupérer son niveau requis + const video = await AppDataSource.getRepository(Video).findOne({ + where: { storageKey: key, storageType: "local", isPublished: true }, + }); + + if (!video) { + res.status(404).json({ success: false, error: "NOT_FOUND" }); + return; + } + + // Contrôle d'accès + const token = + req.headers.authorization?.split(" ")[1] ?? + (req.cookies as Record)?.od_token; + + const userLevel = await getUserLevel(token); + + if (video.requiredLevel > userLevel) { + res.status(403).json({ success: false, error: "INSUFFICIENT_PLAN" }); + return; + } + + // Vérifier que le fichier existe + if (!fs.existsSync(resolved)) { + res.status(404).json({ success: false, error: "FILE_NOT_FOUND" }); + return; + } + + const stat = fs.statSync(resolved); + const fileSize = stat.size; + const ext = path.extname(resolved).toLowerCase(); + + const mimeTypes: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".m3u8": "application/vnd.apple.mpegurl", + ".ts": "video/MP2T", + }; + const contentType = mimeTypes[ext] ?? "application/octet-stream"; + + const range = req.headers.range; + + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunkSize = end - start + 1; + + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + "Content-Length": chunkSize, + "Content-Type": contentType, + }); + + fs.createReadStream(resolved, { start, end }).pipe(res); + } else { + res.writeHead(200, { + "Content-Length": fileSize, + "Content-Type": contentType, + "Accept-Ranges": "bytes", + }); + fs.createReadStream(resolved).pipe(res); + } + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +export default router;