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:
@@ -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(() => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
76
backend/src/routes/auth.routes.ts
Normal file
76
backend/src/routes/auth.routes.ts
Normal 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;
|
||||
194
backend/src/routes/playlist.routes.ts
Normal file
194
backend/src/routes/playlist.routes.ts
Normal 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;
|
||||
83
backend/src/routes/video.routes.ts
Normal file
83
backend/src/routes/video.routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user