feat: stream route, admin subscriptions, fix CORS multi-origin
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
- index.ts : CORS supporte plusieurs origines (FRONTEND_URL séparé par virgule) - stream.routes.ts : GET /api/stream/:key* — sert fichiers locaux avec auth optionnelle, contrôle d'accès par level, support Range requests (seekable) - admin.routes.ts : POST /api/admin/users/:id/subscriptions — assigne un plan, expire l'abonnement actif précédent - Fix .env VPS : FRONTEND_URL=origins.tetardtek.com (domaine correct)
This commit is contained in:
@@ -8,14 +8,23 @@ import authRoutes from "./routes/auth.routes";
|
||||
import videoRoutes from "./routes/video.routes";
|
||||
import playlistRoutes from "./routes/playlist.routes";
|
||||
import adminRoutes from "./routes/admin.routes";
|
||||
import streamRoutes from "./routes/stream.routes";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT ?? "4000");
|
||||
|
||||
const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
.map((o) => o.trim());
|
||||
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
|
||||
origin: (origin, cb) => {
|
||||
// Autoriser les requêtes sans origin (curl, Postman, same-origin)
|
||||
if (!origin || allowedOrigins.includes(origin)) return cb(null, true);
|
||||
cb(new Error(`CORS: origin ${origin} not allowed`));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
@@ -29,6 +38,7 @@ app.use("/api/auth", authRoutes);
|
||||
app.use("/api/videos", videoRoutes);
|
||||
app.use("/api/playlists", playlistRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
app.use("/api/stream", streamRoutes);
|
||||
|
||||
AppDataSource.initialize()
|
||||
.then(() => {
|
||||
|
||||
@@ -342,4 +342,59 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// USER SUBSCRIPTIONS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/admin/users/:id/subscriptions
|
||||
* Assigne un plan à un utilisateur.
|
||||
* Expire l'abonnement actif précédent si existant.
|
||||
* Body: { planId, endsAt? } — endsAt ISO string, null = permanent
|
||||
*/
|
||||
router.post("/users/:id/subscriptions", async (req: Request, res: Response): Promise<void> => {
|
||||
const { planId, endsAt = null } = req.body as { planId?: string; endsAt?: string | null };
|
||||
|
||||
if (!planId) {
|
||||
res.status(400).json({ success: false, error: "MISSING_PLAN_ID" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepo = AppDataSource.getRepository(User);
|
||||
const planRepo = AppDataSource.getRepository(SubscriptionPlan);
|
||||
const subRepo = AppDataSource.getRepository(UserSubscription);
|
||||
|
||||
const [user, plan] = await Promise.all([
|
||||
userRepo.findOne({ where: { id: req.params.id } }),
|
||||
planRepo.findOne({ where: { id: planId } }),
|
||||
]);
|
||||
|
||||
if (!user) { res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); return; }
|
||||
if (!plan) { res.status(404).json({ success: false, error: "PLAN_NOT_FOUND" }); return; }
|
||||
|
||||
// Expirer l'abonnement actif précédent
|
||||
await subRepo
|
||||
.createQueryBuilder()
|
||||
.update(UserSubscription)
|
||||
.set({ status: "expired" })
|
||||
.where("userId = :userId AND status = :status", { userId: user.id, status: "active" })
|
||||
.execute();
|
||||
|
||||
const sub = subRepo.create({
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
planId: plan.id,
|
||||
status: "active",
|
||||
startsAt: new Date(),
|
||||
endsAt: endsAt ? new Date(endsAt) : null,
|
||||
});
|
||||
|
||||
await subRepo.save(sub);
|
||||
res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
136
backend/src/routes/stream.routes.ts
Normal file
136
backend/src/routes/stream.routes.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { Video } from "../entities/Video";
|
||||
import { User } from "../entities/User";
|
||||
import { UserSubscription } from "../entities/UserSubscription";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
|
||||
async function getUserLevel(token: string | undefined): Promise<number> {
|
||||
if (!token) return 0;
|
||||
|
||||
try {
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
if (!superOAuthUrl) return 0;
|
||||
|
||||
const res = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await res.json() as {
|
||||
success: boolean;
|
||||
data?: { valid: boolean; user?: { id: string } };
|
||||
};
|
||||
|
||||
if (!data.data?.valid || !data.data.user) return 0;
|
||||
|
||||
const superOAuthId = data.data.user.id;
|
||||
const dbUser = await AppDataSource.getRepository(User).findOne({ where: { superOAuthId } });
|
||||
if (!dbUser) return 0;
|
||||
|
||||
const sub = await AppDataSource.getRepository(UserSubscription).findOne({
|
||||
where: { userId: dbUser.id, status: "active" },
|
||||
relations: ["plan"],
|
||||
order: { startsAt: "DESC" },
|
||||
});
|
||||
|
||||
return sub?.plan.level ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stream/:key
|
||||
* Sert un fichier local (storageType="local") avec contrôle d'accès.
|
||||
* :key est le chemin relatif stocké en DB (ex: "uploads/my-video.mp4").
|
||||
*
|
||||
* Support Range requests pour la seekabilité vidéo.
|
||||
*/
|
||||
router.get("/:key(*)", async (req: Request, res: Response): Promise<void> => {
|
||||
const key = req.params.key;
|
||||
|
||||
// Sécurité : interdire path traversal
|
||||
const resolved = path.resolve(UPLOADS_DIR, key);
|
||||
if (!resolved.startsWith(path.resolve(UPLOADS_DIR))) {
|
||||
res.status(400).json({ success: false, error: "INVALID_KEY" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier que la vidéo existe en DB et récupérer son niveau requis
|
||||
const video = await AppDataSource.getRepository(Video).findOne({
|
||||
where: { storageKey: key, storageType: "local", isPublished: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
res.status(404).json({ success: false, error: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Contrôle d'accès
|
||||
const token =
|
||||
req.headers.authorization?.split(" ")[1] ??
|
||||
(req.cookies as Record<string, string>)?.od_token;
|
||||
|
||||
const userLevel = await getUserLevel(token);
|
||||
|
||||
if (video.requiredLevel > userLevel) {
|
||||
res.status(403).json({ success: false, error: "INSUFFICIENT_PLAN" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
if (!fs.existsSync(resolved)) {
|
||||
res.status(404).json({ success: false, error: "FILE_NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const fileSize = stat.size;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".ts": "video/MP2T",
|
||||
};
|
||||
const contentType = mimeTypes[ext] ?? "application/octet-stream";
|
||||
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": chunkSize,
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
|
||||
fs.createReadStream(resolved, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
"Content-Length": fileSize,
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
});
|
||||
fs.createReadStream(resolved).pipe(res);
|
||||
}
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user