From c7815aac2f807809cfd06de89cc80c9302a85276 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sat, 14 Mar 2026 14:32:18 +0100 Subject: [PATCH] feat: token refresh, video upload, playlist routes complets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/.env.example | 3 + backend/package-lock.json | 78 ++++++++++++++- backend/package.json | 2 + backend/src/routes/admin.routes.ts | 58 +++++++++++ backend/src/routes/auth.routes.ts | 108 +++++++++++++++++++- backend/src/routes/playlist.routes.ts | 139 ++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 3 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 5cc4987..14c8cce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json index a6b58e7..7ad3cbd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index b813b6f..776b6d3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index d740823..b47297f 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -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. diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 402fe6f..a708710 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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 => { 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 => { 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 => { } }); +/** + * POST /api/auth/refresh + * Échange le refresh token contre un nouvel access token via SuperOAuth. + */ +router.post("/refresh", async (req: Request, res: Response): Promise => { + const refreshToken = (req.cookies as Record)?.[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 => { + const token = + req.headers.authorization?.split(" ")[1] ?? + (req.cookies as Record)?.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; diff --git a/backend/src/routes/playlist.routes.ts b/backend/src/routes/playlist.routes.ts index 1b892c6..71bb493 100644 --- a/backend/src/routes/playlist.routes.ts +++ b/backend/src/routes/playlist.routes.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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).