Files
originsdigital/backend/src/routes/admin.routes.ts
Tetardtek 379a9a115b
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 54s
fix(security): isActive defense-in-depth, MIME magic bytes upload, tenantId=origins OAuth
2026-03-15 17:34:19 +01:00

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;