Compare commits
6 Commits
b771f4d1c3
...
f1de2bb065
| Author | SHA1 | Date | |
|---|---|---|---|
| f1de2bb065 | |||
| 25733ee3db | |||
| f3e392ff1b | |||
| 71d90eb133 | |||
| 2f47be1305 | |||
| 4f3c0e6433 |
31
backend/package-lock.json
generated
31
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,17 @@ export const AppDataSource = new DataSource({
|
||||
database: process.env.DB_NAME ?? "originsdigital",
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
entities: ["src/entities/**/*.ts"],
|
||||
migrations: ["src/migrations/**/*.ts"],
|
||||
entities: [
|
||||
require("../entities/User").User,
|
||||
require("../entities/Role").Role,
|
||||
require("../entities/UserRole").UserRole,
|
||||
require("../entities/SubscriptionPlan").SubscriptionPlan,
|
||||
require("../entities/UserSubscription").UserSubscription,
|
||||
require("../entities/Video").Video,
|
||||
require("../entities/Playlist").Playlist,
|
||||
require("../entities/PlaylistVideo").PlaylistVideo,
|
||||
require("../entities/PlaylistShare").PlaylistShare,
|
||||
],
|
||||
migrations: [__dirname + "/../migrations/**/*.ts"],
|
||||
subscribers: [],
|
||||
});
|
||||
|
||||
47
backend/src/entities/Playlist.ts
Normal file
47
backend/src/entities/Playlist.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
ManyToOne, OneToMany, JoinColumn,
|
||||
CreateDateColumn, UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "./User";
|
||||
import { PlaylistVideo } from "./PlaylistVideo";
|
||||
import { PlaylistShare } from "./PlaylistShare";
|
||||
|
||||
export type PlaylistVisibility = "private" | "shared" | "public";
|
||||
|
||||
@Entity("playlists")
|
||||
export class Playlist {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "uuid" })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// private → visible uniquement par le propriétaire
|
||||
// shared → visible par les utilisateurs invités via PlaylistShare
|
||||
// public → visible par tous
|
||||
@Column({ type: "enum", enum: ["private", "shared", "public"], default: "private" })
|
||||
visibility!: PlaylistVisibility;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.playlists, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "ownerId" })
|
||||
owner!: User;
|
||||
|
||||
@OneToMany(() => PlaylistVideo, (pv) => pv.playlist)
|
||||
playlistVideos!: PlaylistVideo[];
|
||||
|
||||
@OneToMany(() => PlaylistShare, (share) => share.playlist)
|
||||
shares!: PlaylistShare[];
|
||||
}
|
||||
46
backend/src/entities/PlaylistShare.ts
Normal file
46
backend/src/entities/PlaylistShare.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
import { Playlist } from "./Playlist";
|
||||
import { User } from "./User";
|
||||
|
||||
export type SharePermission = "view" | "edit";
|
||||
export type ShareStatus = "pending" | "active" | "revoked";
|
||||
|
||||
@Entity("playlist_shares")
|
||||
export class PlaylistShare {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "uuid" })
|
||||
playlistId!: string;
|
||||
|
||||
@Column({ type: "uuid" })
|
||||
userId!: string;
|
||||
|
||||
// Permissions gérées par le propriétaire de la playlist
|
||||
@Column({ type: "enum", enum: ["view", "edit"], default: "view" })
|
||||
permission!: SharePermission;
|
||||
|
||||
// Statut géré par le propriétaire :
|
||||
// pending → invitation envoyée, pas encore acceptée
|
||||
// active → invité a accepté
|
||||
// revoked → propriétaire a révoqué l'accès
|
||||
@Column({ type: "enum", enum: ["pending", "active", "revoked"], default: "pending" })
|
||||
status!: ShareStatus;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => Playlist, (playlist) => playlist.shares, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "playlistId" })
|
||||
playlist!: Playlist;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.playlistShares, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "userId" })
|
||||
user!: User;
|
||||
}
|
||||
24
backend/src/entities/PlaylistVideo.ts
Normal file
24
backend/src/entities/PlaylistVideo.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Entity, ManyToOne, JoinColumn, PrimaryColumn, Column } from "typeorm";
|
||||
import { Playlist } from "./Playlist";
|
||||
import { Video } from "./Video";
|
||||
|
||||
@Entity("playlist_videos")
|
||||
export class PlaylistVideo {
|
||||
@PrimaryColumn({ type: "uuid" })
|
||||
playlistId!: string;
|
||||
|
||||
@PrimaryColumn({ type: "uuid" })
|
||||
videoId!: string;
|
||||
|
||||
// Ordre d'affichage dans la playlist — géré par le propriétaire ou les éditeurs
|
||||
@Column({ type: "int", unsigned: true, default: 0 })
|
||||
position!: number;
|
||||
|
||||
@ManyToOne(() => Playlist, (playlist) => playlist.playlistVideos, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "playlistId" })
|
||||
playlist!: Playlist;
|
||||
|
||||
@ManyToOne(() => Video, (video) => video.playlistVideos, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "videoId" })
|
||||
video!: Video;
|
||||
}
|
||||
19
backend/src/entities/Role.ts
Normal file
19
backend/src/entities/Role.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
|
||||
import { UserRole } from "./UserRole";
|
||||
|
||||
export type RoleSlug = "user" | "moderator" | "admin" | "super_admin";
|
||||
|
||||
@Entity("roles")
|
||||
export class Role {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 50, unique: true })
|
||||
slug!: RoleSlug;
|
||||
|
||||
@Column({ type: "varchar", length: 100 })
|
||||
name!: string;
|
||||
|
||||
@OneToMany(() => UserRole, (userRole) => userRole.role)
|
||||
userRoles!: UserRole[];
|
||||
}
|
||||
35
backend/src/entities/SubscriptionPlan.ts
Normal file
35
backend/src/entities/SubscriptionPlan.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
|
||||
import { UserSubscription } from "./UserSubscription";
|
||||
|
||||
export type PlanSlug = "free" | "basic" | "pro" | "enterprise";
|
||||
|
||||
@Entity("subscription_plans")
|
||||
export class SubscriptionPlan {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 50, unique: true })
|
||||
slug!: PlanSlug;
|
||||
|
||||
@Column({ type: "varchar", length: 100 })
|
||||
name!: string;
|
||||
|
||||
// Niveau d'accès — comparaison entière : userPlanLevel >= video.requiredLevel
|
||||
// free=0, basic=1, pro=2, enterprise=3
|
||||
@Column({ type: "tinyint", unsigned: true, default: 0 })
|
||||
level!: number;
|
||||
|
||||
// Prix en centimes (0 = gratuit). Évite les flottants en DB.
|
||||
@Column({ type: "int", unsigned: true, default: 0 })
|
||||
priceInCents!: number;
|
||||
|
||||
// Features libres — permet la personnalisation marque blanche sans migration
|
||||
@Column({ type: "json", nullable: true })
|
||||
features!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => UserSubscription, (sub) => sub.plan)
|
||||
subscriptions!: UserSubscription[];
|
||||
}
|
||||
45
backend/src/entities/User.ts
Normal file
45
backend/src/entities/User.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
OneToMany, CreateDateColumn, UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
import { UserRole } from "./UserRole";
|
||||
import { UserSubscription } from "./UserSubscription";
|
||||
import { Playlist } from "./Playlist";
|
||||
import { PlaylistShare } from "./PlaylistShare";
|
||||
|
||||
@Entity("users")
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
// Identifiant SuperOAuth — clé de réconciliation avec le service auth
|
||||
@Column({ type: "varchar", length: 255, unique: true })
|
||||
superOAuthId!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true })
|
||||
email!: string | null;
|
||||
|
||||
@Column({ type: "varchar", length: 100 })
|
||||
nickname!: string;
|
||||
|
||||
@Column({ type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@OneToMany(() => UserRole, (userRole) => userRole.user)
|
||||
userRoles!: UserRole[];
|
||||
|
||||
@OneToMany(() => UserSubscription, (sub) => sub.user)
|
||||
subscriptions!: UserSubscription[];
|
||||
|
||||
@OneToMany(() => Playlist, (playlist) => playlist.owner)
|
||||
playlists!: Playlist[];
|
||||
|
||||
@OneToMany(() => PlaylistShare, (share) => share.user)
|
||||
playlistShares!: PlaylistShare[];
|
||||
}
|
||||
20
backend/src/entities/UserRole.ts
Normal file
20
backend/src/entities/UserRole.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Entity, ManyToOne, JoinColumn, PrimaryColumn } from "typeorm";
|
||||
import { User } from "./User";
|
||||
import { Role } from "./Role";
|
||||
|
||||
@Entity("user_roles")
|
||||
export class UserRole {
|
||||
@PrimaryColumn({ type: "uuid" })
|
||||
userId!: string;
|
||||
|
||||
@PrimaryColumn({ type: "uuid" })
|
||||
roleId!: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.userRoles, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "userId" })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Role, (role) => role.userRoles, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "roleId" })
|
||||
role!: Role;
|
||||
}
|
||||
41
backend/src/entities/UserSubscription.ts
Normal file
41
backend/src/entities/UserSubscription.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
ManyToOne, JoinColumn, CreateDateColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "./User";
|
||||
import { SubscriptionPlan } from "./SubscriptionPlan";
|
||||
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
|
||||
|
||||
@Entity("user_subscriptions")
|
||||
export class UserSubscription {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "uuid" })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: "uuid" })
|
||||
planId!: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["active", "expired", "cancelled", "trial"], default: "active" })
|
||||
status!: SubscriptionStatus;
|
||||
|
||||
@Column({ type: "datetime" })
|
||||
startsAt!: Date;
|
||||
|
||||
// null = pas d'expiration (plan gratuit permanent)
|
||||
@Column({ type: "datetime", nullable: true })
|
||||
endsAt!: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.subscriptions, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "userId" })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => SubscriptionPlan, (plan) => plan.subscriptions)
|
||||
@JoinColumn({ name: "planId" })
|
||||
plan!: SubscriptionPlan;
|
||||
}
|
||||
58
backend/src/entities/Video.ts
Normal file
58
backend/src/entities/Video.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
OneToMany, CreateDateColumn, UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
import { PlaylistVideo } from "./PlaylistVideo";
|
||||
|
||||
export type StorageType = "youtube" | "s3" | "local" | "external";
|
||||
|
||||
@Entity("videos")
|
||||
export class Video {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: "varchar", length: 500, nullable: true })
|
||||
thumbnailUrl!: string | null;
|
||||
|
||||
// Durée en secondes
|
||||
@Column({ type: "int", unsigned: true, nullable: true })
|
||||
duration!: number | null;
|
||||
|
||||
@Column({ type: "enum", enum: ["youtube", "s3", "local", "external"] })
|
||||
storageType!: StorageType;
|
||||
|
||||
// Référence flexible selon storageType :
|
||||
// youtube → videoId YouTube (ex: "dQw4w9WgXcQ")
|
||||
// s3 → clé S3 (ex: "videos/2026/my-video.mp4")
|
||||
// local → chemin relatif (ex: "uploads/my-video.mp4")
|
||||
// external → URL complète
|
||||
@Column({ type: "varchar", length: 500 })
|
||||
storageKey!: string;
|
||||
|
||||
// Niveau de plan minimum requis pour accéder à cette vidéo
|
||||
// 0 = libre, 1 = basic, 2 = pro, 3 = enterprise
|
||||
// Comparaison : userSubscription.plan.level >= video.requiredLevel
|
||||
@Column({ type: "tinyint", unsigned: true, default: 0 })
|
||||
requiredLevel!: number;
|
||||
|
||||
@Column({ type: "boolean", default: false })
|
||||
isPublished!: boolean;
|
||||
|
||||
@Column({ type: "datetime", nullable: true })
|
||||
publishedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@OneToMany(() => PlaylistVideo, (pv) => pv.video)
|
||||
playlistVideos!: PlaylistVideo[];
|
||||
}
|
||||
@@ -1,21 +1,33 @@
|
||||
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 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() });
|
||||
});
|
||||
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/videos", videoRoutes);
|
||||
app.use("/api/playlists", playlistRoutes);
|
||||
|
||||
AppDataSource.initialize()
|
||||
.then(() => {
|
||||
console.log("Database connected");
|
||||
|
||||
71
backend/src/middleware/auth.middleware.ts
Normal file
71
backend/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
isActive: boolean;
|
||||
linkedProviders: string[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware d'authentification — Token Introspection via SuperOAuth
|
||||
*
|
||||
* Valide le Bearer token auprès de SuperOAuth (POST /api/auth/token/validate).
|
||||
* Aucun secret JWT partagé — SuperOAuth garde le contrôle total.
|
||||
*
|
||||
* Flow :
|
||||
* 1. Extraire le Bearer token du header Authorization
|
||||
* 2. Appeler SuperOAuth /api/auth/token/validate
|
||||
* 3. Si valid → attacher req.user et continuer
|
||||
* 4. Sinon → 401
|
||||
*/
|
||||
export const requireAuth = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
if (!superOAuthUrl) {
|
||||
console.error("SUPER_OAUTH_URL not configured");
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
|
||||
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?: AuthenticatedUser };
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !data.data?.valid || !data.data.user) {
|
||||
res.status(401).json({ success: false, error: data.error ?? "INVALID_TOKEN", message: "Invalid or expired token" });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest).user = data.data.user;
|
||||
next();
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
|
||||
}
|
||||
};
|
||||
168
backend/src/migrations/1710374400000-InitialSchema.ts
Normal file
168
backend/src/migrations/1710374400000-InitialSchema.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class InitialSchema1710374400000 implements MigrationInterface {
|
||||
name = "InitialSchema1710374400000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// roles
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE roles (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// subscription_plans
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE subscription_plans (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
level TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
priceInCents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
features JSON NULL,
|
||||
isActive BOOLEAN NOT NULL DEFAULT TRUE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// users
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE users (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
superOAuthId VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NULL,
|
||||
nickname VARCHAR(100) NOT NULL,
|
||||
isActive BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_users_superOAuthId (superOAuthId),
|
||||
INDEX idx_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// user_roles (pivot)
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE user_roles (
|
||||
userId VARCHAR(36) NOT NULL,
|
||||
roleId VARCHAR(36) NOT NULL,
|
||||
PRIMARY KEY (userId, roleId),
|
||||
CONSTRAINT fk_user_roles_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_role FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// user_subscriptions
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE user_subscriptions (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
userId VARCHAR(36) NOT NULL,
|
||||
planId VARCHAR(36) NOT NULL,
|
||||
status ENUM('active','expired','cancelled','trial') NOT NULL DEFAULT 'active',
|
||||
startsAt DATETIME NOT NULL,
|
||||
endsAt DATETIME NULL,
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_user_subscriptions_userId (userId),
|
||||
INDEX idx_user_subscriptions_status (status),
|
||||
CONSTRAINT fk_user_subscriptions_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_subscriptions_plan FOREIGN KEY (planId) REFERENCES subscription_plans(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// videos
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE videos (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
thumbnailUrl VARCHAR(500) NULL,
|
||||
duration INT UNSIGNED NULL,
|
||||
storageType ENUM('youtube','s3','local','external') NOT NULL,
|
||||
storageKey VARCHAR(500) NOT NULL,
|
||||
requiredLevel TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
isPublished BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
publishedAt DATETIME NULL,
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_videos_requiredLevel (requiredLevel),
|
||||
INDEX idx_videos_isPublished (isPublished)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// playlists
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE playlists (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
ownerId VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
visibility ENUM('private','shared','public') NOT NULL DEFAULT 'private',
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_playlists_ownerId (ownerId),
|
||||
INDEX idx_playlists_visibility (visibility),
|
||||
CONSTRAINT fk_playlists_owner FOREIGN KEY (ownerId) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// playlist_videos (pivot ordonné)
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE playlist_videos (
|
||||
playlistId VARCHAR(36) NOT NULL,
|
||||
videoId VARCHAR(36) NOT NULL,
|
||||
position INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (playlistId, videoId),
|
||||
INDEX idx_playlist_videos_position (playlistId, position),
|
||||
CONSTRAINT fk_playlist_videos_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_videos_video FOREIGN KEY (videoId) REFERENCES videos(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// playlist_shares
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE playlist_shares (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
playlistId VARCHAR(36) NOT NULL,
|
||||
userId VARCHAR(36) NOT NULL,
|
||||
permission ENUM('view','edit') NOT NULL DEFAULT 'view',
|
||||
status ENUM('pending','active','revoked') NOT NULL DEFAULT 'pending',
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
UNIQUE KEY uq_playlist_shares (playlistId, userId),
|
||||
INDEX idx_playlist_shares_userId (userId),
|
||||
INDEX idx_playlist_shares_status (status),
|
||||
CONSTRAINT fk_playlist_shares_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_shares_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// Données initiales — plans et rôles
|
||||
await queryRunner.query(`
|
||||
INSERT INTO roles (id, slug, name) VALUES
|
||||
(UUID(), 'user', 'Utilisateur'),
|
||||
(UUID(), 'moderator', 'Modérateur'),
|
||||
(UUID(), 'admin', 'Administrateur'),
|
||||
(UUID(), 'super_admin', 'Super Administrateur')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO subscription_plans (id, slug, name, level, priceInCents, features) VALUES
|
||||
(UUID(), 'free', 'Gratuit', 0, 0, JSON_OBJECT('maxPlaylists', 3)),
|
||||
(UUID(), 'basic', 'Basic', 1, 499, JSON_OBJECT('maxPlaylists', 20)),
|
||||
(UUID(), 'pro', 'Pro', 2, 999, JSON_OBJECT('maxPlaylists', 999)),
|
||||
(UUID(), 'enterprise', 'Enterprise', 3, 4999, JSON_OBJECT('maxPlaylists', 999, 'whiteLabel', true))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS playlist_shares`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS playlist_videos`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS playlists`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS videos`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS user_subscriptions`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS user_roles`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS users`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS subscription_plans`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS roles`);
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
DB_USER: ${DB_USER:-originsdigital}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SUPER_OAUTH_URL: ${SUPER_OAUTH_URL:-https://superoauth.tetardtek.com}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# URL complète d'autorisation OAuth — SuperOAuth
|
||||
# Format : https://superoauth.tetardtek.com/oauth/authorize?client_id=XXX&redirect_uri=http://localhost:5173/callback&response_type=token
|
||||
VITE_SUPEROAUTH_AUTHORIZE_URL=
|
||||
937
frontend/package-lock.json
generated
937
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.8"
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/layout/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import CallbackPage from './pages/CallbackPage';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
function App() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('od-theme') as Theme) ?? 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('od-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>OriginsDigital — v2</h1>
|
||||
<p>Refonte en cours.</p>
|
||||
</main>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
70
frontend/src/components/layout/Header.tsx
Normal file
70
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { User } from '../../hooks/useAuth';
|
||||
|
||||
interface HeaderProps {
|
||||
theme: 'dark' | 'light';
|
||||
onToggleTheme: () => void;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export default function Header({ theme, onToggleTheme, user }: HeaderProps) {
|
||||
const loginUrl = import.meta.env.VITE_SUPEROAUTH_AUTHORIZE_URL;
|
||||
|
||||
return (
|
||||
<header className="border-b border-od-border bg-od-surface">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<span className="font-mono text-xs font-bold text-od-accent tracking-widest group-hover:text-od-accent-dim transition-colors">
|
||||
OD
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-od-text">
|
||||
OriginsDigital
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-od-muted hover:text-od-text transition-colors"
|
||||
>
|
||||
Accueil
|
||||
</Link>
|
||||
<Link
|
||||
to="/videos"
|
||||
className="text-sm text-od-muted hover:text-od-text transition-colors"
|
||||
>
|
||||
Vidéos
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right — thème + auth */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
aria-label="Changer le thème"
|
||||
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? '◑' : '◐'}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<span className="font-mono text-xs text-od-accent">
|
||||
{user.nickname}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={loginUrl}
|
||||
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors"
|
||||
>
|
||||
Connexion
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/layout/Layout.tsx
Normal file
25
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
interface LayoutProps {
|
||||
theme: 'dark' | 'light';
|
||||
onToggleTheme: () => void;
|
||||
}
|
||||
|
||||
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-od-bg text-od-text">
|
||||
<Header
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
user={loading ? null : user}
|
||||
/>
|
||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/hooks/useAuth.ts
Normal file
31
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
apiFetch<User>('/profile')
|
||||
.then((u) => { if (!cancelled) setUser(u); })
|
||||
.catch(() => { if (!cancelled) setUser(null); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return { user, loading };
|
||||
}
|
||||
18
frontend/src/lib/api.ts
Normal file
18
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const BASE = '/api';
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include', // transmet le cookie httpOnly automatiquement
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${path}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
47
frontend/src/pages/CallbackPage.tsx
Normal file
47
frontend/src/pages/CallbackPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export default function CallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
|
||||
// Pas de token dans l'URL → retour silencieux
|
||||
if (!token) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Envoie le token au backend → backend valide + pose le cookie httpOnly
|
||||
apiFetch<void>('/auth/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(() => navigate('/', { replace: true }))
|
||||
.catch(() => setError("Échec de l'authentification. Réessaie."));
|
||||
}, [navigate]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 pt-20">
|
||||
<p className="text-od-crit">{error}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
|
||||
>
|
||||
← Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-20">
|
||||
<p className="font-mono text-sm text-od-muted">Connexion en cours…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/pages/HomePage.tsx
Normal file
56
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="border-b border-od-border pb-8">
|
||||
<h1 className="text-2xl font-semibold text-od-text">
|
||||
Vidéos & formations
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-od-muted">
|
||||
Contenu libre et premium — connecte-toi pour accéder aux formations complètes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Accès libre */}
|
||||
<section>
|
||||
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-muted">
|
||||
Accès libre
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<VideoCardPlaceholder tier="free" />
|
||||
<VideoCardPlaceholder tier="free" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Premium */}
|
||||
<section>
|
||||
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-accent">
|
||||
Premium
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<VideoCardPlaceholder tier="premium" />
|
||||
<VideoCardPlaceholder tier="premium" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoCardPlaceholder({ tier }: { tier: 'free' | 'premium' }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="h-28 rounded bg-od-surface-hi" />
|
||||
{/* Title skeleton */}
|
||||
<div className="h-3 w-3/4 rounded bg-od-surface-hi" />
|
||||
<div className="h-2 w-1/2 rounded bg-od-surface-hi" />
|
||||
{tier === 'premium' && (
|
||||
<span className="self-start rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/styles/index.css
Normal file
40
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,40 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ─── Void Dark (défaut) ─────────────────────────────────────────────────── */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--od-bg: #0a0a0d; /* fond principal — quasi-noir cool */
|
||||
--od-surface: #111115; /* panneaux, cartes */
|
||||
--od-surface-hi: #191920; /* survol, éléments élevés */
|
||||
--od-border: #222228; /* séparateurs subtils */
|
||||
--od-text: #dddde8; /* texte principal */
|
||||
--od-muted: #62626e; /* texte secondaire, labels */
|
||||
--od-accent: #d4a853; /* or chaud — premium */
|
||||
--od-accent-dim: #a07830; /* survol accent */
|
||||
--od-crit: #d95f5f; /* erreurs */
|
||||
--od-ok: #5fc875; /* succès */
|
||||
}
|
||||
|
||||
/* ─── Void Light ─────────────────────────────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--od-bg: #f2f2f5;
|
||||
--od-surface: #ffffff;
|
||||
--od-surface-hi: #e8e8ee;
|
||||
--od-border: #d0d0da;
|
||||
--od-text: #14141a;
|
||||
--od-muted: #6a6a78;
|
||||
--od-accent: #a07830;
|
||||
--od-accent-dim: #7a5c20;
|
||||
--od-crit: #c04040;
|
||||
--od-ok: #3aa855;
|
||||
}
|
||||
|
||||
/* ─── Base ───────────────────────────────────────────────────────────────── */
|
||||
body {
|
||||
background-color: var(--od-bg);
|
||||
color: var(--od-text);
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SUPEROAUTH_AUTHORIZE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
31
frontend/tailwind.config.ts
Normal file
31
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
// Design system "Void" — palette custom OriginsDigital
|
||||
// Les couleurs sont définies comme variables CSS dans src/styles/index.css
|
||||
// → thème sombre/clair géré via data-theme="dark|light" sur <html>
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
od: {
|
||||
bg: 'var(--od-bg)',
|
||||
surface: 'var(--od-surface)',
|
||||
'surface-hi': 'var(--od-surface-hi)',
|
||||
border: 'var(--od-border)',
|
||||
text: 'var(--od-text)',
|
||||
muted: 'var(--od-muted)',
|
||||
accent: 'var(--od-accent)',
|
||||
'accent-dim': 'var(--od-accent-dim)',
|
||||
crit: 'var(--od-crit)',
|
||||
ok: 'var(--od-ok)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
Reference in New Issue
Block a user