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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user