import { Router, Request, Response } from "express"; import path from "path"; import fs from "fs"; /** * Vérifie les magic bytes d'un fichier vidéo déjà écrit sur disque. * MP4 : bytes 4-7 = 'ftyp' (0x66 0x74 0x79 0x70) * WebM : bytes 0-3 = 0x1A 0x45 0xDF 0xA3 */ const isValidVideoMagicBytes = (filePath: string): boolean => { const fd = fs.openSync(filePath, "r"); const buf = Buffer.alloc(12); fs.readSync(fd, buf, 0, 12, 0); fs.closeSync(fd); const isWebM = buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3; const isMP4 = buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70; return isMP4 || isWebM; }; import multer, { FileFilterCallback } from "multer"; import { AppDataSource } from "../config/data-source"; import { Video } from "../entities/Video"; import { User } from "../entities/User"; import { UserRole } from "../entities/UserRole"; import { Role } from "../entities/Role"; import { UserSubscription } from "../entities/UserSubscription"; import { SubscriptionPlan } from "../entities/SubscriptionPlan"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; import { requireAdmin } from "../middleware/admin.middleware"; import logger from "../utils/logger"; const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads"); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); const videoStorage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, UPLOADS_DIR), filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.mp4`), }); const videoUpload = multer({ storage: videoStorage, limits: { fileSize: 4 * 1024 * 1024 * 1024 }, // 4 Go fileFilter: (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => { if (["video/mp4", "video/webm"].includes(file.mimetype)) { cb(null, true); } else { cb(new Error("INVALID_MIME_TYPE")); } }, }); const router = Router(); // Applique requireAuth + requireAdmin sur toutes les routes de ce routeur router.use(requireAuth as unknown as (req: Request, res: Response, next: () => void) => void); router.use(requireAdmin as unknown as (req: Request, res: Response, next: () => void) => void); // --------------------------------------------------------------------------- // VIDEOS // --------------------------------------------------------------------------- /** * POST /api/admin/videos/upload * Upload un fichier vidéo (mp4 / webm) dans UPLOADS_DIR. * Retourne le storageKey à passer ensuite à POST /api/admin/videos. */ router.post( "/videos/upload", (req: Request, res: Response, next) => { videoUpload.single("file")(req, res, (err) => { if (err) { const message = err instanceof Error ? err.message : "UPLOAD_ERROR"; if (message === "INVALID_MIME_TYPE") { res.status(415).json({ success: false, error: "INVALID_MIME_TYPE", message: "Only video/mp4 and video/webm are accepted" }); } else if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") { res.status(413).json({ success: false, error: "FILE_TOO_LARGE" }); } else { next(err); } return; } next(); }); }, (req: Request, res: Response): void => { if (!req.file) { res.status(400).json({ success: false, error: "NO_FILE" }); return; } if (!isValidVideoMagicBytes(req.file.path)) { fs.unlinkSync(req.file.path); res.status(415).json({ success: false, error: "INVALID_FILE_CONTENT", message: "File content does not match a valid video format" }); return; } res.status(201).json({ success: true, data: { storageKey: req.file.filename, storageType: "local" }, }); } ); /** * GET /api/admin/videos * Liste toutes les vidéos (publiées et non publiées), tous les champs. * Query: ?page=1&limit=20 */ router.get("/videos", async (req: Request, res: Response): Promise => { const rawPage = Number(req.query.page ?? 1); const rawLimit = Number(req.query.limit ?? 20); if (!Number.isInteger(rawPage) || rawPage < 1 || !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ success: false, error: "INVALID_PAGINATION" }); return; } try { const [videos, total] = await AppDataSource.getRepository(Video).findAndCount({ order: { createdAt: "DESC" }, skip: (rawPage - 1) * rawLimit, take: rawLimit, }); res.json({ success: true, data: { videos }, total, page: rawPage, limit: rawLimit }); } catch (err) { logger.error("GET /admin/videos — failed to list videos", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * POST /api/admin/videos * Crée une nouvelle vidéo. * Body: { title, description?, thumbnailUrl?, duration?, storageType, storageKey, requiredLevel?, isPublished? } */ router.post("/videos", async (req: Request, res: Response): Promise => { try { const { title, description = null, thumbnailUrl = null, duration = null, storageType, storageKey, requiredLevel = 0, isPublished = false, } = req.body as { title: string; description?: string | null; thumbnailUrl?: string | null; duration?: number | null; storageType: string; storageKey: string; requiredLevel?: number; isPublished?: boolean; }; if (!title || !storageType || !storageKey) { res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" }); return; } const video = AppDataSource.getRepository(Video).create({ id: crypto.randomUUID(), title, description, thumbnailUrl, duration, storageType: storageType as Video["storageType"], storageKey, requiredLevel, isPublished, publishedAt: isPublished ? new Date() : null, }); await AppDataSource.getRepository(Video).save(video); res.status(201).json({ success: true, data: { video } }); } catch (err) { logger.error("POST /admin/videos — failed to create video", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * PATCH /api/admin/videos/:id * Met à jour une vidéo (champs partiels). */ router.patch("/videos/:id", async (req: Request, res: Response): Promise => { try { const repo = AppDataSource.getRepository(Video); const video = await repo.findOne({ where: { id: req.params.id } }); if (!video) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } const allowed = [ "title", "description", "thumbnailUrl", "duration", "storageType", "storageKey", "requiredLevel", "isPublished", ] as const; for (const field of allowed) { if (field in req.body) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (video as any)[field] = (req.body as Record)[field]; } } // Mise à jour de publishedAt si on publie maintenant if (req.body.isPublished === true && !video.publishedAt) { video.publishedAt = new Date(); } else if (req.body.isPublished === false) { video.publishedAt = null; } await repo.save(video); res.json({ success: true, data: { video } }); } catch (err) { logger.error("PATCH /admin/videos/:id — failed to update video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * DELETE /api/admin/videos/:id * Supprime une vidéo. */ router.delete("/videos/:id", async (req: Request, res: Response): Promise => { try { const repo = AppDataSource.getRepository(Video); const video = await repo.findOne({ where: { id: req.params.id } }); if (!video) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } await repo.remove(video); res.json({ success: true, data: { deleted: req.params.id } }); } catch (err) { logger.error("DELETE /admin/videos/:id — failed to delete video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); // --------------------------------------------------------------------------- // USERS // --------------------------------------------------------------------------- /** * GET /api/admin/users * Liste tous les utilisateurs avec leurs rôles et abonnement actif. * Query: ?page=1&limit=20 */ router.get("/users", async (req: Request, res: Response): Promise => { const rawPage = Number(req.query.page ?? 1); const rawLimit = Number(req.query.limit ?? 20); if (!Number.isInteger(rawPage) || rawPage < 1 || !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ success: false, error: "INVALID_PAGINATION" }); return; } try { const [users, total] = await AppDataSource.getRepository(User).findAndCount({ relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"], order: { createdAt: "DESC" }, skip: (rawPage - 1) * rawLimit, take: rawLimit, }); const data = users.map((u) => ({ id: u.id, email: u.email, nickname: u.nickname, isActive: u.isActive, createdAt: u.createdAt, roles: u.userRoles.map((ur) => ({ id: ur.role.id, slug: ur.role.slug, name: ur.role.name })), activeSubscription: (() => { const sub = u.subscriptions.find((s) => s.status === "active"); return sub ? { id: sub.id, status: sub.status, startsAt: sub.startsAt, endsAt: sub.endsAt, plan: sub.plan } : null; })(), })); res.json({ success: true, data: { users: data }, total, page: rawPage, limit: rawLimit }); } catch (err) { logger.error("GET /admin/users — failed to list users", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * PATCH /api/admin/users/:id * Met à jour isActive (ban / unban) d'un utilisateur. */ router.patch("/users/:id", async (req: Request, res: Response): Promise => { const { isActive } = req.body as { isActive?: boolean }; if (typeof isActive !== "boolean") { res.status(400).json({ success: false, error: "INVALID_BODY" }); return; } try { const repo = AppDataSource.getRepository(User); const user = await repo.findOne({ where: { id: req.params.id } }); if (!user) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } user.isActive = isActive; await repo.save(user); res.json({ success: true, data: { userId: user.id, isActive: user.isActive } }); } catch (err) { logger.error("PATCH /admin/users/:id — failed to update user", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * PATCH /api/admin/users/:id/roles * Assigne des rôles à un utilisateur (remplace les rôles existants). * Body: { roles: string[] } — slugs de rôles */ router.patch("/users/:id/roles", async (req: Request, res: Response): Promise => { try { const { roles } = req.body as { roles: string[] }; if (!Array.isArray(roles)) { res.status(400).json({ success: false, error: "INVALID_BODY" }); return; } const userRepo = AppDataSource.getRepository(User); const roleRepo = AppDataSource.getRepository(Role); const userRoleRepo = AppDataSource.getRepository(UserRole); const user = await userRepo.findOne({ where: { id: req.params.id } }); if (!user) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } // Résoudre les slugs en entités Role const roleEntities = await roleRepo .createQueryBuilder("role") .where("role.slug IN (:...slugs)", { slugs: roles.length > 0 ? roles : ["__none__"] }) .getMany(); if (roleEntities.length !== roles.length) { res.status(400).json({ success: false, error: "INVALID_ROLE_SLUGS" }); return; } // Supprimer tous les rôles existants pour cet user await userRoleRepo.delete({ userId: req.params.id }); // Insérer les nouveaux rôles const newUserRoles = roleEntities.map((role) => { const ur = new UserRole(); ur.userId = req.params.id; ur.roleId = role.id; return ur; }); if (newUserRoles.length > 0) { await userRoleRepo.save(newUserRoles); } res.json({ success: true, data: { userId: req.params.id, roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })), }, }); } catch (err) { logger.error("PATCH /admin/users/:id/roles — failed to assign roles", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); // --------------------------------------------------------------------------- // STATS (super_admin) // --------------------------------------------------------------------------- /** * GET /api/admin/stats * Métriques globales de la plateforme. */ router.get("/stats", async (_req: Request, res: Response): Promise => { try { const [totalUsers, totalVideos, activeSubscriptions] = await Promise.all([ AppDataSource.getRepository(User).count(), AppDataSource.getRepository(Video).count({ where: { isPublished: true } }), AppDataSource.getRepository(UserSubscription).count({ where: { status: "active" } }), ]); res.json({ success: true, data: { totalUsers, totalVideos, activeSubscriptions } }); } catch (err) { logger.error("GET /admin/stats — failed", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); // --------------------------------------------------------------------------- // SUBSCRIPTION PLANS // --------------------------------------------------------------------------- /** * GET /api/admin/plans * Liste tous les plans d'abonnement. */ router.get("/plans", async (_req: Request, res: Response): Promise => { try { const plans = await AppDataSource.getRepository(SubscriptionPlan).find({ order: { level: "ASC" }, }); res.json({ success: true, data: { plans } }); } catch (err) { logger.error("GET /admin/plans — failed to list plans", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * POST /api/admin/plans * Crée un plan d'abonnement. * Body: { slug, name, level, priceInCents, features?, isActive? } */ router.post("/plans", async (req: Request, res: Response): Promise => { try { const { slug, name, level, priceInCents, features = null, isActive = true, } = req.body as { slug: string; name: string; level: number; priceInCents: number; features?: Record | null; isActive?: boolean; }; if (!slug || !name || level === undefined || priceInCents === undefined) { res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" }); return; } const plan = AppDataSource.getRepository(SubscriptionPlan).create({ id: crypto.randomUUID(), slug: slug as SubscriptionPlan["slug"], name, level, priceInCents, features, isActive, }); await AppDataSource.getRepository(SubscriptionPlan).save(plan); res.status(201).json({ success: true, data: { plan } }); } catch (err) { logger.error("POST /admin/plans — failed to create plan", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); /** * PATCH /api/admin/plans/:id * Met à jour un plan (isActive, priceInCents, features). */ router.patch("/plans/:id", async (req: Request, res: Response): Promise => { try { const repo = AppDataSource.getRepository(SubscriptionPlan); const plan = await repo.findOne({ where: { id: req.params.id } }); if (!plan) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } const allowed = ["isActive", "priceInCents", "features"] as const; for (const field of allowed) { if (field in req.body) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (plan as any)[field] = (req.body as Record)[field]; } } await repo.save(plan); res.json({ success: true, data: { plan } }); } catch (err) { logger.error("PATCH /admin/plans/:id — failed to update plan", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); // --------------------------------------------------------------------------- // 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 (err) { logger.error("POST /admin/users/:id/subscriptions — failed to assign subscription", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); export default router;