diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a6b408..a6b58e7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", @@ -19,6 +20,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", @@ -260,6 +262,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -901,6 +913,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/backend/package.json b/backend/package.json index d1ffcaf..451888a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", @@ -23,6 +24,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", diff --git a/backend/src/index.ts b/backend/src/index.ts index 14e9589..8d235dd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,27 +1,32 @@ import "reflect-metadata"; import express from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; import dotenv from "dotenv"; import { AppDataSource } from "./config/data-source"; -import { requireAuth, AuthenticatedRequest } from "./middleware/auth.middleware"; +import authRoutes from "./routes/auth.routes"; +import videoRoutes from "./routes/video.routes"; +import playlistRoutes from "./routes/playlist.routes"; dotenv.config(); const app = express(); const PORT = parseInt(process.env.PORT ?? "4000"); -app.use(cors()); +app.use(cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + credentials: true, +})); app.use(express.json()); +app.use(cookieParser()); app.get("/api/health", (_req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); -// Route protégée — valide l'intégration SuperOAuth end-to-end -app.get("/api/profile", requireAuth, (req, res) => { - const { user } = req as AuthenticatedRequest; - res.json({ success: true, data: { user } }); -}); +app.use("/api/auth", authRoutes); +app.use("/api/videos", videoRoutes); +app.use("/api/playlists", playlistRoutes); AppDataSource.initialize() .then(() => { diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index cdd48c5..bfab8f5 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -29,7 +29,9 @@ export const requireAuth = async ( res: Response, next: NextFunction ): Promise => { - const token = req.headers.authorization?.split(" ")[1]; + const token = + req.headers.authorization?.split(" ")[1] ?? + (req.cookies as Record)?.od_token; if (!token) { res.status(401).json({ success: false, error: "UNAUTHORIZED", message: "Access token required" }); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..45f1380 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -0,0 +1,76 @@ +import { Router, Request, Response } from "express"; +import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; + +const router = Router(); + +const COOKIE_NAME = "od_token"; +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + maxAge: 15 * 60 * 1000, // 15 min — durée de vie du token SuperOAuth +}; + +/** + * POST /api/auth/session + * Reçoit le token depuis le callback SuperOAuth, + * le valide, puis le pose en httpOnly cookie. + */ +router.post("/session", async (req: Request, res: Response): Promise => { + const { token } = req.body as { token?: string }; + + if (!token) { + res.status(400).json({ success: false, error: "MISSING_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/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 }; + error?: string; + }; + + if (!response.ok || !data.data?.valid) { + res.status(401).json({ success: false, error: "INVALID_TOKEN" }); + return; + } + + res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); + res.json({ success: true, data: { user: data.data.user } }); + } 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.json({ success: true }); +}); + +/** + * GET /api/auth/me + * Retourne l'utilisateur courant (cookie ou Bearer). + */ +router.get("/me", requireAuth, (req: Request, res: Response): void => { + const { user } = req as AuthenticatedRequest; + res.json({ success: true, data: { user } }); +}); + +export default router; diff --git a/backend/src/routes/playlist.routes.ts b/backend/src/routes/playlist.routes.ts new file mode 100644 index 0000000..f1b63a6 --- /dev/null +++ b/backend/src/routes/playlist.routes.ts @@ -0,0 +1,194 @@ +import { Router, Request, Response } from "express"; +import { AppDataSource } from "../config/data-source"; +import { Playlist } from "../entities/Playlist"; +import { PlaylistShare } from "../entities/PlaylistShare"; +import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; + +const router = Router(); + +/** + * GET /api/playlists + * Playlists publiques + playlists partagées avec l'utilisateur connecté. + */ +router.get("/", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + + try { + const owned = await AppDataSource.getRepository(Playlist).find({ + where: { ownerId: user.id }, + order: { createdAt: "DESC" }, + }); + + const shared = await AppDataSource.getRepository(PlaylistShare).find({ + where: { userId: user.id, status: "active" }, + relations: ["playlist"], + }); + + res.json({ + success: true, + data: { + owned, + shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })), + }, + }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +/** + * POST /api/playlists + * Crée une playlist. + */ +router.post("/", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + const { title, description, visibility } = req.body as { + title?: string; + description?: string; + visibility?: "private" | "shared" | "public"; + }; + + if (!title?.trim()) { + res.status(400).json({ success: false, error: "MISSING_TITLE" }); + return; + } + + try { + const playlist = AppDataSource.getRepository(Playlist).create({ + id: require("crypto").randomUUID(), + ownerId: user.id, + title: title.trim(), + description: description ?? null, + visibility: visibility ?? "private", + }); + + await AppDataSource.getRepository(Playlist).save(playlist); + res.status(201).json({ success: true, data: { playlist } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +/** + * GET /api/playlists/:id + * Détail d'une playlist + ses vidéos. + * Accès : propriétaire, invité actif, ou playlist publique. + */ +router.get("/:id", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + + try { + const playlist = await AppDataSource.getRepository(Playlist).findOne({ + where: { id: req.params.id }, + relations: ["playlistVideos", "playlistVideos.video", "shares"], + }); + + if (!playlist) { + res.status(404).json({ success: false, error: "NOT_FOUND" }); + return; + } + + const isOwner = playlist.ownerId === user.id; + const share = playlist.shares.find((s) => s.userId === user.id && s.status === "active"); + const isPublic = playlist.visibility === "public"; + + if (!isOwner && !share && !isPublic) { + res.status(403).json({ success: false, error: "FORBIDDEN" }); + return; + } + + const videos = playlist.playlistVideos + .sort((a, b) => a.position - b.position) + .map((pv) => pv.video); + + res.json({ + success: true, + data: { + playlist: { ...playlist, shares: undefined }, + videos, + permission: isOwner ? "owner" : share?.permission ?? "view", + }, + }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +/** + * POST /api/playlists/:id/share + * Invite un utilisateur à une playlist (propriétaire uniquement). + */ +router.post("/:id/share", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + const { userId, permission } = req.body as { userId?: string; permission?: "view" | "edit" }; + + try { + const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id }); + + if (!playlist) { + res.status(404).json({ success: false, error: "NOT_FOUND" }); + return; + } + + if (playlist.ownerId !== user.id) { + res.status(403).json({ success: false, error: "FORBIDDEN" }); + return; + } + + if (!userId) { + res.status(400).json({ success: false, error: "MISSING_USER_ID" }); + return; + } + + const share = AppDataSource.getRepository(PlaylistShare).create({ + id: require("crypto").randomUUID(), + playlistId: playlist.id, + userId, + permission: permission ?? "view", + status: "pending", + }); + + await AppDataSource.getRepository(PlaylistShare).save(share); + res.status(201).json({ success: true, data: { share } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +/** + * PATCH /api/playlists/:id/share/:shareId + * Modifie permission ou status d'un invité (propriétaire uniquement). + */ +router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + const { permission, status } = req.body as { + permission?: "view" | "edit"; + status?: "active" | "revoked"; + }; + + try { + const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id }); + + if (!playlist || playlist.ownerId !== user.id) { + res.status(403).json({ success: false, error: "FORBIDDEN" }); + return; + } + + const share = await AppDataSource.getRepository(PlaylistShare).findOneBy({ id: req.params.shareId }); + + if (!share || share.playlistId !== playlist.id) { + res.status(404).json({ success: false, error: "NOT_FOUND" }); + return; + } + + if (permission) share.permission = permission; + if (status) share.status = status; + + await AppDataSource.getRepository(PlaylistShare).save(share); + res.json({ success: true, data: { share } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +export default router; diff --git a/backend/src/routes/video.routes.ts b/backend/src/routes/video.routes.ts new file mode 100644 index 0000000..ce394e7 --- /dev/null +++ b/backend/src/routes/video.routes.ts @@ -0,0 +1,83 @@ +import { Router, Request, Response } from "express"; +import { AppDataSource } from "../config/data-source"; +import { Video } from "../entities/Video"; +import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware"; +import { UserSubscription } from "../entities/UserSubscription"; + +const router = Router(); + +/** Récupère le niveau de plan actif d'un user (0 = free si aucun abonnement actif) */ +async function getUserPlanLevel(userId: string): Promise { + const sub = await AppDataSource.getRepository(UserSubscription).findOne({ + where: { userId, status: "active" }, + relations: ["plan"], + order: { startsAt: "DESC" }, + }); + return sub?.plan.level ?? 0; +} + +/** + * GET /api/videos + * Liste les vidéos publiées. Filtre selon le niveau de plan de l'utilisateur. + * Sans auth → niveau 0 (free uniquement). + */ +router.get("/", async (req: Request, res: Response): Promise => { + try { + const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined; + const userLevel = user ? await getUserPlanLevel(user.id) : 0; + + const videos = await AppDataSource.getRepository(Video).find({ + where: { isPublished: true }, + order: { publishedAt: "DESC" }, + select: ["id", "title", "description", "thumbnailUrl", "duration", + "storageType", "storageKey", "requiredLevel", "publishedAt"], + }); + + // Injequer un flag `locked` côté client pour les vidéos hors niveau + const result = videos.map((v) => ({ + ...v, + locked: v.requiredLevel > userLevel, + // Ne pas exposer storageKey si la vidéo est verrouillée + storageKey: v.requiredLevel > userLevel ? null : v.storageKey, + })); + + res.json({ success: true, data: { videos: result } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +/** + * GET /api/videos/:id + * Détail d'une vidéo. Retourne 403 si requiredLevel > userLevel. + */ +router.get("/:id", async (req: Request, res: Response): Promise => { + try { + const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined; + const userLevel = user ? await getUserPlanLevel(user.id) : 0; + + const video = await AppDataSource.getRepository(Video).findOne({ + where: { id: req.params.id, isPublished: true }, + }); + + if (!video) { + res.status(404).json({ success: false, error: "NOT_FOUND" }); + return; + } + + if (video.requiredLevel > userLevel) { + res.status(403).json({ + success: false, + error: "INSUFFICIENT_PLAN", + data: { requiredLevel: video.requiredLevel, userLevel }, + }); + return; + } + + res.json({ success: true, data: { video } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +export default router;