feat(backend): mount API routes + cookie-parser + CORS with credentials

- index.ts: mount /api/auth, /api/videos, /api/playlists; add cookie-parser; CORS with credentials + FRONTEND_URL env
- auth.middleware: read token from Bearer header OR od_token httpOnly cookie
- routes: auth (session/logout/me), videos (level-gated), playlists (CRUD + share management)
- deps: cookie-parser + @types/cookie-parser
This commit is contained in:
2026-03-14 07:10:47 +01:00
parent 71d90eb133
commit f3e392ff1b
7 changed files with 401 additions and 8 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(() => {

View File

@@ -29,7 +29,9 @@ export const requireAuth = async (
res: Response,
next: NextFunction
): Promise<void> => {
const token = req.headers.authorization?.split(" ")[1];
const token =
req.headers.authorization?.split(" ")[1] ??
(req.cookies as Record<string, string>)?.od_token;
if (!token) {
res.status(401).json({ success: false, error: "UNAUTHORIZED", message: "Access token required" });

View File

@@ -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<void> => {
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;

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;

View File

@@ -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<number> {
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<void> => {
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<void> => {
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;