feat: token refresh, video upload, playlist routes complets

- auth: cookie od_token 7j, refresh token od_refresh 30j, POST /api/auth/refresh, GET /api/auth/me/optional
- admin: POST /api/admin/videos/upload via multer (mp4/webm, 4Go max, UUID filename)
- playlist: PATCH /:id, DELETE /:id, POST /:id/videos, DELETE /:id/videos/:videoId
- env: UPLOADS_DIR documenté dans .env.example
This commit is contained in:
2026-03-14 14:32:18 +01:00
parent aa15dc0f54
commit c7815aac2f
6 changed files with 385 additions and 3 deletions

View File

@@ -14,3 +14,6 @@ SUPER_OAUTH_URL=https://superoauth.tetardtek.com
# CORS — URL du frontend autorisé
FRONTEND_URL=http://localhost:5173
# Dossier de stockage des vidéos uploadées (défaut: ./uploads)
UPLOADS_DIR=./uploads

View File

@@ -14,6 +14,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"mysql2": "^3.9.3",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.20"
@@ -24,6 +25,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^2.1.0",
"@types/node": "^20.12.2",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
@@ -340,6 +342,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
@@ -546,6 +558,12 @@
"node": ">= 6.0.0"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
@@ -737,9 +755,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -877,6 +905,21 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -2109,6 +2152,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mysql2": {
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz",
@@ -2781,6 +2843,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -3086,6 +3156,12 @@
"node": ">= 0.4"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typeorm": {
"version": "0.3.28",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",

View File

@@ -19,6 +19,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"mysql2": "^3.9.3",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.20"
@@ -29,6 +30,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^2.1.0",
"@types/node": "^20.12.2",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",

View File

@@ -1,4 +1,7 @@
import { Router, Request, Response } from "express";
import path from "path";
import fs from "fs";
import multer, { FileFilterCallback } from "multer";
import { AppDataSource } from "../config/data-source";
import { Video } from "../entities/Video";
import { User } from "../entities/User";
@@ -9,6 +12,26 @@ import { SubscriptionPlan } from "../entities/SubscriptionPlan";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
import { requireAdmin } from "../middleware/admin.middleware";
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
@@ -19,6 +42,41 @@ router.use(requireAdmin as unknown as (req: Request, res: Response, next: () =>
// 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;
}
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.

View File

@@ -6,11 +6,20 @@ import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware
const router = Router();
const COOKIE_NAME = "od_token";
const REFRESH_COOKIE_NAME = "od_refresh";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
maxAge: 15 * 60 * 1000,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
};
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
};
/** Upsert user en DB depuis un profil SuperOAuth */
@@ -53,7 +62,7 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
const data = await response.json() as {
success: boolean;
data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string } };
data?: { user: { id: string; email: string | null; nickname: string }; tokens: { accessToken: string; refreshToken?: string } };
message?: string;
};
@@ -65,6 +74,9 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
await upsertUser(data.data.user);
res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS);
if (data.data.tokens.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
}
res.json({ success: true, data: { user: data.data.user } });
} catch {
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
@@ -117,12 +129,61 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
}
});
/**
* POST /api/auth/refresh
* Échange le refresh token contre un nouvel access token via SuperOAuth.
*/
router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
const refreshToken = (req.cookies as Record<string, string>)?.[REFRESH_COOKIE_NAME];
if (!refreshToken) {
res.status(401).json({ success: false, error: "NO_REFRESH_TOKEN" });
return;
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
return;
}
try {
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const data = await response.json() as {
success: boolean;
data?: { tokens: { accessToken: string; refreshToken?: string }; user?: { id: string; email: string | null; nickname: string } };
error?: string;
};
if (!response.ok || !data.data?.tokens?.accessToken) {
res.clearCookie(COOKIE_NAME);
res.clearCookie(REFRESH_COOKIE_NAME);
res.status(401).json({ success: false, error: "REFRESH_FAILED" });
return;
}
res.cookie(COOKIE_NAME, data.data.tokens.accessToken, COOKIE_OPTIONS);
if (data.data.tokens.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
}
res.json({ success: true, data: { user: data.data.user ?? null } });
} catch {
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
}
});
/**
* POST /api/auth/logout
* Supprime le cookie de session.
*/
router.post("/logout", (_req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME);
res.clearCookie(REFRESH_COOKIE_NAME);
res.json({ success: true });
});
@@ -135,4 +196,47 @@ router.get("/me", requireAuth, (req: Request, res: Response): void => {
res.json({ success: true, data: { user } });
});
/**
* GET /api/auth/me/optional
* Retourne l'utilisateur courant ou null si non authentifié (pas de 401).
*/
router.get("/me/optional", async (req: Request, res: Response): Promise<void> => {
const token =
req.headers.authorization?.split(" ")[1] ??
(req.cookies as Record<string, string>)?.od_token;
if (!token) {
res.json({ success: true, data: { user: null } });
return;
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
res.json({ success: true, data: { user: null } });
return;
}
try {
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await response.json() as {
success: boolean;
data?: { valid: boolean; user?: object };
};
if (!response.ok || !data.data?.valid || !data.data.user) {
res.json({ success: true, data: { user: null } });
return;
}
res.json({ success: true, data: { user: data.data.user } });
} catch {
res.json({ success: true, data: { user: null } });
}
});
export default router;

View File

@@ -1,8 +1,10 @@
import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source";
import { Playlist } from "../entities/Playlist";
import { PlaylistVideo } from "../entities/PlaylistVideo";
import { PlaylistShare } from "../entities/PlaylistShare";
import { User } from "../entities/User";
import { Video } from "../entities/Video";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
const router = Router();
@@ -128,6 +130,143 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
}
});
/**
* PATCH /api/playlists/:id
* Renomme ou change la visibilité d'une playlist (propriétaire uniquement).
*/
router.patch("/:id", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { title, description, visibility } = req.body as {
title?: string;
description?: string | null;
visibility?: "private" | "shared" | "public";
};
const dbUserId = await resolveDbUserId(user.id);
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
try {
const repo = AppDataSource.getRepository(Playlist);
const playlist = await repo.findOneBy({ id: req.params.id });
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
if (playlist.ownerId !== dbUserId) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
if (title !== undefined) playlist.title = title.trim();
if (description !== undefined) playlist.description = description;
if (visibility !== undefined) playlist.visibility = visibility;
await repo.save(playlist);
res.json({ success: true, data: { playlist } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* DELETE /api/playlists/:id
* Supprime une playlist (propriétaire uniquement).
*/
router.delete("/:id", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const dbUserId = await resolveDbUserId(user.id);
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
try {
const repo = AppDataSource.getRepository(Playlist);
const playlist = await repo.findOneBy({ id: req.params.id });
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
if (playlist.ownerId !== dbUserId) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
await repo.remove(playlist);
res.json({ success: true, data: { deleted: req.params.id } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/playlists/:id/videos
* Ajoute une vidéo à une playlist (propriétaire ou éditeur).
* Body: { videoId, position? }
*/
router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { videoId, position } = req.body as { videoId?: string; position?: number };
const dbUserId = await resolveDbUserId(user.id);
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
if (!videoId) { res.status(400).json({ success: false, error: "MISSING_VIDEO_ID" }); return; }
try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({
where: { id: req.params.id },
relations: ["shares"],
});
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
const isOwner = playlist.ownerId === dbUserId;
const canEdit = isOwner || playlist.shares.some((s) => s.userId === dbUserId && s.status === "active" && s.permission === "edit");
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
const video = await AppDataSource.getRepository(Video).findOneBy({ id: videoId });
if (!video) { res.status(404).json({ success: false, error: "VIDEO_NOT_FOUND" }); return; }
const existing = await AppDataSource.getRepository(PlaylistVideo).findOneBy({ playlistId: playlist.id, videoId });
if (existing) { res.status(409).json({ success: false, error: "ALREADY_IN_PLAYLIST" }); return; }
const pv = AppDataSource.getRepository(PlaylistVideo).create({
playlistId: playlist.id,
videoId,
position: position ?? 0,
});
await AppDataSource.getRepository(PlaylistVideo).save(pv);
res.status(201).json({ success: true, data: { playlistVideo: pv } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* DELETE /api/playlists/:id/videos/:videoId
* Retire une vidéo d'une playlist (propriétaire ou éditeur).
*/
router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const dbUserId = await resolveDbUserId(user.id);
if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({
where: { id: req.params.id },
relations: ["shares"],
});
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
const isOwner = playlist.ownerId === dbUserId;
const canEdit = isOwner || playlist.shares.some((s) => s.userId === dbUserId && s.status === "active" && s.permission === "edit");
if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; }
const pv = await AppDataSource.getRepository(PlaylistVideo).findOneBy({
playlistId: playlist.id,
videoId: req.params.videoId,
});
if (!pv) { res.status(404).json({ success: false, error: "NOT_IN_PLAYLIST" }); return; }
await AppDataSource.getRepository(PlaylistVideo).remove(pv);
res.json({ success: true, data: { deleted: req.params.videoId } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/playlists/:id/share
* Invite un utilisateur à une playlist (propriétaire uniquement).