feat: stream route, admin subscriptions, fix CORS multi-origin
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:
2026-03-14 09:58:01 +01:00
parent 4265d21c8b
commit 666cf6a435
3 changed files with 202 additions and 1 deletions

View File

@@ -8,14 +8,23 @@ import authRoutes from "./routes/auth.routes";
import videoRoutes from "./routes/video.routes"; import videoRoutes from "./routes/video.routes";
import playlistRoutes from "./routes/playlist.routes"; import playlistRoutes from "./routes/playlist.routes";
import adminRoutes from "./routes/admin.routes"; import adminRoutes from "./routes/admin.routes";
import streamRoutes from "./routes/stream.routes";
dotenv.config(); dotenv.config();
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT ?? "4000"); const PORT = parseInt(process.env.PORT ?? "4000");
const allowedOrigins = (process.env.FRONTEND_URL ?? "http://localhost:5173")
.split(",")
.map((o) => o.trim());
app.use(cors({ 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, credentials: true,
})); }));
app.use(express.json()); app.use(express.json());
@@ -29,6 +38,7 @@ app.use("/api/auth", authRoutes);
app.use("/api/videos", videoRoutes); app.use("/api/videos", videoRoutes);
app.use("/api/playlists", playlistRoutes); app.use("/api/playlists", playlistRoutes);
app.use("/api/admin", adminRoutes); app.use("/api/admin", adminRoutes);
app.use("/api/stream", streamRoutes);
AppDataSource.initialize() AppDataSource.initialize()
.then(() => { .then(() => {

View File

@@ -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; export default router;

View 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;