All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 54s
565 lines
18 KiB
TypeScript
565 lines
18 KiB
TypeScript
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<string, unknown>)[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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
try {
|
|
const {
|
|
slug,
|
|
name,
|
|
level,
|
|
priceInCents,
|
|
features = null,
|
|
isActive = true,
|
|
} = req.body as {
|
|
slug: string;
|
|
name: string;
|
|
level: number;
|
|
priceInCents: number;
|
|
features?: Record<string, unknown> | 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<void> => {
|
|
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<string, unknown>)[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<void> => {
|
|
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;
|