Browse Source

Merge pull request 'developpement' (#12) from developpement into main

Reviewed-on: #12
fix/clean first_release
astria 3 months ago
parent
commit
77d1180331
  1. 9
      .gitignore
  2. 764
      README.md
  3. 18
      backend/Dockerfile
  4. 98
      backend/app/controllers/channel.controller.js
  5. 6
      backend/app/controllers/comment.controller.js
  6. 159
      backend/app/controllers/oauth.controller.js
  7. 151
      backend/app/controllers/playlist.controller.js
  8. 293
      backend/app/controllers/recommendation.controller.js
  9. 131
      backend/app/controllers/search.controller.js
  10. 380
      backend/app/controllers/user.controller.js
  11. 302
      backend/app/controllers/video.controller.js
  12. 98
      backend/app/middlewares/channel.middleware.js
  13. 36
      backend/app/middlewares/comment.middleware.js
  14. 70
      backend/app/middlewares/playlist.middleware.js
  15. 90
      backend/app/middlewares/user.middleware.js
  16. 156
      backend/app/middlewares/video.middleware.js
  17. 47
      backend/app/routes/channel.route.js
  18. 15
      backend/app/routes/oauth.route.js
  19. 6
      backend/app/routes/playlist.route.js
  20. 4
      backend/app/routes/redommendation.route.js
  21. 5
      backend/app/routes/search.route.js
  22. 28
      backend/app/routes/user.route.js
  23. 22
      backend/app/routes/video.route.js
  24. BIN
      backend/app/uploads/profiles/astri6.jpg
  25. BIN
      backend/app/uploads/profiles/astri7.jpg
  26. BIN
      backend/app/uploads/profiles/astria.png
  27. BIN
      backend/app/uploads/profiles/astria2.jpg
  28. BIN
      backend/app/uploads/profiles/astria3.jpg
  29. BIN
      backend/app/uploads/profiles/test.jpg
  30. BIN
      backend/app/uploads/videos/946FFC1D2D8C189D.mp4
  31. 103
      backend/app/utils/database.js
  32. 38
      backend/app/utils/mail.js
  33. 10854
      backend/logs/access.log
  34. 466
      backend/package-lock.json
  35. 9
      backend/package.json
  36. 17
      backend/requests/top-tags-videos.http
  37. 6
      backend/requests/video.http
  38. 64
      backend/server.js
  39. 2317
      backend/swagger.yaml
  40. 21
      backend/tools.js
  41. 145
      checklist.md
  42. 4
      create_db.sql
  43. 4
      db.sql
  44. 68
      default.conf
  45. 100
      deploy.sh
  46. 50
      developpement.yaml
  47. 36
      docker-compose.yaml
  48. 49
      documentation.md
  49. 2
      freetube.sh
  50. 7
      frontend/Docker.Development
  51. 14
      frontend/Dockerfile
  52. 68
      frontend/default.conf
  53. 23
      frontend/nginx-selfsigned.crt
  54. 28
      frontend/nginx-selfsigned.key
  55. 30
      frontend/package-lock.json
  56. 5
      frontend/package.json
  57. BIN
      frontend/src/assets/img/background.png
  58. 1
      frontend/src/assets/svg/check.svg
  59. 1
      frontend/src/assets/svg/exit_fullscreen.svg
  60. 4
      frontend/src/assets/svg/eye-slash.svg
  61. 4
      frontend/src/assets/svg/eye.svg
  62. 1
      frontend/src/assets/svg/fullscreen.svg
  63. 1
      frontend/src/assets/svg/infinite.svg
  64. 4
      frontend/src/assets/svg/play.svg
  65. 1
      frontend/src/assets/svg/plus.svg
  66. 1
      frontend/src/assets/svg/trash.svg
  67. 1
      frontend/src/assets/svg/user.svg
  68. 33
      frontend/src/components/Alert.jsx
  69. 17
      frontend/src/components/AlertList.jsx
  70. 20
      frontend/src/components/ChannelLastVideos.jsx
  71. 129
      frontend/src/components/Comment.jsx
  72. 21
      frontend/src/components/CreatorCard.jsx
  73. 23
      frontend/src/components/GitHubLoginButton.jsx
  74. 52
      frontend/src/components/LinearGraph.jsx
  75. 246
      frontend/src/components/Navbar.jsx
  76. 2
      frontend/src/components/PlaylistCard.jsx
  77. 17
      frontend/src/components/PlaylistVideoCard.jsx
  78. 4
      frontend/src/components/ProtectedRoute.jsx
  79. 18
      frontend/src/components/Recommendations.jsx
  80. 21
      frontend/src/components/SeeLater.jsx
  81. 43
      frontend/src/components/TabLayout.jsx
  82. 22
      frontend/src/components/Tag.jsx
  83. 27
      frontend/src/components/TopCreators.jsx
  84. 10
      frontend/src/components/TrendingVideos.jsx
  85. 20
      frontend/src/components/UserCard.jsx
  86. 67
      frontend/src/components/VideoCard.jsx
  87. 19
      frontend/src/components/VideoStatListElement.jsx
  88. 12
      frontend/src/contexts/AuthContext.jsx
  89. 41
      frontend/src/index.css
  90. 71
      frontend/src/modals/CreateChannelModal.jsx
  91. 46
      frontend/src/modals/CreatePlaylistModal.jsx
  92. 26
      frontend/src/modals/EmailVerificationModal.jsx
  93. 17
      frontend/src/modals/LoadingVideoModal.jsx
  94. 27
      frontend/src/modals/VerificationModal.jsx
  95. 260
      frontend/src/pages/Account.jsx
  96. 362
      frontend/src/pages/AddVideo.jsx
  97. 115
      frontend/src/pages/Channel.jsx
  98. 92
      frontend/src/pages/Home.jsx
  99. 74
      frontend/src/pages/Login.jsx
  100. 59
      frontend/src/pages/LoginSuccess.jsx

9
.gitignore

@ -0,0 +1,9 @@
/backend/app/uploads/
# Ignore all files in the uploads directory
/frontend/node_modules
/backend/node_modules
# Ignore node_modules directories in both frontend and backend
/frontend/dist
# Ignore the build output directory for the frontend

764
README.md

@ -1,436 +1,502 @@
# FreeTube - Video Sharing Platform
![FreeTube Logo](frontend/src/assets/img/hero.png)
## 📋 Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [API Documentation](#api-documentation)
- [Project Structure](#project-structure)
- [Development](#development)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
## 🎯 Overview
FreeTube is a modern video sharing platform built as a YouTube competitor. It allows users to upload, watch, and interact with videos through comments and likes. The platform features user authentication, video management, channel subscriptions, and a recommendation system.
This project is part of a 3-part development resit assignment focusing on:
- **Part 1**: HTTP server serving HTML pages
- **Part 2**: REST API for video, user, and comment management
- **Part 3**: Interactive frontend user interface
## ✨ Features
### 🔐 Authentication System
- User registration with profile picture upload
- Secure login with JWT tokens
- Persistent sessions with localStorage
- Protected routes and authentication guards
- Profile management and display
### 📹 Video Management
- Video upload with thumbnail generation
- Video streaming and playback
- Video metadata management
- Video search and filtering
### 👥 User Features
- User profiles with customizable avatars
- Channel creation and management
- Subscription system
- User activity tracking
### 💬 Social Features
- Video commenting system
- Like/dislike functionality
- Video recommendations
- Trending videos section
### 🎨 Frontend Features
- Responsive React-based UI
- Modern design with Tailwind CSS
- Client-side routing with React Router
- Real-time updates and interactions
## 🛠 Tech Stack
### Backend
- **Runtime**: Node.js
- **Framework**: Express.js
- **Database**: PostgreSQL
- **Authentication**: JWT (JSON Web Tokens)
- **File Upload**: Multer
- **Testing**: Vitest
### Frontend
- **Framework**: React 18
- **Build Tool**: Vite
- **Styling**: Tailwind CSS
- **Routing**: React Router
- **State Management**: React Context API
- **Fonts**: Montserrat, Inter
### Infrastructure
- **Containerization**: Docker & Docker Compose
- **Reverse Proxy**: Nginx
- **Development**: Hot reload for both frontend and backend
## 📋 Prerequisites
Before you begin, ensure you have the following installed:
- [Docker](https://docs.docker.com/get-docker/) (v20.10+)
- [Docker Compose](https://docs.docker.com/compose/install/) (v2.0+)
- [Git](https://git-scm.com/downloads)
## 🚀 Installation
### Quick Start with Docker (Recommended)
1. **Clone the repository**
```bash
git clone <repository-url>
cd 3RESIT_DOCKER
```
2. **Set up environment variables**
Create a `.env` file in the root directory:
```env
# Database Configuration
POSTGRES_USER=freetube_user
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=freetube_db
POSTGRES_HOST=db
# Backend Configuration
BACKEND_PORT=8000
JWT_SECRET=your_jwt_secret_key_min_32_chars
LOG_FILE=/var/log/freetube/access.log
```
3. **Build and start the application**
```bash
docker-compose up --build
```
4. **Access the application**
- Frontend: http://localhost
- Backend API: http://localhost:8000
- Database: localhost:5432
### Manual Installation
<details>
<summary>Click to expand manual installation steps</summary>
#### Backend Setup
```bash
cd backend
npm install
npm run dev
```
#### Frontend Setup ## Sommaire
```bash
cd frontend 1) Introduction
npm install 2) Description du projet
npm run dev 3) La pile technologique
``` 1) Le serveur
2) Le site web
3) La base de données
4) Le serveur
1) Les dépendances
2) Le fonctionnement
5) Le site web
1) Les dépendances
2) Le fonctionnement
6) La base de données
7) Installation du projet
1) Docker Compose
2) Script Shell
3) Manuellement
8) Conclusion
## Introduction
Cette documentation est à destination des futurs développeurs travaillant sur Freetube. Elle a pour but d’expliquer le fonctionnement technique de toutes les couches de l’application et à justifier les choix pris pour chaque langage et framework. 
La documentation est une ressource indispensable pour tous ceux voulant comprendre le fonctionnement interne de Freetube. Les parties démontrées ici couvrent toutes les couches de l’application. Par ailleurs des diagrammes UML et schémas de base de données vous sont fournies pour une meilleure compréhension du code. Un indexe est disponible en fin de document. 
Cette documentation est à un but technique, elle rentre volontairement dans les détails du fonctionnement de chaque partie. Elle n’est pas faite pour être lu par un utilisateur, un manuel utilisateur vous a été fournies pour remplir ce besoin.
De plus les documentations externes sont disponible en fin de document et un **Swagger** est présent sur l'endpoint `/api/api-docs` pour une documentation de l'API plus approfondie.
## Description du projet
Il m’a été demandé de créer une plateforme concurrente à YouTube nommée Freetube. Cette alternative a pour but de mieux remplir les demandes des utilisateurs, pas d’abonnement ni publicité pour consommer ou poster des vidéos sur la plateforme. 
Le cahier des charges du projet demande certaines fonctionnalités à implémenter. Les utilisateurs doivent pourvoir regarder des vidéos sans avoir à se connecter où à créer de compte, ils doivent pouvoir créer un compte et le gérer, pouvoir créer une chaîne la gérer et y poster des vidéos. La fonctionnalité de vidéo privé a aussi été demandé. 
Pour ce projet j’avais la main libre sur la pile technologique à utiliser tout en respectant les demandes d’efficacité d’une plateforme de streaming vidéo.
## La pile technologique
### Le serveur
Le serveur a été codé en **Nodejs**, j’ai choisi ce langage car il permet d’implémenter une **API REST** efficacement grâce à son implémentation native de l’asynchrone indispensable pour une API REST. NodeJS étant basé sur **Javascript** il n’est pas le plus efficient mais ce n’est pas dérangeant car nous travaillons avec une API, le temps de réponse sera biaisé par la connexion internet de l’utilisateur.
### Le site web
Le site web a lui été codé en **ReactJS** une librairie Javascript permettant de créer des interfaces utilisateur. ReactJS étant lui aussi basé sur Javascript, cela permet une maintenabilité plus simple car les deux parties sont dans le même langages, de plus le site web bénéficie lui aussi de l’implémentation de l’asynchrone. J’ai choisi d’utiliser ReactJS car il permet l’utilisation de **components** permettant le live reload et évite la duplication de code inutile.
### La base de données
#### Database Setup La base de données est en **PostgreSQL**, un langage basé sur **SQL** largement utilisé pour communiquer avec une base de données. Cependant PostgreSQL possède quelques avantages par rapport à une base de données comme **MySQL**, il intègre une très bonne gestion du **JSON** que j’ai beaucoup utilisé dans ce projet et il est **Open Source**.
- Install PostgreSQL
- Create database and user
- Run migrations (if available)
</details>
## ⚙️ Configuration ## Le serveur
### Environment Variables ### Les dépendances
| Variable | Description | Default | Required | Le serveur NodeJS sert de plateforme entre le site web et la base de données, il doit donc pouvoir recevoir des requêtes HTTP et envoyer des requêtes SQL. Pour les requêtes HTTP j’ai choisis le framework **ExpressJS** car il est très connu et a donc beaucoup de contenu disponible sur internet. Pour les requêtes SQL j'ai utilisé la librairie **pg** qui permet de communiquer avec PostgreSQL, cette librairie peux gérer les fermetures de connexion inutile comme les time out ou les oublies. 
|----------|-------------|---------|----------|
| `POSTGRES_USER` | Database username | - | ✅ |
| `POSTGRES_PASSWORD` | Database password | - | ✅ |
| `POSTGRES_DB` | Database name | - | ✅ |
| `POSTGRES_HOST` | Database host | db | ✅ |
| `BACKEND_PORT` | Backend server port | 8000 | ✅ |
| `JWT_SECRET` | JWT signing secret | - | ✅ |
| `LOG_FILE` | Log file path | /var/log/freetube/access.log | ❌ |
### Docker Volumes Puisque l’API transmet des données sensibles j’ai dû sécuriser les endpoints en vérifiant les données entrantes, pour cela j’ai utilisé **express-validator** qui permet de créer des **middlewares** pour vérifier les données. Pour les images et les fichiers vidéo j’ai utilisé **multer** qui s’intègre très bien avec ExpressJS. Pour l’authentification par **Github** j’ai choisi de passé par **PassportJS** qui s’occupe du passage de token entre GitHub et le backend. 
The application uses the following volumes: Pour la gestion de l’authentification j’ai utilisé **Json Web Token** qui permet de générer et de vérifier des tokens d’authentifications. Pour l’encryption des mots de passe **Bcrypt** a été utilisé.
- `./backend/logs:/var/log/freetube` - Application logs
- `./backend/app/uploads:/app/app/uploads` - Uploaded files (videos, images)
- Database data volume for PostgreSQL persistence
## 📖 Usage ### Le fonctionnement
### Getting Started Chaque endpoints et diviser en trois parties distinctes, la définition de la route, qui va définir l’URL à appeler, les middlewares qui vont effectuer les vérifications et modifications des données avant leur utilisation, et les controllers qui vont faire les actions (appels de base données, déplacement de fichier...). Des diagrammes UML expliquant les routes unes-à-unes est disponible dans le dossier “Diagramme_UML”.
1. **Create an Account** Chaque endpoints qui doivent être protégées par l’utilisation d’un compte utilisent le middleware “auth.middleware.js” pour vérifier la validité d’un token.
- Navigate to http://localhost
- Click "Créer un compte"
- Fill in your details and optionally upload a profile picture
- Submit the form to register and automatically log in
2. **Login** ## Le site web
- Click "Se connecter" on the homepage
- Enter your username and password
- You'll be redirected to the authenticated homepage
3. **Upload Videos** ### Les dépendances
- Use the API endpoints to upload videos (see API documentation)
- Videos are stored in the uploads directory
4. **Browse Content** Le site web ne doit faire aucun calcul, tout passe donc par des requête HTTP, j'ai donc utilisé la librairie **fetch** intégré à NodeJS dans sa version 22. 
- View recommendations on the homepage
- Search for videos using the search bar
- Browse trending videos and top creators
### Authentication Flow Pour l’hébergement j’ai choisi **NGINX** car il permet de d’héberger un site et de faire des redirections, il m’a permis de rediriger les requêtes vers l’API en passant par la route “/api/” ce qui évite d’exposer des ports inutilement. 
The authentication system works as follows: NGINX permet aussi de mettre en place l’**HTTPS** avec des **certificats SSL** ce qui chiffre les requêtes du site et de l'API. 
1. User registers/logs in through the frontend forms
2. Backend validates credentials and returns JWT token
3. Token is stored in localStorage for persistence
4. Protected routes check authentication status
5. Navbar updates to show user profile and logout option
## 📚 API Documentation Le site fonctionnant avec ReactJS nécessite l’utilisation de **Vite** pour le développement et le déploiement. 
### Authentication Endpoints Freetube est un site multi-page et doit utiliser un système de routage. Pour cela j’ai utilisé **React Router 7** car il est très utilisé et donc très bien documenté.
#### Register User Pour la partie style du site web, j'ai utilisé TailwindCSS dans sa version 4.0. Tailwind permet de créer des classes CSS directement depuis le JSX et prend en charge le responsive grâce a des **breakpoints**. Il est notamment plus léger que ses concurrents car il créer ses classes CSS au moment du build contrairement, par exemple, a Bootstrap qui à besoin d'un fichier contenant toue les classes CSS de la librairie pour fonctionner.
```http
POST /api/users/
Content-Type: multipart/form-data
email: user@example.com ### Le fonctionnement
username: johndoe
password: securepassword Les éléments du site sont divisés en plusieurs parties, les pages sont dans le dossier “/src/pages” et servent à accueillir et à mettre en forme les composants et à appeler les services.  
picture: [file upload]
Les composants dans le dossier “/src/components” servent à diviser le code et à éviter la duplication, un composant peut être appelé plusieurs fois sur plusieurs pages différentes. Les composants ne font pas d'appel aux services, les événements liés aux composants sont passer en paramètre de ces dernier. 
Les modales sont dans le dossier “/src/modals” et sont toujours afficher au-dessus de la vue principale. Comme les composants elles ne fonts aucun appels aux services, les événements liés aux modales sont passer en paramètre de ces derniers. Elles sont toujours appelées en fin de fichier. 
Les services présent dans le dossier “/src/services” sont les seuls fichiers faisant appel à l’API (à l’exception du fichier AuthContext.jsx). Les services sont organisés de la même manière que les endpoints. Un service peut être appelé plusieurs fois dans plusieurs pages. 
Les routes utilisées par React Router sont présente dans le fichier “/src/routes/route.jsx”. Les routes ayant besoin d’un compte sont protégées par “ProtectedRoute” et rédigeront automatiquement à la page de connexion. React Router n’étant pas directement compatible avec le système de NGINX une configuration supplémentaire est nécessaire, elle est détaillée dans le fichier “/nginx/default.conf”.
## La base de données
La structure de la base de données est créée automatiquement par le serveur au lancement, chaque modification effectuée doit être modifié dans le fichier `/backend/src/utils/database.js` dans la fonction `initDb()`.  
La base de données étant relationnelle, elle repose sur beaucoup de clé étrangère détaillés dans le schéma fourni. A savoir que les enfants se détruisent automatiquement si le lien parent est supprimé grâce à la condition “ON CASCADE” présente dans chacun des liens.  
Le port de la base de données (5432 par defaut) ne doit jamais être exposé sans pare-feu, seul le serveur doit y avoir accès. Pour cela PostgreSQL propose deux fichiers de configuration. `pg_hba` créer des règles internes en fonction de l’utilisateur, la base cible et l’IP du client et `postgres.conf` qui permet de définir un schéma d’IP autorisé. A savoir que si le projet et lancé via Docker seul localhost peut avoir accès à cette base de données.
## Installation et lancement
**Ces instructions sont prévues pour un serveur tournant sous Ubuntu 24.04/Debian 12.** Par conséquent certaines commandes peuvent être incompatible avec votre système, cependant cotre système d'exploitation fournis des commandes alternatives.
Freetube peut être installé de trois manière différentes :
- Docker Compose
- Script Shell
- Manuellement
### Installation avec Docker Compose
#### Installer Docker et Docker Compose
De part la [documentation officielle de Docker](https://docs.docker.com/engine/install/ubuntu/)
```bash
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
``` ```
#### Login User #### Paramétrages NGINX
```http Quelque modification doivent être faites pour le fonctionnement de NGINX
POST /api/users/login ```nginx
Content-Type: application/json server {
#------------------------------ ici -------------------------------
server_name <url du serveur>;
#------------------------------------------------------------------
listen 80;
return 301 https://$host$request_uri;
}
server {
#------------------------------ ici -------------------------------
server_name <url du serveur>;
#------------------------------------------------------------------
listen 443 ssl;
root /usr/share/nginx/html;
index index.html index.htm;
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
proxy_pass http://resit_backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
proxy_buffering off;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files $uri $uri/ /index.html;
}
{
"username": "johndoe",
"password": "securepassword"
} }
``` ```
### Media Endpoints #### Création de clé d'API Gmail
Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte
Dans la barre de recherche tapez `Mot de passe des applications`
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements
#### Création Application OAuth Github
Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte
En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth`
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements
#### Get Profile Picture #### Mise en place des variables d'environnements
```http
GET /api/media/profile/{filename} A la racine du projet créer un fichier `.env`
```bash
touch .env
``` ```
#### Get Video Thumbnail A l'aide de l'éditeur de votre choix entrez dans le fichier
```http ```
GET /api/media/thumbnail/{filename} nano .env
``` ```
### Additional Endpoints Rentrez les informations dans ce format
/!\ les valeurs non-entourées de chevrons **ne doivent pas être modifié**.
```env
POSTGRES_USER=<utilisateur_de_la_base>
POSTGRES_PASSWORD=<mot_de_passe>
POSTGRES_DB=<nom_de_la_base>
POSTGRES_HOST=db
- **Videos**: `/api/videos/` BACKEND_PORT=8000
- **Comments**: `/api/comments/`
- **Channels**: `/api/channels/`
- **Playlists**: `/api/playlists/`
- **Recommendations**: `/api/recommendations/`
For detailed API documentation, check the `.http` files in the `backend/requests/` directory. JWT_SECRET=<votre_clé_JWT>
## 📁 Project Structure LOG_FILE=/var/log/freetube/access.log
``` GMAIL_USER=<adresse e-mail>
3RESIT_DOCKER/ GMAIL_PASSWORD=<mot_de_passe_créer_precedemment>
├── backend/ # Node.js Express backend
│ ├── app/ FRONTEND_URL=<URL_HTTPS_de_votre_nginx>
│ │ ├── controllers/ # Request handlers
│ │ ├── middlewares/ # Express middlewares
│ │ ├── routes/ # API route definitions
│ │ ├── uploads/ # File storage
│ │ └── utils/ # Utility functions
│ ├── logs/ # Application logs
│ ├── requests/ # HTTP request examples
│ └── test/ # Test files
├── frontend/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable React components
│ │ ├── contexts/ # React Context providers
│ │ ├── pages/ # Page components
│ │ ├── routes/ # Route configuration
│ │ └── assets/ # Static assets
│ └── public/ # Public assets
├── nginx/ # Nginx configuration
└── docker-compose.yaml # Docker orchestration
```
## 🔧 Development GITHUB_ID=<ID_github>
### Available Scripts GITHUB_SECRET=<secret_github>
#### Backend
#### Lancement
Pour lancer le groupe de conteneur
```bash ```bash
npm run dev # Start development server with hot reload docker compose up -d # pour détacher de la session
npm run start # Start production server
npm run test # Run tests
``` ```
#### Frontend ### Installation via le Script Shell
#### Ajout des autorisations
Pour ajouter les autorisations nécessaire au lancement du script
```bash ```bash
npm run dev # Start development server chmod +x ./deploy.sh
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
``` ```
#### Docker Commands #### Création de clé d'API Gmail
```bash
# Start all services
docker-compose up --build
# Stop all services Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte
docker-compose down Dans la barre de recherche tapez `Mot de passe des applications`
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements
# View logs #### Création Application OAuth Github
docker-compose logs [service-name]
# Restart specific service Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte
docker-compose restart [service-name] En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth`
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements
# Reset database #### Lancer l'installation
docker-compose down --volumes
Lancer le script et répondez au question
```bash
./deploy.sh
```
Lancer le projet
```bash
cd backend && npm run start
cd frontend && npx vite build
systemctl enable --now nginx # Pour un démarrage automatique au lancement de la machine
systemctl enable --now postgresql
``` ```
### Development Workflow ### Installation manuelle
1. **Backend Changes**: Automatically reload with nodemon #### Création de clé d'API Gmail
2. **Frontend Changes**: Hot module replacement with Vite
3. **Database Changes**: Restart containers to apply schema changes
4. **Nginx Changes**: Restart nginx service
### File Upload Testing Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte
Dans la barre de recherche tapez `Mot de passe des applications`
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements
Use the provided `.http` files in `backend/requests/` to test API endpoints: #### Création Application OAuth Github
- `user.http` - User registration and authentication
- `video.http` - Video management
- `medias.http` - Media file serving
- `comment.http` - Comment system
## 🧪 Testing Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte
En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth`
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements
### Running Tests #### Mise en place des variables d'environnements
A la racine du projet créer un fichier `.env`
```bash ```bash
# Backend tests touch .env
cd backend ```
npm test
# Frontend tests (if configured) A l'aide de l'éditeur de votre choix entrez dans le fichier
cd frontend
npm test
``` ```
nano .env
```
Rentrez les informations dans ce format
/!\ les valeurs non-entourées de chevrons **ne doivent pas être modifié**.
```env
POSTGRES_USER=<utilisateur_de_la_base>
POSTGRES_PASSWORD=<mot_de_passe>
POSTGRES_DB=<nom_de_la_base>
POSTGRES_HOST=db
### Test Structure BACKEND_PORT=8000
- **Unit Tests**: Individual component/function testing JWT_SECRET=<votre_clé_JWT>
- **Integration Tests**: API endpoint testing
- **E2E Tests**: Full application workflow testing
Current test coverage includes: LOG_FILE=/var/log/freetube/access.log
- User authentication
- Video management
- Comment system
- Channel operations
- Playlist functionality
## 🔍 Troubleshooting GMAIL_USER=<adresse e-mail>
GMAIL_PASSWORD=<mot_de_passe_créer_precedemment>
### Common Issues FRONTEND_URL=<URL_HTTPS_de_votre_nginx>
#### Authentication Problems GITHUB_ID=<ID_github>
- **Blank screen on reload**: Check browser console for context errors
- **Login not persisting**: Verify JWT token in localStorage
- **Registration fails**: Check file upload size limits
#### Media File Issues GITHUB_SECRET=<secret_github>
- **404 on images**: Verify nginx proxy configuration ```
- **Upload fails**: Check file permissions and upload directory
#### Docker Issues #### Installation des paquets
- **Containers won't start**: Check port conflicts
- **Database connection fails**: Verify environment variables
- **Build failures**: Clear Docker cache with `docker system prune`
### Debug Commands Pour installer PostgreSQL/NGINX
```bash
apt install nginx postgresql
```
Pour installer NodeJS de part la [documentation officielle](https://nodejs.org/en/download/)
```bash ```bash
# Check container status # Download and install nvm:
docker-compose ps curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# View service logs # in lieu of restarting the shell
docker-compose logs -f [service-name] \. "$HOME/.nvm/nvm.sh"
# Access container shell # Download and install Node.js:
docker-compose exec [service-name] /bin/bash nvm install 22
# Reset everything # Verify the Node.js version:
docker-compose down --volumes --rmi all node -v # Should print "v22.19.0".
docker system prune -a nvm current # Should print "v22.19.0".
# Verify npm version:
npm -v # Should print "10.9.3".
``` ```
### Performance Optimization #### Installations des dépendances NodeJS
- Enable nginx caching for static assets Pour le serveur
- Implement image optimization for uploads ```bash
- Use CDN for media file delivery cd backend && npm i --production
- Database query optimization ```
- Frontend code splitting
## 🤝 Contributing Pour le site web
```bash
cd frontend && npm i --production
npx vite build # pour la construction du site
```
1. Fork the repository #### Configuration de NGINX
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Code Style Dans `/etc/nginx/conf.d/` ajouter le fichier `freetube.conf` avec cette configuration
```nginx
server {
server_name <url du serveur>;
listen 80;
return 301 https://$host$request_uri;
}
- **Backend**: ESLint with Node.js rules server {
- **Frontend**: ESLint with React rules server_name <url du serveur>;
- **Formatting**: Prettier for consistent code style listen 443 ssl;
- **Commits**: Conventional commit messages
root /usr/share/nginx/html;
index index.html index.htm;
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
proxy_pass http://resit_backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
proxy_buffering off;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files $uri $uri/ /index.html;
}
## 📜 License }
```
This project is part of an educational assignment. All rights reserved. #### Création de la base de données
Pour créer l'utilisateur PostgreSQL
```postgresql
CREATE ROLE "<nom_utilisateur>" WITH PASSWORD "<mot_de_passe>";
```
Pour créer la base
```postgresql
CREATE DATABASE "<nom_de_la_base>" OWNER "<nom_utilisateur";
```
## 👥 Authors #### Créer le fichier de log
Pour créer le fichier de log
```bash
mkdir -p /var/log/freetube/
touch /var/log/freetube/access.log
```
- **Developer**: [Your Name] #### Activer les services
- **Institution**: [Institution Name] Pour activer et lancer les services
- **Course**: 3 RESIT - Web Development ```bash
systemctl enable --now postgresql
systemctl enable --now nginx
```
--- #### Lancement
Pour lancer Freetube
```bash
cd backend
npm run start
```
## 📞 Support ## Conclusion
For support and questions: ## Documentations externes
- Create an issue in the repository
- Check the troubleshooting section
- Review the API documentation
**Happy coding! 🚀** [NodeJS](https://nodejs.org/docs/latest/api/)
[ReactJS](https://react.dev/)
[Vite](https://vite.dev/guide/)
[ExpressJS](https://expressjs.com/en/guide/routing.html)
[NGINX](https://nginx.org/en/docs/)
[PostgreSQL](https://www.postgresql.org/docs/)
[Multer](https://www.npmjs.com/package/multer)
[TailwindCSS v4.0](https://tailwindcss.com/docs/installation/using-vite)
[PassportJS](https://www.passportjs.org/docs/)
[Swagger](https://swagger.io/docs/)

18
backend/Dockerfile

@ -1,22 +1,14 @@
FROM node:20-alpine FROM node:22-alpine
# Set the working directory
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./ COPY package*.json ./
# Install dependencies
RUN npm install --production RUN npm install --production
# Copy the rest of the application code
COPY . .
# Expose the port the app runs on
EXPOSE 8000
# Install netcat for health checks COPY . .
RUN apk add --no-cache netcat-openbsd
# Install the cli tools EXPOSE 8000
RUN chmod +x ./freetube.sh
RUN cp ./freetube.sh /usr/local/bin/freetube
# Start the application # Start the application
CMD ["npm", "start"] CMD ["npm", "start"]

98
backend/app/controllers/channel.controller.js

@ -18,6 +18,7 @@ export async function create(req, res) {
logger.action("try to create new channel with owner " + channel.owner + " and name " + channel.name); logger.action("try to create new channel with owner " + channel.owner + " and name " + channel.name);
logger.write("Successfully created new channel with name " + channel.name, 200); logger.write("Successfully created new channel with name " + channel.name, 200);
client.release();
res.status(200).json(channel); res.status(200).json(channel);
} }
@ -27,10 +28,67 @@ export async function getById(req, res) {
const logger = req.body.logger; const logger = req.body.logger;
logger.action("try to get channel with id " + id); logger.action("try to get channel with id " + id);
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM channels WHERE id = $1`;
const query = `
SELECT channels.*, u.username, u.picture, COUNT(s.id) as subscriptions
FROM channels
JOIN public.users u ON channels.owner = u.id
LEFT JOIN public.subscriptions s ON channels.id = s.channel
WHERE channels.id = $1
GROUP BY channels.id, name, description, channels.owner, u.username, u.picture
`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
const videoQuery = `
SELECT
videos.id,
videos.title,
videos.description AS video_description,
videos.thumbnail,
videos.channel,
videos.visibility,
videos.file,
videos.slug,
videos.format,
videos.release_date,
channels.name AS name,
channels.description AS description,
users.picture AS profilePicture,
COUNT(h.id) AS views,
COUNT(likes.id) AS likes,
COUNT(c.id) AS comments
FROM public.videos
LEFT JOIN public.channels ON videos.channel = channels.id
LEFT JOIN public.users ON channels.OWNER = users.id
LEFT JOIN public.history h ON h.video = videos.id
LEFT JOIN public.likes ON likes.video = videos.id
LEFT JOIN public.comments c ON c.video = videos.id
WHERE videos.channel = $1 AND videos.visibility = 'public'
GROUP BY videos.id, channels.name, channels.description, users.username, users.picture
`;
const videoResult = await client.query(videoQuery, [id]);
const videoReturn = [];
for (const video of videoResult.rows) {
video.creator = {
name: video.name,
profilePicture: video.profilepicture,
description: video.video_description
};
delete video.name;
delete video.profilepicture;
delete video.video_description;
videoReturn.push(video);
}
result.rows[0].videos = videoReturn;
logger.write("Successfully get channel with id " + id, 200); logger.write("Successfully get channel with id " + id, 200);
res.status(200).json(result); client.release();
res.status(200).json(result.rows[0]);
} }
@ -65,6 +123,7 @@ export async function getAll(req, res) {
}) })
logger.write("Successfully get all channels", 200); logger.write("Successfully get all channels", 200);
client.release();
res.status(200).json(result); res.status(200).json(result);
} }
@ -88,6 +147,7 @@ export async function update(req, res) {
const nameResult = await client.query(nameQuery, [channel.name]); const nameResult = await client.query(nameQuery, [channel.name]);
if (nameResult.rows.length > 0) { if (nameResult.rows.length > 0) {
logger.write("failed to update channel because name already taken", 400); logger.write("failed to update channel because name already taken", 400);
client.release();
res.status(400).json({error: 'Name already used'}); res.status(400).json({error: 'Name already used'});
return return
} }
@ -96,6 +156,7 @@ export async function update(req, res) {
const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`; const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`;
await client.query(updateQuery, [channel.name, channel.description, id]); await client.query(updateQuery, [channel.name, channel.description, id]);
logger.write("Successfully updated channel", 200); logger.write("Successfully updated channel", 200);
client.release();
res.status(200).json(channel); res.status(200).json(channel);
} }
@ -108,6 +169,7 @@ export async function del(req, res) {
const query = `DELETE FROM channels WHERE id = $1`; const query = `DELETE FROM channels WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("Successfully deleted channel", 200); logger.write("Successfully deleted channel", 200);
client.release();
res.status(200).json({message: 'Successfully deleted'}); res.status(200).json({message: 'Successfully deleted'});
} }
@ -132,6 +194,7 @@ export async function toggleSubscription(req, res) {
const remainingSubscriptions = countResult.rows[0].count; const remainingSubscriptions = countResult.rows[0].count;
logger.write("Successfully unsubscribed from channel", 200); logger.write("Successfully unsubscribed from channel", 200);
client.release();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions}); res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else { } else {
// Subscribe // Subscribe
@ -144,6 +207,37 @@ export async function toggleSubscription(req, res) {
const totalSubscriptions = countResult.rows[0].count; const totalSubscriptions = countResult.rows[0].count;
logger.write("Successfully subscribed to channel", 200); logger.write("Successfully subscribed to channel", 200);
client.release();
res.status(200).json({message: 'Subscribed successfully', subscriptions: totalSubscriptions}); res.status(200).json({message: 'Subscribed successfully', subscriptions: totalSubscriptions});
} }
} }
export async function getStats(req, res) {
try {
const id = req.params.id;
const logger = req.body.logger;
logger.action("try to get stats");
const request = `
SELECT
(SELECT COUNT(*) FROM subscriptions WHERE channel = $1) as subscribers,
(SELECT COUNT(*) FROM history h JOIN videos v ON h.video = v.id WHERE v.channel = $1) as views
FROM channels
LEFT JOIN public.subscriptions s on channels.id = s.channel
LEFT JOIN public.videos v on channels.id = v.channel
LEFT JOIN public.history h on v.id = h.video
WHERE channels.id = $1
`;
const client = await getClient();
const result = await client.query(request, [id]);
logger.write("Successfully get stats", 200);
client.release();
res.status(200).json(result.rows[0]);
} catch (error) {
console.log(error);
res.status(500).json({error: error.message});
}
}

6
backend/app/controllers/comment.controller.js

@ -39,7 +39,7 @@ export async function upload(req, res) {
createdAt: createdAt createdAt: createdAt
} }
client.release();
res.status(200).json(responseComment); res.status(200).json(responseComment);
} }
@ -52,6 +52,7 @@ export async function getByVideo(req, res) {
const query = `SELECT * FROM comments WHERE video = $1`; const query = `SELECT * FROM comments WHERE video = $1`;
const result = await client.query(query, [videoId]); const result = await client.query(query, [videoId]);
logger.write("successfully get comment", 200); logger.write("successfully get comment", 200);
client.release()
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
@ -63,6 +64,7 @@ export async function getById(req, res) {
const query = `SELECT * FROM comments WHERE id = $1`; const query = `SELECT * FROM comments WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully get comment", 200); logger.write("successfully get comment", 200);
client.release();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }
@ -74,6 +76,7 @@ export async function update(req, res) {
const query = `UPDATE comments SET content = $1 WHERE id = $2`; const query = `UPDATE comments SET content = $1 WHERE id = $2`;
const result = await client.query(query, [req.body.content, id]); const result = await client.query(query, [req.body.content, id]);
logger.write("successfully update comment", 200); logger.write("successfully update comment", 200);
client.release();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }
@ -85,5 +88,6 @@ export async function del(req, res) {
const query = `DELETE FROM comments WHERE id = $1`; const query = `DELETE FROM comments WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully deleted comment", 200); logger.write("successfully deleted comment", 200);
client.release();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }

159
backend/app/controllers/oauth.controller.js

@ -0,0 +1,159 @@
import passport from "passport";
import { Strategy as GitHubStrategy } from "passport-github2";
import { getClient } from "../utils/database.js";
import jwt from "jsonwebtoken";
export async function login(req, res) {
passport.authenticate("github", { scope: ["user:email"] })(req, res);
}
export async function callback(req, res, next) {
passport.authenticate("github", { failureRedirect: "https://localhost/login?error=oauth_failed" }, async (err, user) => {
if (err) {
console.error("OAuth error:", err);
return res.redirect("https://localhost/login?error=oauth_error");
}
if (!user) {
return res.redirect("https://localhost/login?error=oauth_cancelled");
}
try {
// Extract user information from GitHub profile
const githubId = user.id;
const username = user.username || user.login;
const email = user.emails[0].value || null;
const avatarUrl = user.photos && user.photos[0] ? user.photos[0].value : null;
const displayName = user.displayName || username;
console.log(user);
console.log("GitHub user info:", {
githubId,
username,
email,
displayName,
avatarUrl
});
const client = await getClient();
// Check if user already exists by GitHub ID
let existingUserQuery = `SELECT * FROM users WHERE github_id = $1`;
let existingUserResult = await client.query(existingUserQuery, [githubId]);
let dbUser;
if (existingUserResult.rows.length > 0) {
// User exists, update their information
dbUser = existingUserResult.rows[0];
const updateQuery = `UPDATE users SET
username = $1,
email = $2,
picture = $3,
is_verified = true,
updated_at = NOW()
WHERE github_id = $4
RETURNING id, username, email, picture`;
const updateResult = await client.query(updateQuery, [
username,
email,
avatarUrl || "/api/media/profile/default.png",
githubId
]);
dbUser = updateResult.rows[0];
} else {
// Check if username already exists
const usernameCheck = await client.query(`SELECT id FROM users WHERE username = $1`, [username]);
let finalUsername = username;
if (usernameCheck.rows.length > 0) {
// Username exists, append GitHub ID to make it unique
finalUsername = `${username}_gh${githubId}`;
}
// Create new user
const insertQuery = `INSERT INTO users (
username,
email,
picture,
github_id,
password,
is_verified
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, username, email, picture`;
const insertResult = await client.query(insertQuery, [
finalUsername,
email,
avatarUrl || "/api/media/profile/default.png",
githubId,
null, // No password for OAuth users
true // OAuth users are automatically verified
]);
dbUser = insertResult.rows[0];
// Create default playlist for new user
const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`;
await client.query(playlistQuery, [dbUser.id]);
}
client.release();
// Generate JWT token
const payload = {
id: dbUser.id,
username: dbUser.username,
};
const token = jwt.sign(payload, process.env.JWT_SECRET);
// Redirect to frontend with token and user info
const userData = encodeURIComponent(JSON.stringify({
id: dbUser.id,
username: dbUser.username,
email: dbUser.email,
picture: dbUser.picture
}));
res.redirect(`https://localhost/login/success?token=${token}&user=${userData}`);
} catch (error) {
console.error("Error processing GitHub OAuth callback:", error);
res.redirect("https://localhost/login?error=processing_error");
}
})(req, res, next);
}
export async function getUserInfo(req, res) {
try {
// This endpoint can be used to get current user info from token
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "No token provided" });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const client = await getClient();
const query = `SELECT id, username, email, picture, github_id, is_verified FROM users WHERE id = $1`;
const result = await client.query(query, [decoded.id]);
if (!result.rows[0]) {
client.release();
return res.status(404).json({ error: "User not found" });
}
client.release();
res.status(200).json({ user: result.rows[0] });
} catch (error) {
console.error("Error getting user info:", error);
res.status(500).json({ error: "Internal server error" });
}
}

151
backend/app/controllers/playlist.controller.js

@ -14,9 +14,11 @@ export async function create(req, res) {
try { try {
const result = await client.query(query, [name, userId]); const result = await client.query(query, [name, userId]);
logger.write("Playlist created with id " + result.rows[0].id, 200); logger.write("Playlist created with id " + result.rows[0].id, 200);
client.release()
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error creating playlist: " + error.message, 500); logger.write("Error creating playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -31,9 +33,11 @@ export async function addVideo(req, res) {
try { try {
const result = await client.query(query, [video, id]); const result = await client.query(query, [video, id]);
logger.write("Video added to playlist with id " + id, 200); logger.write("Video added to playlist with id " + id, 200);
client.release();
res.status(200).json({id: result.rows[0].id}); res.status(200).json({id: result.rows[0].id});
} catch (error) { } catch (error) {
logger.write("Error adding video to playlist: " + error.message, 500); logger.write("Error adding video to playlist: " + error.message, 500);
client.release();
res.status(500).json({error: "Internal server error"}); res.status(500).json({error: "Internal server error"});
} }
} }
@ -60,13 +64,16 @@ export async function getByUser(req, res) {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlists found for user with id " + id, 404); logger.write("No playlists found for user with id " + id, 404);
client.release();
res.status(404).json({ error: "No playlists found" }); res.status(404).json({ error: "No playlists found" });
return; return;
} }
logger.write("Playlists retrieved for user with id " + id, 200); logger.write("Playlists retrieved for user with id " + id, 200);
client.release();
res.status(200).json(result.rows); res.status(200).json(result.rows);
} catch (error) { } catch (error) {
logger.write("Error retrieving playlists: " + error.message, 500); logger.write("Error retrieving playlists: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -76,19 +83,78 @@ export async function getById(req, res) {
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM playlists WHERE id = $1`; const query = `
SELECT
playlists.id,
playlists.name,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'id', video_data.id,
'title', video_data.title,
'thumbnail', video_data.thumbnail,
'video_description', video_data.description,
'channel', video_data.channel,
'visibility', video_data.visibility,
'file', video_data.file,
'slug', video_data.slug,
'format', video_data.format,
'release_date', video_data.release_date,
'channel_id', video_data.channel,
'owner', channels.owner,
'views', CAST(video_data.views AS TEXT),
'creator', JSON_BUILD_OBJECT(
'name', channels.name,
'profilePicture', users.picture,
'description', channels.description
),
'type', 'video'
)
) FILTER (WHERE video_data.id IS NOT NULL),
'[]'::json
) AS videos
FROM
playlists
LEFT JOIN playlist_elements ON playlists.id = playlist_elements.playlist
LEFT JOIN (
SELECT
videos.id,
videos.title,
videos.description,
videos.thumbnail,
videos.release_date,
videos.visibility,
videos.file,
videos.slug,
videos.format,
videos.channel,
COUNT(history.id) AS views
FROM videos
LEFT JOIN history ON history.video = videos.id
GROUP BY videos.id, videos.title, videos.description, videos.thumbnail, videos.release_date, videos.visibility, videos.file, videos.slug, videos.format, videos.channel
) video_data ON playlist_elements.video = video_data.id
LEFT JOIN channels ON video_data.channel = channels.id
LEFT JOIN users ON channels.owner = users.id
WHERE
playlists.id = $1
GROUP BY
playlists.id;
`;
try { try {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404); logger.write("No playlist found with id " + id, 404);
client.release();
res.status(404).json({ error: "Playlist not found" }); res.status(404).json({ error: "Playlist not found" });
return; return;
} }
logger.write("Playlist retrieved with id " + id, 200); logger.write("Playlist retrieved with id " + id, 200);
client.release();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} catch (error) { } catch (error) {
logger.write("Error retrieving playlist: " + error.message, 500); logger.write("Error retrieving playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -105,13 +171,16 @@ export async function update(req, res) {
const result = await client.query(query, [name, id]); const result = await client.query(query, [name, id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404); logger.write("No playlist found with id " + id, 404);
client.release();
res.status(404).json({ error: "Playlist not found", result: result.rows, query: query }); res.status(404).json({ error: "Playlist not found", result: result.rows, query: query });
return; return;
} }
logger.write("Playlist updated with id " + result.rows[0].id, 200); logger.write("Playlist updated with id " + result.rows[0].id, 200);
client.release();
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error updating playlist: " + error.message, 500); logger.write("Error updating playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -127,13 +196,16 @@ export async function deleteVideo(req, res) {
const result = await client.query(query, [videoId, id]); const result = await client.query(query, [videoId, id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No video found in playlist with id " + id, 404); logger.write("No video found in playlist with id " + id, 404);
client.release();
res.status(404).json({ error: "Video not found in playlist" }); res.status(404).json({ error: "Video not found in playlist" });
return; return;
} }
logger.write("Video deleted from playlist with id " + id, 200); logger.write("Video deleted from playlist with id " + id, 200);
client.release();
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error deleting video from playlist: " + error.message, 500); logger.write("Error deleting video from playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -148,9 +220,86 @@ export async function del(req, res) {
try { try {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("Playlist deleted", 200); logger.write("Playlist deleted", 200);
client.release()
res.status(200).json({ "message": "playlist deleted" }); res.status(200).json({ "message": "playlist deleted" });
} catch (error) { } catch (error) {
logger.write("Error deleting playlist: " + error.message, 500); logger.write("Error deleting playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" });
}
}
export async function getSeeLater(req, res) {
const token = req.headers.authorization.split(' ')[1];
const userId = jwt.decode(token)["id"];
const logger = req.body.logger;
const client = await getClient();
const query = `
SELECT
COALESCE(
JSON_AGG(
json_build_object(
'video_id', videos.id,
'title', videos.title,
'thumbnail', videos.thumbnail,
'video_decscription', videos.description,
'channel', videos.channel,
'visibility', videos.visibility,
'file', videos.file,
'format', videos.format,
'release_date', videos.release_date,
'channel_id', channels.id,
'owner', channels.owner,
'views', COALESCE(video_views.view_count, 0),
'creator', json_build_object(
'name', channels.name,
'profilePicture', users.picture,
'description', channels.description
)
)
) FILTER (WHERE videos.id IS NOT NULL),
'[]'::json
) AS videos
FROM
public.playlists
LEFT JOIN public.playlist_elements ON public.playlists.id = public.playlist_elements.playlist
LEFT JOIN (
SELECT
*
FROM public.videos
LIMIT 10
) videos ON public.playlist_elements.video = videos.id
LEFT JOIN public.channels ON videos.channel = public.channels.id
LEFT JOIN public.users ON public.channels.owner = public.users.id
LEFT JOIN (
SELECT video, COUNT(*) as view_count
FROM public.history
GROUP BY video
) video_views ON videos.id = video_views.video
WHERE
playlists.owner = $1
GROUP BY playlists.id, playlists.name
ORDER BY
playlists.id ASC
LIMIT 1;
`;
try {
const result = await client.query(query, [userId]);
if (result.rows.length === 0) {
logger.write("No 'See Later' playlist found for user with id " + userId, 200);
client.release();
res.status(200).json([]);
return;
}
logger.write("'See Later' playlist retrieved for user with id " + userId, 200);
client.release();
res.status(200).json(result.rows[0].videos || []);
} catch (error) {
logger.write("Error retrieving 'See Later' playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }

293
backend/app/controllers/recommendation.controller.js

@ -1,88 +1,277 @@
import {getClient} from "../utils/database.js"; import {getClient} from "../utils/database.js";
import jwt from 'jsonwebtoken';
export async function getRecommendations(req, res) { export async function getRecommendations(req, res) {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
if (!token) { if (!req.headers.authorization || !token) {
// GET MOST USED TOKEN // GET MOST USED TAGS
let client = await getClient(); let client = await getClient();
let queryMostUsedToken = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`; let queryMostUsedTags = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`;
let result = await client.query(queryMostUsedToken); let result = await client.query(queryMostUsedTags);
// GET 10 VIDEOS WITH THE TAGS
let tagIds = result.rows.map(tag => tag.id);
let queryVideosWithTags = `
const recommendations = result.rows; SELECT
v.id,
v.title,
v.thumbnail,
v.description AS video_description,
v.channel,
v.visibility,
v.file,
v.slug,
v.release_date,
v.channel AS channel_id,
c.owner,
COUNT(h.id) AS views,
json_build_object(
'name', c.name,
'profilePicture', u.picture,
'description', c.description
) AS creator,
'video' AS type
FROM public.videos v
INNER JOIN public.video_tags vt ON v.id = vt.video
INNER JOIN public.tags t ON vt.tag = t.id
INNER JOIN public.channels c ON v.channel = c.id
INNER JOIN public.users u ON c.owner = u.id
LEFT JOIN public.history h ON h.video = v.id
WHERE t.id = ANY($1::int[])
AND v.visibility = 'public'
GROUP BY
v.id,
v.title,
v.thumbnail,
v.description,
v.channel,
v.visibility,
v.file,
v.slug,
v.release_date,
c.owner,
c.name,
u.picture,
c.description
ORDER BY views DESC, v.release_date DESC
LIMIT 10;
`;
let videoResult = await client.query(queryVideosWithTags, [tagIds]);
const recommendations = videoResult.rows;
res.status(200).json(recommendations); res.status(200).json(recommendations);
} else { } else {
// Recuperer les 20 derniere vu de l'historique
let client = await getClient(); let client = await getClient();
let queryLastVideos = `SELECT video_id FROM history WHERE user_id = $1 ORDER BY viewed_at DESC LIMIT 20;`; const claims = jwt.decode(token)
// TODO: Implement retrieval of recommendations based on user history and interactions const query = `
-- Recommandation de contenu similaire non vu basée sur les interactions utilisateur
// Recuperer les likes de l'utilisateur sur les 20 derniere videos recuperees -- Paramètre: $1 = user_id
WITH user_interactions AS (
-- Récupérer tous les contenus avec lesquels l'utilisateur a interagi
SELECT DISTINCT v.id as video_id, v.channel, t.id as tag_id, t.name as tag_name
FROM videos v
JOIN video_tags vt ON v.id = vt.video
JOIN tags t ON vt.tag = t.id
WHERE v.id IN (
-- Vidéos likées par l'utilisateur
SELECT DISTINCT l.video FROM likes l WHERE l.owner = $1
UNION
-- Vidéos commentées par l'utilisateur
SELECT DISTINCT c.video FROM comments c WHERE c.author = $1
UNION
-- Vidéos ajoutées aux playlists de l'utilisateur
SELECT DISTINCT pe.video
FROM playlist_elements pe
JOIN playlists p ON pe.playlist = p.id
WHERE p.owner = $1
)
),
user_preferred_tags AS (
-- Tags préférés basés sur les interactions
SELECT tag_id, tag_name, COUNT(*) as interaction_count
FROM user_interactions
GROUP BY tag_id, tag_name
),
user_preferred_channels AS (
-- Chaînes préférées basées sur les interactions
SELECT channel, COUNT(*) as interaction_count
FROM user_interactions
GROUP BY channel
),
unseen_videos AS (
-- Vidéos que l'utilisateur n'a jamais vues
SELECT v.id, v.title, v.thumbnail, v.description, v.channel, v.visibility,
v.file, v.slug, v.format, v.release_date, ch.owner
FROM videos v
JOIN channels ch ON v.channel = ch.id
WHERE v.visibility = 'public'
AND v.id NOT IN (
-- Exclure les vidéos déjà vues
SELECT DISTINCT h.video FROM history h WHERE h.user_id = $1
UNION
-- Exclure les vidéos déjà likées
SELECT DISTINCT l.video FROM likes l WHERE l.owner = $1
UNION
-- Exclure les vidéos déjà commentées
SELECT DISTINCT c.video FROM comments c WHERE c.author = $1
UNION
-- Exclure les vidéos déjà ajoutées aux playlists
SELECT DISTINCT pe.video
FROM playlist_elements pe
JOIN playlists p ON pe.playlist = p.id
WHERE p.owner = $1
)
)
-- Requête principale : recommander du contenu similaire
SELECT
uv.id,
uv.title,
uv.thumbnail,
uv.description as video_description,
uv.channel,
uv.visibility,
uv.file,
uv.slug,
uv.format,
uv.release_date,
uv.channel as channel_id,
uv.owner,
COALESCE(view_counts.views::text, '0') as views,
json_build_object(
'name', u.username,
'profilePicture', u.picture,
'description', ch.description
) as creator,
'video' as type
FROM unseen_videos uv
JOIN channels ch ON uv.channel = ch.id
JOIN users u ON ch.owner = u.id
-- Compter les vues
LEFT JOIN (
SELECT video, COUNT(*) as views
FROM history
GROUP BY video
) view_counts ON uv.id = view_counts.video
-- Score basé sur les tags similaires
LEFT JOIN (
SELECT
vt.video,
SUM(upt.interaction_count * 0.7) as score
FROM video_tags vt
JOIN user_preferred_tags upt ON vt.tag = upt.tag_id
GROUP BY vt.video
) tag_score ON uv.id = tag_score.video
-- Score basé sur les chaînes similaires
LEFT JOIN (
SELECT
uv2.channel,
MAX(upc.interaction_count * 0.3) as score
FROM unseen_videos uv2
JOIN user_preferred_channels upc ON uv2.channel = upc.channel
GROUP BY uv2.channel
) channel_score ON uv.channel = channel_score.channel
WHERE (tag_score.score > 0 OR channel_score.score > 0) -- Au moins une similarité
GROUP BY uv.id, uv.title, uv.thumbnail, uv.description, uv.channel, uv.visibility,
uv.file, uv.slug, uv.format, uv.release_date, uv.owner, u.username, u.picture,
ch.description, view_counts.views, tag_score.score, channel_score.score
ORDER BY (COALESCE(tag_score.score, 0) + COALESCE(channel_score.score, 0)) DESC, uv.release_date DESC
LIMIT 20;
// Recuperer les commentaires de l'utilisateur sur les 20 derniere videos recuperees `;
let result = await client.query(query, [claims.id]);
// Recuperer les 3 tags avec lesquels l'utilisateur a le plus interagi
// Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur client.release()
res.status(200).json(result.rows);
res.status(200).json({
message: "Recommendations based on user history and interactions are not yet implemented."
});
} }
} }
export async function getTrendingVideos(req, res) { export async function getTrendingVideos(req, res) {
const client = await getClient();
try { try {
// GET 10 VIDEOS WITH THE MOST LIKES AND COMMENTS // Optimized single query to get all trending video data
let client = await getClient();
let queryTrendingVideos = ` let queryTrendingVideos = `
SELECT v.id, v.title, v.description, v.release_date, v.thumbnail, SELECT
COUNT(DISTINCT l.id) AS like_count, COUNT(DISTINCT c.id) AS comment_count v.id,
v.title,
v.description,
v.release_date,
v.thumbnail,
v.visibility,
COUNT(DISTINCT l.id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count,
COUNT(DISTINCT h.id) AS views,
ch.id AS creator_id,
ch.name AS creator_name,
u.picture AS creator_profile_picture
FROM videos v FROM videos v
LEFT JOIN likes l ON v.id = l.video LEFT JOIN likes l ON v.id = l.video
LEFT JOIN comments c ON v.id = c.video LEFT JOIN comments c ON v.id = c.video
GROUP BY v.id LEFT JOIN history h ON v.id = h.video
ORDER BY like_count DESC, comment_count DESC LEFT JOIN channels ch ON v.channel = ch.id
LIMIT 10; LEFT JOIN users u ON ch.owner = u.id
`; WHERE v.visibility = 'public'
let result = await client.query(queryTrendingVideos); GROUP BY v.id, ch.id, ch.name, u.picture
const trendingVideos = result.rows; ORDER BY like_count DESC, comment_count DESC, views DESC
LIMIT 10
for (let video of trendingVideos) { `;
// Get the number of views for each video
let viewsQuery = `SELECT COUNT(*) AS view_count FROM history WHERE video = $1;`;
let viewsResult = await client.query(viewsQuery, [video.id]);
video.views = viewsResult.rows[0].view_count;
// Get the creator of each video
let creatorQuery = `SELECT c.id, c.name FROM channels c JOIN videos v ON c.id = v.channel WHERE v.id = $1;`;
let creatorResult = await client.query(creatorQuery, [video.id]);
if (creatorResult.rows.length > 0) {
video.creator = creatorResult.rows[0];
} else {
video.creator = {id: null, name: "Unknown"};
}
// GET THE PROFILE PICTURE OF THE CREATOR let result = await client.query(queryTrendingVideos);
let profilePictureQuery = `SELECT u.picture FROM users u JOIN channels c ON u.id = c.owner WHERE c.id = $1;`; const trendingVideos = result.rows.map(video => ({
let profilePictureResult = await client.query(profilePictureQuery, [video.creator.id]); id: video.id,
if (profilePictureResult.rows.length > 0) { title: video.title,
video.creator.profilePicture = profilePictureResult.rows[0].picture; description: video.description,
} else { release_date: video.release_date,
video.creator.profilePicture = null; // Default or placeholder image can be set here thumbnail: video.thumbnail,
visibility: video.visibility,
like_count: video.like_count,
comment_count: video.comment_count,
views: video.views,
creator: {
id: video.creator_id,
name: video.creator_name,
profilePicture: video.creator_profile_picture
} }
}));
}
res.status(200).json(trendingVideos); res.status(200).json(trendingVideos);
} catch (error) { } catch (error) {
console.error("Error fetching trending videos:", error); console.error("Error fetching trending videos:", error);
res.status(500).json({error: "Internal server error while fetching trending videos."}); res.status(500).json({error: "Internal server error while fetching trending videos."});
} finally {
client.release();
}
}
export async function getTopCreators(req, res) {
const client = await getClient();
try {
// GET TOP 5 CREATORS BASED ON NUMBER OF SUBSCRIBERS
let queryTopCreators = `
SELECT c.id, c.name, c.description, u.picture AS profilePicture, COUNT(s.id) AS subscriber_count
FROM channels c
JOIN users u ON c.owner = u.id
LEFT JOIN subscriptions s ON c.id = s.channel
GROUP BY c.id, u.picture
ORDER BY subscriber_count DESC
LIMIT 10;
`;
let result = await client.query(queryTopCreators);
const topCreators = result.rows;
res.status(200).json(topCreators);
} catch (error) {
console.error("Error fetching top creators:", error);
res.status(500).json({error: "Internal server error while fetching top creators."});
} finally {
client.release();
} }
} }

131
backend/app/controllers/search.controller.js

@ -5,8 +5,8 @@ export async function search(req, res) {
console.log(req.query); console.log(req.query);
const query = req.query.q; const query = req.query.q;
const type = req.query.type || 'all'; const type = req.query.type || 'all';
const offset = req.query.offset || 0; const offset = parseInt(req.query.offset) || 0;
const limit = req.query.limit || 20; const limit = parseInt(req.query.limit) || 20;
const client = await getClient(); const client = await getClient();
@ -15,71 +15,80 @@ export async function search(req, res) {
} }
if (type === 'videos') { if (type === 'videos') {
// Search video in database based on the query, video title, tags and author let videoResults = [];
const videoNameQuery = `SELECT id FROM videos WHERE title ILIKE $1 OFFSET $3 LIMIT $2`;
const videoNameResult = await client.query(videoNameQuery, [`%${query}%`, limit, offset]); // Search video in database based on the video title
const videoNameQuery = `
// Search video from tags SELECT
const tagQuery = `SELECT id FROM tags WHERE name ILIKE $1 OFFSET $3 LIMIT $2`; v.id, v.title, v.thumbnail, v.description as video_description, v.channel, v.visibility, v.file, v.slug, v.format, v.release_date,
const tagResult = await client.query(tagQuery, [`%${query}%`, limit, offset]); c.id as channel_id, c.owner, c.description as channel_description, c.name,
const tags = tagResult.rows.map(tag => tag.name); u.picture as profilePicture,
COUNT(h.id) as views
for (const tag of tags) { FROM videos as v
const videoTagQuery = `SELECT id FROM videos WHERE id IN (SELECT video FROM video_tags WHERE tag = (SELECT id FROM tags WHERE name = $1)) OFFSET $3 LIMIT $2`; JOIN public.channels c on v.channel = c.id
const videoTagResult = await client.query(videoTagQuery, [tag, limit, offset]); JOIN public.users u on c.owner = u.id
videoNameResult.rows.push(...videoTagResult.rows); LEFT JOIN public.history h on h.video = v.id
WHERE v.title ILIKE $1 AND v.visibility = 'public'
GROUP BY v.id, v.title, v.thumbnail, v.description, v.channel, v.visibility, v.file, v.slug, v.format, v.release_date,
c.id, c.owner, c.description, c.name, u.picture
OFFSET $2
LIMIT $3;
`;
const videoNameResult = await client.query(videoNameQuery, [`%${query}%`, offset, limit]);
const videoNames = videoNameResult.rows;
for (const video of videoNames) {
// Put all the creator's information in the creator sub-object
video.creator = {
name: video.name,
profilePicture: video.profilepicture,
description: video.channel_description
};
// Remove the creator's information from the video object
delete video.name;
delete video.profilepicture;
delete video.channel_description;
video.type = "video";
videoResults.push(video);
} }
// Search video from author client.release()
const authorQuery = `SELECT videos.id FROM videos JOIN channels c ON videos.channel = c.id WHERE c.name ILIKE $1`;
const authorResult = await client.query(authorQuery, [`%${query}%`]); return res.status(200).json(videoResults);
for (const author of authorResult.rows) { } else if (type === 'channel') {
if (!videoNameResult.rows.some(video => video.id === author.id)) { let channelResults = [];
videoNameResult.rows.push(author);
} // Search channel in database based on the channel name
} const channelNameQuery = `
SELECT c.id as channel_id, c.name, c.description as channel_description, c.owner, u.picture as profilePicture, COUNT(s.id) as subscribers
const videos = []; FROM public.channels c
JOIN public.users u on c.owner = u.id
for (let video of videoNameResult.rows) { LEFT JOIN public.subscriptions s ON s.channel = c.id
video = video.id; // Extracting the video ID WHERE c.name ILIKE $1
let videoDetails = {}; group by c.name, c.id, c.description, c.owner, u.picture
OFFSET $2
// Fetching video details LIMIT $3;
const videoDetailsQuery = `SELECT id, title, description, thumbnail, channel, release_date FROM videos WHERE id = $1`; `;
const videoDetailsResult = await client.query(videoDetailsQuery, [video]);
if (videoDetailsResult.rows.length === 0) { const channelNameResult = await client.query(channelNameQuery, [`%${query}%`, offset, limit]);
continue; // Skip if no video details found const channelNames = channelNameResult.rows;
}
for (const channel of channelNames) {
videoDetails = videoDetailsResult.rows[0]; channel.type = "channel";
// Setting the type channel.profilePicture = channel.profilepicture; // Rename for consistency
videoDetails.type = 'video'; delete channel.profilepicture;
channelResults.push(channel);
// Fetching views and likes
const viewsQuery = `SELECT COUNT(*) AS view_count FROM history WHERE video = $1`;
const viewsResult = await client.query(viewsQuery, [video]);
videoDetails.views = viewsResult.rows[0].view_count;
// GET CREATOR
const creatorQuery = `SELECT c.id, c.name, c.owner FROM channels c JOIN videos v ON c.id = v.channel WHERE v.id = $1`;
const creatorResult = await client.query(creatorQuery, [video]);
videoDetails.creator = creatorResult.rows[0];
// GET CREATOR PROFILE PICTURE
const profilePictureQuery = `SELECT picture FROM users WHERE id = $1`;
const profilePictureResult = await client.query(profilePictureQuery, [videoDetails.creator.owner]);
videoDetails.creator.profile_picture = profilePictureResult.rows[0].picture;
videos.push(videoDetails);
} }
client.release()
return res.status(200).json(channelResults);
return res.status(200).json(videos); } else {
return res.status(400).json({ message: "Invalid type parameter. Use 'videos' or 'channel'." });
} }

380
backend/app/controllers/user.controller.js

@ -1,9 +1,11 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {getClient} from "../utils/database.js"; import { getClient } from "../utils/database.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import path, {dirname} from "path"; import path, { dirname } from "path";
import fs from "fs"; import fs from "fs";
import {fileURLToPath} from "url"; import { fileURLToPath } from "url";
import crypto from "crypto";
import { sendEmail } from "../utils/mail.js";
export async function register(req, res) { export async function register(req, res) {
try { try {
@ -43,15 +45,128 @@ export async function register(req, res) {
const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`; const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`;
await client.query(playlistQuery, [id]); await client.query(playlistQuery, [id]);
// GENERATE EMAIL HEXA VERIFICATION TOKEN
const token = crypto.randomBytes(32).toString("hex").slice(0, 5);
const textMessage = "Merci de vous être inscrit. Veuillez vérifier votre e-mail. Code: " + token;
const htmlMessage = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur Freetube</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
🎬 Bienvenue sur Freetube!
</h1>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<h2 style="color: #333333; margin-top: 0; font-size: 24px;">
Bonjour ${user.username}! 👋
</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
Merci de vous être inscrit sur <strong>Freetube</strong>! Nous sommes ravis de vous accueillir dans notre communauté.
</p>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
Pour finaliser votre inscription, veuillez utiliser le code de vérification ci-dessous :
</p>
<!-- Verification Code Box -->
<div style="background-color: #f8f9fa; border: 2px dashed #667eea; border-radius: 8px; padding: 25px; text-align: center; margin: 30px 0;">
<p style="color: #333333; font-size: 14px; margin: 0 0 10px 0; text-transform: uppercase; letter-spacing: 1px;">
Code de vérification
</p>
<div style="background-color: #667eea; color: #ffffff; font-size: 32px; font-weight: bold; padding: 15px 25px; border-radius: 6px; letter-spacing: 3px; display: inline-block;">
${token}
</div>
</div>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 30px 0 10px 0;">
Ce code expirera dans <strong>1 heure</strong>.
</p>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 10px 0;">
Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.
</p>
</div>
<!-- Footer -->
<div style="background-color: #f8f9fa; padding: 20px 30px; border-top: 1px solid #eee;">
<p style="color: #999999; font-size: 12px; margin: 0; text-align: center;">
© 2025 Freetube. Tous droits réservés.
</p>
</div>
</div>
</body>
</html>
`;
console.log("Sending email to:", user.email);
await sendEmail(user.email, "🎬 Bienvenue sur Freetube - Vérifiez votre email", textMessage, htmlMessage);
// Store the token in the database
const expirationDate = new Date();
expirationDate.setHours(expirationDate.getHours() + 1); // Token expires in 1 hour
const insertQuery = `INSERT INTO email_verification (email, token, expires_at) VALUES ($1, $2, $3)`;
await client.query(insertQuery, [user.email, token, expirationDate]);
console.log("Successfully registered"); console.log("Successfully registered");
client.end();
logger.write("successfully registered", 200); logger.write("successfully registered", 200);
res.status(200).send({user: user}); res.status(200).send({ user: user });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
logger?.write("failed to register user", 500);
res.status(500).json({ error: "Internal server error" });
} finally {
} }
}
export async function verifyEmail(req, res) {
const { email, token } = req.body;
const logger = req.body.logger;
logger.action("try to verify email for " + email + " with token " + token);
const client = await getClient();
try {
const query = `SELECT * FROM email_verification WHERE email = $1 AND token = $2`;
const result = await client.query(query, [email, token]);
if (result.rows.length === 0) {
logger.write("failed to verify email for " + email, 404);
return res.status(404).json({ error: "Invalid token or email" });
}
// If we reach this point, the email is verified
const queryDelete = `DELETE FROM email_verification WHERE email = $1`;
await client.query(queryDelete, [email]);
const updateQuery = `UPDATE users SET is_verified = TRUE WHERE email = $1`;
await client.query(updateQuery, [email]);
logger.write("successfully verified email for " + email, 200);
res.status(200).json({ message: "Email verified successfully" });
} catch (error) {
console.error("Error verifying email:", error);
logger.write("failed to verify email for " + email, 500);
res.status(500).json({ error: "Internal server error" });
} finally {
client.release();
}
} }
export async function login(req, res) { export async function login(req, res) {
@ -65,42 +180,49 @@ export async function login(req, res) {
const client = await getClient(); const client = await getClient();
let query = `SELECT id, username, email, picture, password FROM users WHERE username = $1`; try {
const result = await client.query(query, [user.username]); let query = `SELECT id, username, email, picture, password FROM users WHERE username = $1`;
const result = await client.query(query, [user.username]);
const userInBase = result.rows[0];
if (!userInBase) { const userInBase = result.rows[0];
logger.write("failed to login", 401)
res.status(401).json({error: "Invalid credentials"});
return
}
const isPasswordValid = await bcrypt.compare(req.body.password, userInBase.password); if (!userInBase) {
logger.write("failed to login", 401)
res.status(401).json({ error: "Invalid credentials" });
return
}
if (!isPasswordValid) { const isPasswordValid = await bcrypt.compare(req.body.password, userInBase.password);
logger.write("failed to login", 401)
res.status(401).json({error: "Invalid credentials"});
return
}
const payload = { if (!isPasswordValid) {
id: userInBase.id, logger.write("failed to login", 401)
username: userInBase.username, res.status(401).json({ error: "Invalid credentials" });
} return
}
const token = jwt.sign(payload, process.env.JWT_SECRET); const payload = {
id: userInBase.id,
username: userInBase.username,
}
const userData = { const token = jwt.sign(payload, process.env.JWT_SECRET);
id: userInBase.id,
username: userInBase.username,
email: userInBase.email,
picture: userInBase.picture
}
logger.write("Successfully logged in", 200); const userData = {
res.status(200).json({token: token, user: userData}); id: userInBase.id,
username: userInBase.username,
email: userInBase.email,
picture: userInBase.picture
}
logger.write("Successfully logged in", 200);
res.status(200).json({ token: token, user: userData });
} catch (err) {
console.log(err);
logger?.write("failed to login", 500);
res.status(500).json({ error: "Internal server error" });
} finally {
client.release();
}
} }
export async function getById(req, res) { export async function getById(req, res) {
@ -108,15 +230,20 @@ export async function getById(req, res) {
const logger = req.body.logger; const logger = req.body.logger;
logger.action("try to retrieve user " + id); logger.action("try to retrieve user " + id);
const client = await getClient(); const client = await getClient();
const query = `SELECT id, email, username, picture FROM users WHERE id = $1`; const query = `SELECT id, email, username, picture
FROM users
WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve user " + id + " because it doesn't exist", 404);
res.status(404).json({error: "Not Found"}); client.release()
res.status(404).json({ error: "Not Found" });
return return
} }
logger.write("successfully retrieved user " + id, 200); logger.write("successfully retrieved user " + id, 200);
return res.status(200).json({user: result.rows[0]}); if (result.rows[0].picture) {
return res.status(200).json({ user: result.rows[0] });
}
} }
export async function getByUsername(req, res) { export async function getByUsername(req, res) {
@ -128,11 +255,13 @@ export async function getByUsername(req, res) {
const result = await client.query(query, [username]); const result = await client.query(query, [username]);
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve user " + username + " because it doesn't exist", 404); logger.write("failed to retrieve user " + username + " because it doesn't exist", 404);
res.status(404).json({error: "Not Found"}); client.release()
res.status(404).json({ error: "Not Found" });
return return
} }
logger.write("successfully retrieved user " + username, 200); logger.write("successfully retrieved user " + username, 200);
return res.status(200).json({user: result.rows[0]}); client.release();
return res.status(200).json({ user: result.rows[0] });
} }
export async function update(req, res) { export async function update(req, res) {
@ -159,7 +288,8 @@ export async function update(req, res) {
const emailResult = await client.query(emailQuery, [user.email]); const emailResult = await client.query(emailQuery, [user.email]);
if (emailResult.rows[0]) { if (emailResult.rows[0]) {
logger.write("failed to update because email is already used", 400) logger.write("failed to update because email is already used", 400)
res.status(400).json({error: "Email already exists"}); client.release();
res.status(400).json({ error: "Email already exists" });
} }
} }
@ -168,7 +298,8 @@ export async function update(req, res) {
const usernameResult = await client.query(usernameQuery, [user.username]); const usernameResult = await client.query(usernameQuery, [user.username]);
if (usernameResult.rows[0]) { if (usernameResult.rows[0]) {
logger.write("failed to update because username is already used", 400) logger.write("failed to update because username is already used", 400)
res.status(400).json({error: "Username already exists"}); client.release();
res.status(400).json({ error: "Username already exists" });
} }
} }
@ -184,13 +315,32 @@ export async function update(req, res) {
user.password = userInBase.password; user.password = userInBase.password;
} }
let __filename = fileURLToPath(import.meta.url);
let __dirname = dirname(__filename);
console.log(__dirname);
let profilePicture = userInBase.picture.split("/").pop();
fs.rename(
path.join(__dirname, "..", "uploads", "profiles", profilePicture),
path.join(__dirname, "..", "uploads", "profiles", user.username + "." + profilePicture.split(".").pop()),
(err) => {
if (err) {
logger.write("failed to update profile picture", 500);
console.error("Error renaming file:", err);
throw err;
}
});
profilePicture = "/api/media/profile/" + user.username + "." + profilePicture.split(".").pop();
const updateQuery = `UPDATE users SET email = $1, username = $2, password = $3, picture = $4 WHERE id = $5 RETURNING id, email, username, picture`; const updateQuery = `UPDATE users SET email = $1, username = $2, password = $3, picture = $4 WHERE id = $5 RETURNING id, email, username, picture`;
const result = await client.query(updateQuery, [user.email, user.username, user.password, user.picture, id]); const result = await client.query(updateQuery, [user.email, user.username, user.password, profilePicture, id]);
logger.write("successfully updated user " + id, 200); logger.write("successfully updated user " + id, 200);
res.status(200).send({user: result.rows[0]}); client.release();
res.status(200).json(result.rows[0]);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
res.status(500).json({error: err}); client.release()
res.status(500).json({ error: err });
} }
} }
@ -203,7 +353,8 @@ export async function deleteUser(req, res) {
const query = `DELETE FROM users WHERE id = $1`; const query = `DELETE FROM users WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("successfully deleted user " + id); logger.write("successfully deleted user " + id);
res.status(200).json({message: 'User deleted'}); client.release();
res.status(200).json({ message: 'User deleted' });
} }
export async function getChannel(req, res) { export async function getChannel(req, res) {
@ -217,12 +368,14 @@ export async function getChannel(req, res) {
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve channel of user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve channel of user " + id + " because it doesn't exist", 404);
res.status(404).json({error: "Channel Not Found"}); client.release();
res.status(404).json({ error: "Channel Not Found" });
return; return;
} }
logger.write("successfully retrieved channel of user " + id, 200); logger.write("successfully retrieved channel of user " + id, 200);
res.status(200).json({channel: result.rows[0]}); client.release();
res.status(200).json({ channel: result.rows[0] });
} }
export async function getHistory(req, res) { export async function getHistory(req, res) {
@ -267,10 +420,141 @@ export async function getHistory(req, res) {
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve history of user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve history of user " + id + " because it doesn't exist", 404);
res.status(404).json({error: "History Not Found"}); res.status(404).json({ error: "History Not Found" });
client.release();
return; return;
} }
logger.write("successfully retrieved history of user " + id, 200); logger.write("successfully retrieved history of user " + id, 200);
client.release();
res.status(200).json(videos); res.status(200).json(videos);
} }
export async function isSubscribed(req, res) {
const token = req.headers.authorization.split(" ")[1];
const tokenPayload = jwt.decode(token);
const userId = tokenPayload.id;
const channelId = req.params.id;
const client = await getClient();
const logger = req.body.logger;
logger.action(`check if user ${userId} is subscribed to channel ${channelId}`);
const query = `SELECT * FROM subscriptions WHERE owner = $1 AND channel = $2`;
const result = await client.query(query, [userId, channelId]);
if (result.rows[0]) {
logger.write(`user ${userId} is subscribed to channel ${channelId}`, 200);
client.release();
return res.status(200).json({ subscribed: true });
} else {
logger.write(`user ${userId} is not subscribed to channel ${channelId}`, 200);
client.release();
return res.status(200).json({ subscribed: false });
}
}
export async function searchByUsername(req, res) {
const username = req.query.username;
const client = await getClient();
const logger = req.body.logger;
logger.action("try to search user by username " + username);
const query = `SELECT id, username, picture, email, is_verified FROM users WHERE username ILIKE $1`;
const result = await client.query(query, [`%${username}%`]);
if (result.rows.length === 0) {
logger.write("no user found with username " + username, 404);
client.release();
return res.status(404).json({ error: "User Not Found" });
}
logger.write("successfully found user with username " + username, 200);
client.release();
res.status(200).json(result.rows);
}
export async function getAllSubscriptions(req,res) {
const userId = req.params.id;
const client = await getClient();
const logger = req.body.logger;
logger.action("try to retrieve all subscriptions of user " + userId);
const query = `
SELECT
subscriptions.id,
channels.id AS channel_id,
channels.name AS channel_name,
users.picture
FROM
subscriptions
LEFT JOIN channels ON subscriptions.channel = channels.id
LEFT JOIN users ON channels.owner = users.id
WHERE
subscriptions.owner = $1
`;
const result = await client.query(query, [userId]);
if (result.rows.length === 0) {
logger.write("no subscriptions found for user " + userId, 404);
client.release();
return res.status(404).json({ error: "No Subscriptions Found" });
}
logger.write("successfully retrieved all subscriptions of user " + userId, 200);
client.release();
res.status(200).json(result.rows);
}
export async function getAllSubscriptionVideos(req, res) {
const userId = req.params.id;
const client = await getClient();
const logger = req.body.logger;
logger.action("try to retrieve all subscriptions of user " + userId);
const query = `
SELECT
videos.id,
videos.title,
videos.thumbnail,
channels.id AS channel,
videos.visibility,
videos.file,
videos.format,
videos.release_date,
channels.id AS channel_id,
channels.owner,
COUNT(history.id) AS views,
JSON_BUILD_OBJECT(
'name', channels.name,
'profilePicture', users.picture,
'description', channels.description
) AS creator
FROM
subscriptions
LEFT JOIN channels ON subscriptions.channel = channels.id
LEFT JOIN users ON channels.owner = users.id
LEFT JOIN videos ON channels.id = videos.channel
LEFT JOIN history ON videos.id = history.video
WHERE
subscriptions.owner = $1
GROUP BY
videos.id,
channels.id,
users.id;
`;
const result = await client.query(query, [userId]);
if (result.rows.length === 0) {
logger.write("no subscriptions found for user " + userId, 404);
client.release();
return res.status(404).json({ error: "No Subscriptions Found" });
}
logger.write("successfully retrieved all subscriptions of user " + userId, 200);
client.release();
res.status(200).json(result.rows);
}

302
backend/app/controllers/video.controller.js

@ -1,15 +1,16 @@
import {getClient} from "../utils/database.js"; import { getClient } from "../utils/database.js";
import * as path from "node:path"; import * as path from "node:path";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import {query} from "express-validator"; import { sendEmail } from "../utils/mail.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
export async function upload(req, res) { export async function upload(req, res) {
// HANDLE VIDEO FILE
const fileBuffer = req.file.buffer; const fileBuffer = req.file.buffer;
let isGenerate = false; let isGenerate = false;
while (isGenerate === false) { while (isGenerate === false) {
@ -22,7 +23,7 @@ export async function upload(req, res) {
const query = `SELECT * FROM videos WHERE slug = $1`; const query = `SELECT * FROM videos WHERE slug = $1`;
const result = await client.query(query, [hex]); const result = await client.query(query, [hex]);
client.end(); client.release();
if (result.rows.length === 0) { if (result.rows.length === 0) {
isGenerate = true; isGenerate = true;
req.body.slug = hex; req.body.slug = hex;
@ -45,14 +46,119 @@ export async function upload(req, res) {
visibility: req.body.visibility, visibility: req.body.visibility,
} }
// HANDLE VIDEO DETAILS
const logger = req.body.logger; const logger = req.body.logger;
logger.write("try to upload video"); logger.write("try to upload video");
const releaseDate = new Date(Date.now()).toISOString(); const releaseDate = new Date(Date.now()).toISOString();
const query = `INSERT INTO videos (title, thumbnail, description, channel, visibility, file, slug, format, release_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`; const query = `INSERT INTO videos (title, thumbnail, description, channel, visibility, file, slug, format, release_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]); const idResult = await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]);
const id = idResult.rows[0].id;
console.log(req.body.visibility, req.body.authorizedUsers)
// HANDLE AUTHORIZED USERS
if (req.body.visibility === "private" && req.body.authorizedUsers) {
let authorizedUsers = req.body.authorizedUsers;
// Parse if still a string (safety check)
if (typeof authorizedUsers === 'string') {
try {
authorizedUsers = JSON.parse(authorizedUsers);
} catch (error) {
console.error("Failed to parse authorizedUsers:", error);
authorizedUsers = [];
}
}
if (Array.isArray(authorizedUsers) && authorizedUsers.length > 0) {
for (let i = 0; i < authorizedUsers.length; i++) {
const user = authorizedUsers[i];
console.log("authorized user", user);
const query = `INSERT INTO video_authorized_users (video_id, user_id) VALUES ($1, $2)`;
// SEND EMAIL TO AUTHORIZED USER
const emailQuery = `SELECT email FROM users WHERE id = $1`;
const emailResult = await client.query(emailQuery, [user]);
const email = emailResult.rows[0].email;
const textMessage = `Vous êtes autorisé à visionner une vidéo privée. ${process.env.FRONTEND_URL}/videos/${id}`;
const htmlMessage = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accès à une vidéo privée - Freetube</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
🔐 Vidéo privée partagée avec vous!
</h1>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<h2 style="color: #333333; margin-top: 0; font-size: 24px;">
Bonjour! 👋
</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
Vous avez été autorisé(e) à visionner une vidéo privée sur <strong>Freetube</strong>!
</p>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
<strong>On vous a partagé une vidéo privée avec vous. Cliquez sur le bouton ci-dessous pour la regarder :
</p>
<!-- Video Info Box -->
<div style="background-color: #f8f9fa; border: 2px dashed #667eea; border-radius: 8px; padding: 25px; margin: 30px 0;">
<h3 style="color: #333333; font-size: 18px; margin: 0 0 10px 0; text-align: center;">
${video.title}
</h3>
<p style="color: #666666; font-size: 14px; margin: 0 0 20px 0; text-align: center; line-height: 1.4;">
${video.description}
</p>
<div style="text-align: center;">
<a href="${process.env.FRONTEND_URL}/videos/${id}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 12px 25px; border-radius: 6px; font-size: 16px; font-weight: bold; display: inline-block;">
Regarder la vidéo
</a>
</div>
</div>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 30px 0 10px 0;">
🔒 Cette vidéo est privée et n'est accessible qu'aux personnes autorisées.
</p>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 10px 0;">
Si vous pensez avoir reçu cet e-mail par erreur, vous pouvez l'ignorer.
</p>
</div>
<!-- Footer -->
<div style="background-color: #f8f9fa; padding: 20px 30px; border-top: 1px solid #eee;">
<p style="color: #999999; font-size: 12px; margin: 0; text-align: center;">
© 2025 Freetube. Tous droits réservés.
</p>
</div>
</div>
</body>
</html>
`;
sendEmail(email, "Invitation à visionner une vidéo privée", textMessage, htmlMessage);
await client.query(query, [id, user]);
}
}
}
logger.write("successfully uploaded video", 200); logger.write("successfully uploaded video", 200);
res.status(200).json({"message": "Successfully uploaded video"}); await client.release()
res.status(200).json({ "message": "Successfully uploaded video", "id": id });
} }
@ -75,7 +181,8 @@ export async function uploadThumbnail(req, res) {
const updateQuery = `UPDATE videos SET thumbnail = $1 WHERE id = $2`; const updateQuery = `UPDATE videos SET thumbnail = $1 WHERE id = $2`;
await client.query(updateQuery, [file, req.body.video]); await client.query(updateQuery, [file, req.body.video]);
logger.write("successfully uploaded thumbnail", 200); logger.write("successfully uploaded thumbnail", 200);
res.status(200).json({"message": "Successfully uploaded thumbnail"}); await client.release();
res.status(200).json({ "message": "Successfully uploaded thumbnail" });
} }
export async function getById(req, res) { export async function getById(req, res) {
@ -97,7 +204,7 @@ export async function getById(req, res) {
video.likes = likesResult.rows[0].like_count; video.likes = likesResult.rows[0].like_count;
// GET COMMENTS // GET COMMENTS
const commentsQuery = `SELECT c.id, c.content, c.created_at, u.username, u.picture FROM comments c JOIN users u ON c.author = u.id WHERE c.video = $1`; const commentsQuery = `SELECT c.id, c.content, c.created_at, u.username, u.picture FROM comments c JOIN users u ON c.author = u.id WHERE c.video = $1 ORDER BY c.created_at DESC`;
const commentsResult = await client.query(commentsQuery, [id]); const commentsResult = await client.query(commentsQuery, [id]);
video.comments = commentsResult.rows; video.comments = commentsResult.rows;
@ -121,7 +228,13 @@ export async function getById(req, res) {
const tagsResult = await client.query(tagsQuery, [id]); const tagsResult = await client.query(tagsQuery, [id]);
video.tags = tagsResult.rows.map(tag => tag.name); video.tags = tagsResult.rows.map(tag => tag.name);
// GET AUTHORIZED USERS
const authorizedUsersQuery = `SELECT u.id, u.username, u.picture FROM users u JOIN video_authorized_users vp ON u.id = vp.user_id WHERE vp.video_id = $1`;
const authorizedUsersResult = await client.query(authorizedUsersQuery, [id]);
video.authorizedUsers = authorizedUsersResult.rows;
logger.write("successfully get video " + id, 200); logger.write("successfully get video " + id, 200);
client.release()
res.status(200).json(video); res.status(200).json(video);
} }
@ -133,6 +246,7 @@ export async function getByChannel(req, res) {
const query = `SELECT * FROM videos WHERE channel = $1`; const query = `SELECT * FROM videos WHERE channel = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully get video from channel " + id, 200); logger.write("successfully get video from channel " + id, 200);
client.release()
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
@ -143,8 +257,48 @@ export async function update(req, res) {
const client = await getClient(); const client = await getClient();
const query = `UPDATE videos SET title = $1, description = $2, visibility = $3 WHERE id = $4`; const query = `UPDATE videos SET title = $1, description = $2, visibility = $3 WHERE id = $4`;
await client.query(query, [req.body.title, req.body.description, req.body.visibility, id]); await client.query(query, [req.body.title, req.body.description, req.body.visibility, id]);
const resultQuery = `
SELECT
videos.id,
videos.title,
videos.thumbnail,
videos.channel,
videos.file,
videos.description,
videos.visibility,
videos.release_date,
COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT h.id) AS history_count,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', c.id,
'content', c.content,
'username', u.username,
'video', c.video,
'created_at', c.created_at,
'picture', u.picture
)
) FILTER (
WHERE
c.id IS NOT NULL
) AS comments,
JSON_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM public.videos
LEFT JOIN public.likes l ON l.video = videos.id
LEFT JOIN public.history h ON h.video = videos.id
LEFT JOIN public.comments c ON c.video = videos.id
LEFT JOIN public.video_tags vt ON vt.video = videos.id
LEFT JOIN public.tags t ON vt.tag = t.id
LEFT JOIN public.users u ON u.id = c.author
WHERE
videos.id = $1
GROUP BY public.videos.id
`;
const result = await client.query(resultQuery, [id]);
logger.write("successfully updated video", 200); logger.write("successfully updated video", 200);
res.status(200).json({"message": "Successfully updated video"}); client.release()
res.status(200).json(result.rows[0]);
} }
export async function updateVideo(req, res) { export async function updateVideo(req, res) {
@ -157,14 +311,15 @@ export async function updateVideo(req, res) {
const video = videoResult.rows[0]; const video = videoResult.rows[0];
const slug = video.slug; const slug = video.slug;
const format = video.format; const format = video.format;
const pathToDelete = path.join(__dirname, "../uploads/videos/", slug + "." + format ); const pathToDelete = path.join(__dirname, "../uploads/videos/", slug + "." + format);
fs.unlink(pathToDelete, (error) => { fs.unlink(pathToDelete, (error) => {
if (error) { if (error) {
logger.write(error, 500); logger.write(error, 500);
res.status(500).json({"message": "Failed to delete video"}); client.release()
res.status(500).json({ "message": "Failed to delete video" });
return return
} }
logger.action("successfully deleted video " + slug + "." + format ); logger.action("successfully deleted video " + slug + "." + format);
const fileBuffer = req.file.buffer; const fileBuffer = req.file.buffer;
const finalName = slug + "." + format; const finalName = slug + "." + format;
const destinationPath = path.join(__dirname, "../uploads/videos/" + finalName) const destinationPath = path.join(__dirname, "../uploads/videos/" + finalName)
@ -172,7 +327,8 @@ export async function updateVideo(req, res) {
fs.writeFileSync(destinationPath, fileBuffer); fs.writeFileSync(destinationPath, fileBuffer);
logger.write("successfully updated video", 200); logger.write("successfully updated video", 200);
res.status(200).json({"message": "Successfully updated video"}); client.release()
res.status(200).json({ "message": "Successfully updated video" });
}) })
@ -193,7 +349,8 @@ export async function del(req, res) {
fs.unlink(pathToDelete, (error) => { fs.unlink(pathToDelete, (error) => {
if (error) { if (error) {
logger.write(error, 500); logger.write(error, 500);
res.status(500).json({"message": "Failed to delete video"}); client.release()
res.status(500).json({ "message": "Failed to delete video" });
return return
} }
@ -201,13 +358,14 @@ export async function del(req, res) {
fs.unlink(pathToDelete, async (error) => { fs.unlink(pathToDelete, async (error) => {
if (error) { if (error) {
logger.write(error, 500); logger.write(error, 500);
res.status(500).json({"message": "Failed to delete video"}); res.status(500).json({ "message": "Failed to delete video" });
return return
} }
const query = `DELETE FROM videos WHERE id = $1`; const query = `DELETE FROM videos WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("successfully deleted video", 200); logger.write("successfully deleted video", 200);
res.status(200).json({"message": "Successfully deleted video"}); client.release()
res.status(200).json({ "message": "Successfully deleted video" });
}) })
}) })
@ -237,7 +395,8 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count; const likesCount = likesCountResult.rows[0].like_count;
logger.write("no likes found adding likes for video " + id, 200); logger.write("no likes found adding likes for video " + id, 200);
res.status(200).json({"message": "Successfully added like", "likes": likesCount}); client.release();
res.status(200).json({ "message": "Successfully added like", "likes": likesCount });
} else { } else {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`; const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
await client.query(query, [userId, id]); await client.query(query, [userId, id]);
@ -248,7 +407,8 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count; const likesCount = likesCountResult.rows[0].like_count;
logger.write("likes found, removing like for video " + id, 200); logger.write("likes found, removing like for video " + id, 200);
res.status(200).json({"message": "Successfully removed like", "likes": likesCount}); client.release();
res.status(200).json({ "message": "Successfully removed like", "likes": likesCount });
} }
@ -295,9 +455,15 @@ export async function addTags(req, res) {
const insertVideoTagQuery = `INSERT INTO video_tags (tag, video) VALUES ($1, $2)`; const insertVideoTagQuery = `INSERT INTO video_tags (tag, video) VALUES ($1, $2)`;
await client.query(insertVideoTagQuery, [id, videoId]); await client.query(insertVideoTagQuery, [id, videoId]);
} }
// GET UPDATED TAGS FOR VIDEO
const updatedTagsQuery = `SELECT t.name FROM tags t JOIN video_tags vt ON t.id = vt.tag WHERE vt.video = $1`;
const updatedTagsResult = await client.query(updatedTagsQuery, [videoId]);
const updatedTags = updatedTagsResult.rows;
logger.write("successfully added tags to video " + videoId, 200); logger.write("successfully added tags to video " + videoId, 200);
await client.end(); await client.release();
res.status(200).json({"message": "Successfully added tags to video"}); res.status(200).json({ "message": "Successfully added tags to video", "tags": updatedTags.map(tag => tag.name) });
} }
@ -315,7 +481,7 @@ export async function getSimilarVideos(req, res) {
if (tags.length === 0) { if (tags.length === 0) {
logger.write("No tags found for video " + id, 404); logger.write("No tags found for video " + id, 404);
res.status(404).json({"message": "No similar videos found"}); res.status(404).json({ "message": "No similar videos found" });
return; return;
} }
@ -326,7 +492,7 @@ export async function getSimilarVideos(req, res) {
JOIN video_tags vt ON v.id = vt.video JOIN video_tags vt ON v.id = vt.video
JOIN tags t ON vt.tag = t.id JOIN tags t ON vt.tag = t.id
JOIN channels c ON v.channel = c.id JOIN channels c ON v.channel = c.id
WHERE t.name = ANY($1) AND v.id != $2 WHERE t.name = ANY($1) AND v.id != $2 AND v.visibility = 'public'
GROUP BY v.id, c.name, c.id GROUP BY v.id, c.name, c.id
LIMIT 10; LIMIT 10;
`; `;
@ -355,10 +521,62 @@ export async function getSimilarVideos(req, res) {
} }
logger.write("successfully retrieved similar videos for video " + id, 200); logger.write("successfully get similar videos for video " + id, 200);
await client.release();
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
export async function getLikesPerDay(req, res) {
const id = req.params.id;
const logger = req.body.logger;
logger.action("try to get likes per day");
const client = await getClient();
try {
const response = {}
const likeQuery = `
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM likes
WHERE video = $1
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30
`;
const viewQuery = `
SELECT
DATE(viewed_at) as date,
COUNT(*) as count
FROM history
WHERE video = $1
GROUP BY DATE(viewed_at)
ORDER BY date DESC
LIMIT 30
`;
const resultViews = await client.query(viewQuery, [id]);
response.views = resultViews.rows;
const resultLikes = await client.query(likeQuery, [id]);
response.likes = resultLikes.rows;
console.log(response);
logger.write("successfully retrieved likes per day", 200);
res.status(200).json(response);
} catch (error) {
logger.write("Error retrieving likes per day: " + error.message, 500);
res.status(500).json({ error: "Internal server error" });
} finally {
await client.release();
}
}
export async function addViews(req, res) { export async function addViews(req, res) {
const id = req.params.id; const id = req.params.id;
const logger = req.body.logger; const logger = req.body.logger;
@ -378,5 +596,41 @@ export async function addViews(req, res) {
} }
logger.write("successfully added views for video " + id, 200); logger.write("successfully added views for video " + id, 200);
res.status(200).json({"message": "Successfully added views"}); await client.release();
res.status(200).json({ "message": "Successfully added views" });
}
export async function updateAuthorizedUsers(req, res) {
const id = req.params.id;
const logger = req.body.logger;
logger.action("try to update authorized users for video " + id);
const { authorizedUsers } = req.body;
console.log(authorizedUsers);
const client = await getClient();
try {
// Remove all existing authorized users
const deleteQuery = `DELETE FROM video_authorized_users WHERE video_id = $1`;
console.log(`DELETE FROM video_authorized_users WHERE video_id = ${id}`);
await client.query(deleteQuery, [id]);
// Add new authorized users
const insertQuery = `INSERT INTO video_authorized_users (video_id, user_id) VALUES ($1, $2)`;
for (let i = 0; i < authorizedUsers.length; i++) {
const user = authorizedUsers[i];
console.log(`INSERT INTO video_authorized_users (video_id, user_id) VALUES (${id}, ${user})`)
await client.query(insertQuery, [id, user]);
}
logger.write("successfully updated authorized users for video " + id, 200);
res.status(200).json({ "message": "Successfully updated authorized users" });
} catch (error) {
logger.write("Error updating authorized users: " + error.message, 500);
res.status(500).json({ error: "Internal server error" });
} finally {
await client.release();
}
} }

98
backend/app/middlewares/channel.middleware.js

@ -7,7 +7,7 @@ export const Channel = {
name: body("name").notEmpty().isAlphanumeric("fr-FR", { name: body("name").notEmpty().isAlphanumeric("fr-FR", {
ignore: " _-" ignore: " _-"
}).trim(), }).trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric().trim(), description: body("description").optional({values: "falsy"}).isLength({ max: 500 }).trim(),
owner: body("owner").notEmpty().isNumeric().trim().withMessage("bad owner"), owner: body("owner").notEmpty().isNumeric().trim().withMessage("bad owner"),
} }
@ -15,62 +15,77 @@ export const ChannelCreate = {
name: body("name").notEmpty().isAlphanumeric("fr-FR", { name: body("name").notEmpty().isAlphanumeric("fr-FR", {
ignore: " _-" ignore: " _-"
}).trim(), }).trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric().trim(), description: body("description").optional({values: "falsy"}).isLength({ max: 500 }).trim(),
owner: body("owner").notEmpty().isNumeric().trim(), owner: body("owner").notEmpty().isNumeric().trim(),
} }
export async function doUserHaveChannel(req, res, next) { export async function doUserHaveChannel(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE owner = ${req.body.owner}`; try {
const result = await client.query(query); const query = `SELECT id FROM channels WHERE owner = $1`;
const result = await client.query(query, [req.body.owner]);
if (result.rows[0]) { if (result.rows[0]) {
logger.write("failed because user already has a channel", 400); logger.write("failed because user already has a channel", 400);
res.status(400).json({error: "User already own a channel"}) res.status(400).json({error: "User already own a channel"})
} else { } else {
next() next()
}
} finally {
client.release();
} }
} }
export async function doChannelNameExists(req, res, next) { export async function doChannelNameExists(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT * FROM channels WHERE name = '${req.body.name}'`; try {
const result = await client.query(query); const query = `SELECT * FROM channels WHERE name = $1`;
const result = await client.query(query, [req.body.name]);
if (result.rows[0]) { if (result.rows[0]) {
logger.write("failed because channel name already exist", 400) logger.write("failed because channel name already exist", 400)
res.status(400).json({error: "Channel name already used"}) res.status(400).json({error: "Channel name already used"})
} else { } else {
next() next()
}
} finally {
client.release();
} }
} }
export async function doChannelExists(req, res, next) { export async function doChannelExists(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE id = '${req.params.id}'`; try {
const result = await client.query(query); const query = `SELECT id FROM channels WHERE id = $1`;
if (result.rows[0]) { const result = await client.query(query, [req.params.id]);
next() if (result.rows[0]) {
} else { next()
logger.write("failed to retrieve channel because it doesn't exist", 404); } else {
res.status(404).json({error: "Not Found"}) logger.write("failed to retrieve channel because it doesn't exist", 404);
res.status(404).json({error: "Not Found"})
}
} finally {
client.release();
} }
} }
export async function doChannelExistBody(req, res, next) { export async function doChannelExistBody(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE id = ${req.body.channel}`; try {
const result = await client.query(query); const query = `SELECT id FROM channels WHERE id = $1`;
if (result.rows[0]) { const result = await client.query(query, [req.body.channel]);
next() if (result.rows[0]) {
} else { next()
logger.write("failed to retrieve channel because it doesn't exist", 404); } else {
res.status(404).json({error: "Not Found"}) logger.write("failed to retrieve channel because it doesn't exist", 404);
res.status(404).json({error: "Not Found"})
}
} finally {
client.release();
} }
} }
@ -83,14 +98,17 @@ export async function isOwner(req, res, next) {
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
try {
const query = `SELECT id, owner FROM channels WHERE id = ${id}`; const query = `SELECT id, owner FROM channels WHERE id = $1`;
const result = await client.query(query); const result = await client.query(query, [id]);
const channel = result.rows[0]; const channel = result.rows[0];
if (channel.owner != claims.id) { if (channel.owner != claims.id) {
logger.write("failed because user do not own the channel", 403); logger.write("failed because user do not own the channel", 403);
res.status(403).json({error: "You're not the owner of the channel"}) res.status(403).json({error: "You're not the owner of the channel"})
} else { } else {
next() next()
}
} finally {
client.release();
} }
} }

36
backend/app/middlewares/comment.middleware.js

@ -20,14 +20,18 @@ export async function doCommentExists(req, res, next) {
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM comments WHERE id = ${id}`; try {
const result = await client.query(query); const query = `SELECT * FROM comments WHERE id = $1`;
if (result.rows.length === 0) { const result = await client.query(query, [id]);
logger.write("comment not found", 404); if (result.rows.length === 0) {
res.status(404).json({error: "comment not found"}); logger.write("comment not found", 404);
return res.status(404).json({error: "comment not found"});
return
}
next()
} finally {
client.release();
} }
next()
} }
export async function isAuthor(req, res, next) { export async function isAuthor(req, res, next) {
@ -37,12 +41,16 @@ export async function isAuthor(req, res, next) {
const userId = claims.id; const userId = claims.id;
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM comments WHERE id = ${id}`; try {
const result = await client.query(query); const query = `SELECT * FROM comments WHERE id = $1`;
if (userId !== result.rows[0].author) { const result = await client.query(query, [id]);
logger.write("is not author of the comment", 403); if (userId !== result.rows[0].author) {
res.status(403).json({error: "You do not have permission"}); logger.write("is not author of the comment", 403);
return res.status(403).json({error: "You do not have permission"});
return
}
next()
} finally {
client.release();
} }
next()
} }

70
backend/app/middlewares/playlist.middleware.js

@ -5,7 +5,7 @@ import jwt from "jsonwebtoken";
export const Playlist = { export const Playlist = {
id: param("id").notEmpty().isNumeric().trim(), id: param("id").notEmpty().isNumeric().trim(),
name: body("name").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(), name: body("name").notEmpty().trim(),
owner: body("owner").notEmpty().isNumeric().trim(), owner: body("owner").notEmpty().isNumeric().trim(),
videoId: param("videoId").notEmpty().isNumeric().trim(), videoId: param("videoId").notEmpty().isNumeric().trim(),
} }
@ -16,20 +16,23 @@ export async function doPlaylistExists(req, res, next) {
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT id FROM playlists WHERE id = ${id}`;
try { try {
var result = await client.query(query); const query = `SELECT id FROM playlists WHERE id = $1`;
const result = await client.query(query, [id]);
if (result.rows.length === 0) {
logger.write("No playlist with id " + id, 404);
res.status(404).json({error: "Playlist not found"});
return;
}
next();
} catch (error) { } catch (error) {
logger.write("Error checking playlist existence: " + error.message, 500); logger.write("Error checking playlist existence: " + error.message, 500);
res.status(500).json({error: error}); res.status(500).json({error: error});
return; return;
} finally {
client.release();
} }
if (result.rows.length === 0) {
logger.write("No playlist with id " + id, 404);
res.status(404).json({error: "Playlist not found"});
return;
}
next();
} }
export async function isOwner(req, res, next) { export async function isOwner(req, res, next) {
@ -41,18 +44,20 @@ export async function isOwner(req, res, next) {
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT owner FROM playlists WHERE id = ${id}`; try {
const query = `SELECT owner FROM playlists WHERE id = $1`;
const result = await client.query(query); const result = await client.query(query, [id]);
if(result.rows[0].owner !== userId) { if(result.rows[0].owner !== userId) {
logger.write("user not the owner of the playlist with id " + id, 403); logger.write("user not the owner of the playlist with id " + id, 403);
res.status(403).json({error: "You do not have permission"}); res.status(403).json({error: "You do not have permission"});
return; return;
}
next();
} finally {
client.release();
} }
next();
} }
export async function isVideoInPlaylist(req, res, next) { export async function isVideoInPlaylist(req, res, next) {
@ -61,22 +66,23 @@ export async function isVideoInPlaylist(req, res, next) {
const videoId = req.params.videoId; const videoId = req.params.videoId;
const logger = req.body.logger; const logger = req.body.logger;
const client = await getClient(); const client = await getClient();
const query = `SELECT id FROM playlist_elements WHERE video = ${videoId} AND playlist = ${id}`;
try { try {
var result = await client.query(query); const query = `SELECT id FROM playlist_elements WHERE video = $1 AND playlist = $2`;
const result = await client.query(query, [videoId, id]);
if(result.rows.length === 0) {
logger.write("video " + videoId + "not found in playlist with id " + id, 404 );
res.status(404).json({error: "Video " + videoId + "not found"});
return;
}
next();
} catch (error) { } catch (error) {
logger.write("Error checking video in playlist: " + query, 500); logger.write("Error checking video in playlist: " + error.message, 500);
res.status(500).json({error: error }); res.status(500).json({error: error });
return; return;
} finally {
client.release();
} }
if(result.rows.length === 0) {
logger.write("video " + videoId + "not found in playlist with id " + id, 404 );
res.status(404).json({error: "Video " + videoId + "not found"});
return;
}
next();
} }

90
backend/app/middlewares/user.middleware.js

@ -1,18 +1,18 @@
import {body, param} from "express-validator"; import {body, param, query} from "express-validator";
import {getClient} from "../utils/database.js"; import {getClient} from "../utils/database.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
export const User = { export const User = {
id: param("id").notEmpty().isNumeric().trim(), id: param("id").notEmpty().isNumeric().trim(),
email: body("email").notEmpty().isEmail().trim(), email: body("email").notEmpty().isEmail().trim(),
username: body("username").notEmpty().isAlphanumeric().trim(), username: body("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
password: body("password").notEmpty().isAlphanumeric().trim(), password: body("password").notEmpty().trim(),
picture: body("picture").notEmpty().isAlphanumeric().trim(), picture: body("picture").notEmpty().isAlphanumeric().trim(),
} }
export const UserRegister = { export const UserRegister = {
email: body("email").notEmpty().isEmail().trim(), email: body("email").notEmpty().isEmail().trim(),
username: body("username").notEmpty().isAlphanumeric().trim(), username: body("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
password: body("password").notEmpty().isStrongPassword({ password: body("password").notEmpty().isStrongPassword({
minLength: 8, minLength: 8,
maxLength: 32, maxLength: 32,
@ -23,7 +23,7 @@ export const UserRegister = {
} }
export const UserLogin = { export const UserLogin = {
username: body("username").notEmpty().isAlphanumeric().trim(), username: body("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
password: body("password").notEmpty().isStrongPassword({ password: body("password").notEmpty().isStrongPassword({
minLength: 8, minLength: 8,
maxLength: 32, maxLength: 32,
@ -34,61 +34,79 @@ export const UserLogin = {
} }
export const UserRequest = { export const UserRequest = {
username: param("username").notEmpty().isAlphanumeric().trim(), username: param("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
}
export const UserSearch = {
username: query("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
} }
export async function doEmailExists(req, res, next) { export async function doEmailExists(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`; try {
const result = await client.query(query); const query = `SELECT * FROM users WHERE email = $1`;
const result = await client.query(query, [req.body.email]);
if (result.rows.length > 0) { if (result.rows.length > 0) {
logger.write("failed because email already exists", 400) logger.write("failed because email already exists", 400)
res.status(400).send({error: "Email already exists"}) res.status(400).send({error: "Email already exists"})
} else { } else {
next() next()
}
} finally {
client.release();
} }
} }
export async function doUsernameExists(req, res, next) { export async function doUsernameExists(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT * FROM users WHERE username = '${req.body.username}'`; try {
const result = await client.query(query); const query = `SELECT * FROM users WHERE username = $1`;
if (result.rows.length > 0) { const result = await client.query(query, [req.body.username]);
logger.write("failed because username already exists", 400) if (result.rows.length > 0) {
res.status(400).send({error: "Username already exists"}) logger.write("failed because username already exists", 400)
} else { res.status(400).send({error: "Username already exists"})
next() } else {
next()
}
} finally {
client.release();
} }
} }
export async function doUserExists(req, res, next) { export async function doUserExists(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT id FROM users WHERE id = ${req.params.id}`; try {
const result = await client.query(query); const query = `SELECT id FROM users WHERE id = $1`;
if (result.rows.length > 0) { const result = await client.query(query, [req.params.id]);
next() if (result.rows.length > 0) {
}else{ next()
logger.write("failed because user doesn't exists", 404) }else{
res.status(404).json({error: "Not Found"}) logger.write("failed because user doesn't exists", 404)
res.status(404).json({error: "Not Found"})
}
} finally {
client.release();
} }
} }
export async function doUserExistsBody(req, res, next) { export async function doUserExistsBody(req, res, next) {
const client = await getClient(); const client = await getClient();
const logger = req.body.logger; const logger = req.body.logger;
const query = `SELECT id FROM users WHERE id = ${req.body.owner}`; try {
const result = await client.query(query); const query = `SELECT id FROM users WHERE id = $1`;
if (result.rows.length > 0) { const result = await client.query(query, [req.body.owner]);
next() if (result.rows.length > 0) {
}else{ next()
logger.write("failed because user doesn't exists", 404) }else{
res.status(404).json({error: "Not Found"}) logger.write("failed because user doesn't exists", 404)
res.status(404).json({error: "Not Found"})
}
} finally {
client.release();
} }
} }

156
backend/app/middlewares/video.middleware.js

@ -5,8 +5,8 @@ import jwt from "jsonwebtoken";
export const Video = { export const Video = {
id: param("id").notEmpty().isNumeric().trim(), id: param("id").notEmpty().isNumeric().trim(),
title: body("title").notEmpty().isAlphanumeric("fr-FR", {'ignore': " _-"}).trim(), title: body("title").notEmpty().trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric("fr-FR", {ignore: " -_"}).trim(), description: body("description").optional({values: "falsy"}).trim(),
channel: body("channel").notEmpty().isNumeric().trim(), channel: body("channel").notEmpty().isNumeric().trim(),
visibility: body("visibility").notEmpty().isAlpha().trim(), visibility: body("visibility").notEmpty().isAlpha().trim(),
idBody: body("video").notEmpty().isNumeric().trim(), idBody: body("video").notEmpty().isNumeric().trim(),
@ -19,10 +19,25 @@ export const Video = {
} }
export const VideoCreate = { export const VideoCreate = {
title: body("title").notEmpty().isAlphanumeric("fr-FR", {'ignore': " _-"}).trim(), title: body("title").notEmpty().trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric("fr-FR", {ignore: " -_"}).trim(), description: body("description").optional({values: "falsy"}).trim(),
channel: body("channel").notEmpty().isNumeric().trim(), channel: body("channel").notEmpty().isNumeric().trim(),
visibility: body("visibility").notEmpty().isAlpha().trim(), visibility: body("visibility").notEmpty().isAlpha().trim(),
authorizedUsers: body("authorizedUsers")
.optional({values: "falsy"})
.customSanitizer((value) => {
// Parse JSON string back to array
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
return [];
}
}
return value;
})
.isArray()
.withMessage("Authorized users must be an array"),
} }
export const VideoThumbnail = { export const VideoThumbnail = {
@ -35,15 +50,19 @@ export async function isOwner(req, res, next) {
const token = req.headers.authorization.split(' ')[1]; const token = req.headers.authorization.split(' ')[1];
const claims = jwt.decode(token); const claims = jwt.decode(token);
const client = await getClient(); const client = await getClient();
const channelQuery = `SELECT owner FROM channels WHERE id = '${channelId}'`; try {
const channelResult = await client.query(channelQuery); const channelQuery = `SELECT owner FROM channels WHERE id = $1`;
const channelInBase = channelResult.rows[0]; const channelResult = await client.query(channelQuery, [channelId]);
if (channelInBase.owner !== claims.id) { const channelInBase = channelResult.rows[0];
logger.write("failed because user is not owner", 403); if (channelInBase.owner !== claims.id) {
res.status(403).json({error: "Not authorized"}); logger.write("failed because user is not owner", 403);
return res.status(403).json({error: "Not authorized"});
return
}
next()
} finally {
client.release();
} }
next()
} }
export async function doVideoExists(req, res, next) { export async function doVideoExists(req, res, next) {
@ -51,16 +70,19 @@ export async function doVideoExists(req, res, next) {
const videoId = req.body.video; const videoId = req.body.video;
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM videos WHERE id = ${videoId}`; try {
const result = await client.query(query); const query = `SELECT * FROM videos WHERE id = $1`;
const videos = result.rows; const result = await client.query(query, [videoId]);
if (videos.length === 0) { const videos = result.rows;
logger.write("failed because video not found", 404); if (videos.length === 0) {
res.status(404).json({error: "Not Found"}); logger.write("failed because video not found", 404);
return res.status(404).json({error: "Not Found"});
return
}
next()
} finally {
client.release();
} }
next()
} }
export async function doVideoExistsParam(req, res, next) { export async function doVideoExistsParam(req, res, next) {
@ -68,14 +90,92 @@ export async function doVideoExistsParam(req, res, next) {
const videoId = req.params.id; const videoId = req.params.id;
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM videos WHERE id = ${videoId}`; try {
const result = await client.query(query); const query = `SELECT * FROM videos WHERE id = $1`;
const video = result.rows[0]; const result = await client.query(query, [videoId]);
if (!video) { const video = result.rows[0];
logger.write("failed because video not found", 404); if (!video) {
res.status(404).json({error: "Not Found"}); logger.write("failed because video not found", 404);
return res.status(404).json({error: "Not Found"});
return
}
next()
} finally {
client.release();
}
}
export async function doAuthorizedUserExists(req, res, next) {
const logger = req.body.logger;
let authorizedUsers = req.body.authorizedUsers;
// Parse JSON string if needed
if (typeof authorizedUsers === 'string') {
try {
authorizedUsers = JSON.parse(authorizedUsers);
} catch (error) {
logger.write("failed because authorizedUsers is not valid JSON", 400);
res.status(400).json({error: "Invalid authorized users format"});
return;
}
}
if (authorizedUsers && Array.isArray(authorizedUsers) && authorizedUsers.length > 0) {
const client = await getClient();
try {
for (const userId of authorizedUsers) {
const query = `SELECT id FROM users WHERE id = $1`;
const result = await client.query(query, [userId]);
const foundUser = result.rows[0];
if (!foundUser) {
logger.write("failed because authorized user not found", 404);
res.status(404).json({error: "Not Found"});
return
}
}
} finally {
client.release();
}
} }
next() next()
}
export async function hasAccess(req, res, next) {
const logger = req.body.logger;
const videoId = req.params.id;
const client = await getClient();
try {
const videoQuery = "SELECT visibility FROM videos WHERE id = $1";
const videoResult = await client.query(videoQuery, [videoId]);
const video = videoResult.rows[0];
console.log(video);
if (video.visibility === 'private') {
const token = req.headers.authorization?.split(" ")[1];
if (!req.headers.authorization || !token) {
logger.write("failed because no token provided", 401);
res.status(401).json({error: "Unauthorized"});
return;
}
const claims = jwt.decode(token);
const userId = claims.id;
const query = `SELECT * FROM video_authorized_users WHERE video_id = $1 AND user_id = $2`;
const result = await client.query(query, [videoId, userId]);
const isAuthorized = result.rows.length > 0;
if (!isAuthorized) {
logger.write("failed because user is not authorized", 403);
res.status(403).json({error: "Not authorized"});
return;
}
}
next();
} finally {
client.release();
}
} }

47
backend/app/routes/channel.route.js

@ -1,5 +1,5 @@
import {Router} from "express"; import {Router} from "express";
import {create, del, getAll, getById, toggleSubscription, update} from "../controllers/channel.controller.js"; import {create, del, getAll, getById, getStats, toggleSubscription, update} from "../controllers/channel.controller.js";
import {isTokenValid} from "../middlewares/jwt.middleware.js"; import {isTokenValid} from "../middlewares/jwt.middleware.js";
import { import {
Channel, Channel,
@ -14,11 +14,48 @@ import {addLogger} from "../middlewares/logger.middleware.js";
const router = Router(); const router = Router();
// CREATE CHANNEL /**
* @swagger
* tags:
* name: Channels
* description: API for managing channels
* /:
* post:
* summary: Create a new channel
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description:
* type: string
* owner:
* type: string
* responses:
* 201:
* description: Channel created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* name:
* type: string
* description:
* type: string
* owner:
* type: string
*/
router.post("/", [addLogger, isTokenValid, ChannelCreate.name, ChannelCreate.description, ChannelCreate.owner, validator, doUserExistsBody, doUserHaveChannel, isOwnerBody, doChannelNameExists], create); router.post("/", [addLogger, isTokenValid, ChannelCreate.name, ChannelCreate.description, ChannelCreate.owner, validator, doUserExistsBody, doUserHaveChannel, isOwnerBody, doChannelNameExists], create);
// GET CHANNEL BY ID // GET CHANNEL BY ID
router.get("/:id", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], getById); router.get("/:id", [addLogger, Channel.id, validator, doChannelExists], getById);
// GET ALL CHANNEL // GET ALL CHANNEL
router.get("/", [addLogger, isTokenValid], getAll); router.get("/", [addLogger, isTokenValid], getAll);
@ -32,4 +69,8 @@ router.delete("/:id", [addLogger, isTokenValid, Channel.id, validator, doChannel
// TOGGLE SUBSCRIPTION // TOGGLE SUBSCRIPTION
router.post("/:id/subscribe", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], toggleSubscription); router.post("/:id/subscribe", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], toggleSubscription);
// GET TOTAL VIEWS AND SUBSCRIBERS OF THE CHANNEL
router.get("/:id/stats", [addLogger, isTokenValid, Channel.id, validator, doChannelExists, isOwner], getStats);
export default router; export default router;

15
backend/app/routes/oauth.route.js

@ -0,0 +1,15 @@
import {Router} from 'express';
import { callback, login, getUserInfo } from '../controllers/oauth.controller.js';
import { isTokenValid } from '../middlewares/jwt.middleware.js';
import { addLogger } from '../middlewares/logger.middleware.js';
const router = Router();
router.get('/github', login)
router.get('/callback', callback)
// Get current user info from token
router.get('/me', [addLogger, isTokenValid], getUserInfo)
export default router;

6
backend/app/routes/playlist.route.js

@ -3,7 +3,7 @@ import {addLogger} from "../middlewares/logger.middleware.js";
import {isTokenValid} from "../middlewares/jwt.middleware.js"; import {isTokenValid} from "../middlewares/jwt.middleware.js";
import {doPlaylistExists, Playlist, isOwner, isVideoInPlaylist} from "../middlewares/playlist.middleware.js"; import {doPlaylistExists, Playlist, isOwner, isVideoInPlaylist} from "../middlewares/playlist.middleware.js";
import validator from "../middlewares/error.middleware.js"; import validator from "../middlewares/error.middleware.js";
import {addVideo, create, del, deleteVideo, getById, getByUser, update} from "../controllers/playlist.controller.js"; import {addVideo, create, del, deleteVideo, getById, getByUser, update, getSeeLater} from "../controllers/playlist.controller.js";
import {doVideoExists, Video} from "../middlewares/video.middleware.js"; import {doVideoExists, Video} from "../middlewares/video.middleware.js";
import {doUserExists, User, isOwner as isOwnerUser} from "../middlewares/user.middleware.js"; import {doUserExists, User, isOwner as isOwnerUser} from "../middlewares/user.middleware.js";
@ -12,6 +12,9 @@ const router = new Router();
// CREATE PLAYLIST // CREATE PLAYLIST
router.post("/", [addLogger, isTokenValid, Playlist.name, validator], create); router.post("/", [addLogger, isTokenValid, Playlist.name, validator], create);
// GET SEE LATER PLAYLIST
router.get("/see-later", [addLogger, isTokenValid], getSeeLater);
// ADD VIDEO TO PLAYLIST // ADD VIDEO TO PLAYLIST
router.post("/:id", [addLogger, isTokenValid, Playlist.id, Video.idBody, validator, doPlaylistExists, isOwner, doVideoExists], addVideo); router.post("/:id", [addLogger, isTokenValid, Playlist.id, Video.idBody, validator, doPlaylistExists, isOwner, doVideoExists], addVideo);
@ -30,4 +33,5 @@ router.delete("/:id/video/:videoId", [addLogger, isTokenValid, Video.id, Playlis
// DELETE PLAYLIST // DELETE PLAYLIST
router.delete("/:id", [addLogger, isTokenValid, Playlist.id, validator, doPlaylistExists, isOwner], del); router.delete("/:id", [addLogger, isTokenValid, Playlist.id, validator, doPlaylistExists, isOwner], del);
export default router; export default router;

4
backend/app/routes/redommendation.route.js

@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import {getRecommendations, getTrendingVideos} from "../controllers/recommendation.controller.js"; import {getRecommendations, getTrendingVideos, getTopCreators} from "../controllers/recommendation.controller.js";
const router = Router(); const router = Router();
@ -7,4 +7,6 @@ router.get('/', [], getRecommendations);
router.get('/trending', [], getTrendingVideos); router.get('/trending', [], getTrendingVideos);
router.get("/creators", [], getTopCreators)
export default router; export default router;

5
backend/app/routes/search.route.js

@ -3,6 +3,11 @@ import {search} from "../controllers/search.controller.js";
const router = Router(); const router = Router();
// Handle OPTIONS preflight requests
router.options('/', (req, res) => {
res.status(200).end();
});
router.get('/', search) router.get('/', search)
export default router; export default router;

28
backend/app/routes/user.route.js

@ -6,7 +6,12 @@ import {
getByUsername, getByUsername,
update, update,
deleteUser, deleteUser,
getChannel, getHistory getChannel, getHistory,
isSubscribed,
verifyEmail,
searchByUsername,
getAllSubscriptions,
getAllSubscriptionVideos
} from "../controllers/user.controller.js"; } from "../controllers/user.controller.js";
import { import {
UserRegister, UserRegister,
@ -16,12 +21,14 @@ import {
isOwner, isOwner,
UserLogin, UserLogin,
User, User,
UserRequest UserRequest,
UserSearch
} from "../middlewares/user.middleware.js"; } from "../middlewares/user.middleware.js";
import validator from "../middlewares/error.middleware.js"; import validator from "../middlewares/error.middleware.js";
import {isTokenValid} from "../middlewares/jwt.middleware.js"; import {isTokenValid} from "../middlewares/jwt.middleware.js";
import {addLogger} from "../middlewares/logger.middleware.js"; import {addLogger} from "../middlewares/logger.middleware.js";
import {profileUpload} from "../middlewares/file.middleware.js"; import {profileUpload} from "../middlewares/file.middleware.js";
import {Channel} from "../middlewares/channel.middleware.js";
const router = Router(); const router = Router();
@ -31,6 +38,9 @@ router.post("/", [profileUpload.single("profile"), addLogger, UserRegister.email
// LOGIN A USER // LOGIN A USER
router.post("/login", [addLogger, UserLogin.username, UserLogin.password, validator], login) router.post("/login", [addLogger, UserLogin.username, UserLogin.password, validator], login)
// SEARCH BY USERNAME
router.get("/search", [addLogger, isTokenValid, UserSearch.username, validator], searchByUsername);
// GET USER BY ID // GET USER BY ID
router.get("/:id", [addLogger, isTokenValid, User.id, validator], getById) router.get("/:id", [addLogger, isTokenValid, User.id, validator], getById)
@ -38,7 +48,7 @@ router.get("/:id", [addLogger, isTokenValid, User.id, validator], getById)
router.get("/username/:username", [addLogger, isTokenValid, UserRequest.username, validator], getByUsername); router.get("/username/:username", [addLogger, isTokenValid, UserRequest.username, validator], getByUsername);
// UPDATE USER // UPDATE USER
router.put("/:id", [addLogger, isTokenValid, User.id, UserRegister.email, UserRegister.username, UserRegister.password, validator, doUserExists, isOwner], update); router.put("/:id", [addLogger, isTokenValid, User.id, UserRegister.email, UserRegister.username, validator, doUserExists, isOwner], update);
// DELETE USER // DELETE USER
router.delete("/:id", [addLogger, isTokenValid, User.id, validator, doUserExists, isOwner], deleteUser); router.delete("/:id", [addLogger, isTokenValid, User.id, validator, doUserExists, isOwner], deleteUser);
@ -49,4 +59,16 @@ router.get("/:id/channel", [addLogger, isTokenValid, User.id, validator], getCha
// GET USER HISTORY // GET USER HISTORY
router.get("/:id/history", [addLogger, isTokenValid, User.id, validator], getHistory); router.get("/:id/history", [addLogger, isTokenValid, User.id, validator], getHistory);
// CHECK IF SUBSCRIBED TO CHANNEL
router.get("/:id/channel/subscribed", [addLogger, isTokenValid, User.id, Channel.id, validator], isSubscribed)
// VERIFY EMAIL
router.post("/verify-email", [addLogger, validator], verifyEmail);
// GET ALL SUBSCRIPTIONS
router.get("/:id/subscriptions", [addLogger, isTokenValid, User.id, validator, doUserExists], getAllSubscriptions);
// GET ALL SUBSCRIPTIONS VIDEOS
router.get("/:id/subscriptions/videos", [addLogger, isTokenValid, User.id, validator, doUserExists], getAllSubscriptionVideos);
export default router; export default router;

22
backend/app/routes/video.route.js

@ -10,7 +10,7 @@ import {
uploadThumbnail, uploadThumbnail,
updateVideo, updateVideo,
toggleLike, toggleLike,
addTags, getSimilarVideos, addViews addTags, getSimilarVideos, addViews, getLikesPerDay, updateAuthorizedUsers
} from "../controllers/video.controller.js"; } from "../controllers/video.controller.js";
import { import {
doVideoExists, doVideoExists,
@ -18,7 +18,9 @@ import {
isOwner, isOwner,
Video, Video,
VideoCreate, VideoCreate,
VideoThumbnail VideoThumbnail,
doAuthorizedUserExists,
hasAccess
} from "../middlewares/video.middleware.js"; } from "../middlewares/video.middleware.js";
import {Channel, doChannelExistBody, doChannelExists} from "../middlewares/channel.middleware.js"; import {Channel, doChannelExistBody, doChannelExists} from "../middlewares/channel.middleware.js";
import {thumbnailUpload, videoUpload} from "../middlewares/file.middleware.js"; import {thumbnailUpload, videoUpload} from "../middlewares/file.middleware.js";
@ -27,19 +29,20 @@ import validator from "../middlewares/error.middleware.js";
const router = Router(); const router = Router();
// UPLOAD VIDEO // UPLOAD VIDEO
router.post("/", [videoUpload.single('file'), addLogger, isTokenValid, VideoCreate.title, VideoCreate.description, VideoCreate.visibility, VideoCreate.channel, validator, doChannelExistBody, isOwner], upload); router.post("/", [videoUpload.single('file'), addLogger, isTokenValid, VideoCreate.title, VideoCreate.description, VideoCreate.visibility, VideoCreate.authorizedUsers, VideoCreate.channel, validator, doChannelExistBody, isOwner, doAuthorizedUserExists], upload);
// UPLOAD/UPDATE THUMBNAIL // UPLOAD/UPDATE THUMBNAIL
router.post("/thumbnail", [thumbnailUpload.single('file'), addLogger, isTokenValid, VideoThumbnail.video, Video.channel, validator, doChannelExistBody, isOwner, doVideoExists], uploadThumbnail ) router.post("/thumbnail", [thumbnailUpload.single('file'), addLogger, isTokenValid, VideoThumbnail.video, Video.channel, validator, doChannelExistBody, isOwner, doVideoExists], uploadThumbnail )
// GET BY ID // GET BY ID
router.get("/:id", [addLogger, Video.id, validator, doVideoExistsParam], getById); router.get("/:id", [addLogger, Video.id, validator, doVideoExistsParam, hasAccess], getById);
// GET BY CHANNEL // GET BY CHANNEL
router.get("/channel/:id", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], getByChannel); router.get("/channel/:id", [addLogger, isTokenValid, Channel.id, validator, doChannelExists, hasAccess], getByChannel);
// UPDATE VIDEO DATA // UPDATE VIDEO DATA
router.put("/:id", [addLogger, isTokenValid, Video.id, VideoCreate.title, VideoCreate.description, VideoCreate.visibility, VideoCreate.channel, validator, doVideoExistsParam, doChannelExistBody, isOwner], update); router.put("/:id", [addLogger, isTokenValid, Video.id, VideoCreate.title, VideoCreate.description, VideoCreate.visibility, VideoCreate.channel, validator, doVideoExistsParam, doChannelExistBody, isOwner], update);
// UPDATE VIDEO // UPDATE VIDEO
router.put("/:id/video", [videoUpload.single("file"), addLogger, isTokenValid, Video.id, Video.channel, validator, doVideoExistsParam, doChannelExistBody, isOwner ], updateVideo); router.put("/:id/video", [videoUpload.single("file"), addLogger, isTokenValid, Video.id, Video.channel, validator, doVideoExistsParam, doChannelExistBody, isOwner ], updateVideo);
@ -53,10 +56,15 @@ router.get("/:id/like", [addLogger, isTokenValid, Video.id, validator, doVideoEx
router.put("/:id/tags", [addLogger, isTokenValid, Video.id, Video.tags, validator, doVideoExistsParam, isOwner], addTags); router.put("/:id/tags", [addLogger, isTokenValid, Video.id, Video.tags, validator, doVideoExistsParam, isOwner], addTags);
// GET SIMILAR VIDEOS // GET SIMILAR VIDEOS
router.get("/:id/similar", [addLogger, Video.id, validator, doVideoExistsParam], getSimilarVideos); router.get("/:id/similar", [addLogger, Video.id, validator, doVideoExistsParam, hasAccess], getSimilarVideos);
// ADD VIEWS // ADD VIEWS
router.get("/:id/views", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], addViews); router.get("/:id/views", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam, hasAccess], addViews);
// GET LIKE PER DAY
router.get("/:id/likes/day", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], getLikesPerDay);
// UPDATE AUTHORIZED USERS
router.put("/:id/authorized-users", [addLogger, isTokenValid, Video.id, VideoCreate.authorizedUsers, validator, doVideoExistsParam, isOwner, doAuthorizedUserExists], updateAuthorizedUsers);
export default router; export default router;

BIN
backend/app/uploads/profiles/astri6.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
backend/app/uploads/profiles/astri7.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
backend/app/uploads/profiles/astria.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

BIN
backend/app/uploads/profiles/astria2.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
backend/app/uploads/profiles/astria3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
backend/app/uploads/profiles/test.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

BIN
backend/app/uploads/videos/946FFC1D2D8C189D.mp4

Binary file not shown.

103
backend/app/utils/database.js

@ -1,18 +1,31 @@
import pg from "pg"; import pg from "pg";
// Create a connection pool instead of individual connections
const pool = new pg.Pool({
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
host: process.env.POSTGRES_HOST,
database: process.env.POSTGRES_DB,
port: 5432,
max: 30, // Increased maximum number of connections in the pool
idleTimeoutMillis: 30000, // Close idle connections after 30 seconds
connectionTimeoutMillis: 10000, // Increased timeout to 10 seconds
acquireTimeoutMillis: 10000, // Wait up to 10 seconds for a connection
});
export async function getClient() { export async function getClient() {
const client = new pg.Client({ // Use pool.connect() instead of creating new clients
user: process.env.DB_USER, return await pool.connect();
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
port: 5432
})
await client.connect();
return client;
} }
// Graceful shutdown
process.on('SIGINT', () => {
pool.end(() => {
console.log('Pool has ended');
process.exit(0);
});
});
export async function initDb() { export async function initDb() {
const client = await getClient(); const client = await getClient();
@ -20,10 +33,14 @@ export async function initDb() {
try { try {
let query = `CREATE TABLE IF NOT EXISTS users ( let query = `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL, email VARCHAR(255),
username VARCHAR(255) NOT NULL, username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255),
picture VARCHAR(255) picture VARCHAR(255),
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
github_id VARCHAR(255) UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);`; );`;
await client.query(query); await client.query(query);
@ -31,7 +48,7 @@ export async function initDb() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
owner INTEGER NOT NULL REFERENCES users(id) owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)` )`
await client.query(query); await client.query(query);
@ -40,7 +57,7 @@ export async function initDb() {
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
thumbnail VARCHAR(255) NOT NULL, thumbnail VARCHAR(255) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
channel INTEGER NOT NULL REFERENCES channels(id), channel INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
visibility VARCHAR(50) NOT NULL DEFAULT 'public', visibility VARCHAR(50) NOT NULL DEFAULT 'public',
file VARCHAR(255) NOT NULL, file VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL,
@ -53,37 +70,38 @@ export async function initDb() {
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
content TEXT NOT NULL, content TEXT NOT NULL,
author INTEGER NOT NULL REFERENCES users(id), author INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id), video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW() created_at TIMESTAMP NOT NULL DEFAULT NOW()
)`; )`;
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS likes ( query = `CREATE TABLE IF NOT EXISTS likes (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
owner INTEGER NOT NULL REFERENCES users(id), owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);`; );`;
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS playlists ( query = `CREATE TABLE IF NOT EXISTS playlists (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
owner INTEGER NOT NULL REFERENCES users(id) owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`; )`;
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS playlist_elements ( query = `CREATE TABLE IF NOT EXISTS playlist_elements (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
video INTEGER NOT NULL REFERENCES videos(id), video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
playlist INTEGER NOT NULL REFERENCES playlists(id) playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE
)`; )`;
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS subscriptions ( query = `CREATE TABLE IF NOT EXISTS subscriptions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
channel INTEGER NOT NULL REFERENCES channels(id), channel INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
owner INTEGER NOT NULL REFERENCES users(id) owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`; )`;
await client.query(query); await client.query(query);
@ -95,21 +113,50 @@ export async function initDb() {
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS video_tags ( query = `CREATE TABLE IF NOT EXISTS video_tags (
video INTEGER NOT NULL REFERENCES videos(id), video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
tag INTEGER NOT NULL REFERENCES tags(id) tag INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
)` )`
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS history ( query = `CREATE TABLE IF NOT EXISTS history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id), video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
viewed_at TIMESTAMP NOT NULL DEFAULT NOW() viewed_at TIMESTAMP NOT NULL DEFAULT NOW()
)`; )`;
await client.query(query); await client.query(query);
query = `CREATE TABLE IF NOT EXISTS email_verification (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS video_authorized_users (
id SERIAL PRIMARY KEY,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`;
await client.query(query);
// Add GitHub OAuth columns if they don't exist
try {
await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS github_id VARCHAR(255) UNIQUE`);
await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW()`);
await client.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW()`);
await client.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`);
await client.query(`ALTER TABLE users ALTER COLUMN password DROP NOT NULL`);
} catch (e) {
console.log("OAuth columns already exist or error adding them:", e.message);
}
} catch (e) { } catch (e) {
console.error("Error initializing database:", e); console.error("Error initializing database:", e);
} finally {
client.release();
} }
} }

38
backend/app/utils/mail.js

@ -0,0 +1,38 @@
import nodemailer from "nodemailer";
function getTransporter() {
return nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.GMAIL_USER,
pass: "yuuu kvoi ytrf blla",
},
});
};
/**
* Send an email
* @param {string} to
* @param {string} subject
* @param {string} text
* @param {string} html
* @return {Promise<object>}
*/
export function sendEmail(to, subject, text, html = null) {
const transporter = getTransporter();
const mailOptions = {
from: process.env.GMAIL_USER,
to,
subject,
text,
};
// Add HTML if provided
if (html) {
mailOptions.html = html;
}
return transporter.sendMail(mailOptions);
}

10854
backend/logs/access.log

File diff suppressed because it is too large

466
backend/package-lock.json

@ -14,11 +14,18 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.30.1", "moment": "^2.30.1",
"multer": "^2.0.1", "multer": "^2.0.1",
"pg": "^8.16.3" "nodemailer": "^7.0.5",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"pg": "^8.16.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"yaml": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"chai": "^5.2.0", "chai": "^5.2.0",
@ -31,6 +38,50 @@
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5", "version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
@ -481,6 +532,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -795,6 +852,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@ -826,6 +890,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -1034,7 +1104,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asap": { "node_modules/asap": {
@ -1065,9 +1134,17 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": { "node_modules/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -1119,7 +1196,6 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -1217,6 +1293,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@ -1467,6 +1549,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/component-emitter": { "node_modules/component-emitter": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@ -1481,7 +1572,6 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
@ -1666,6 +1756,18 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.0.1", "version": "17.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz",
@ -1863,6 +1965,15 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -1924,6 +2035,46 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express-validator": { "node_modules/express-validator": {
"version": "7.2.1", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
@ -2094,6 +2245,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2354,6 +2511,17 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2531,7 +2699,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -2605,6 +2772,13 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -2617,6 +2791,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@ -2641,6 +2822,12 @@
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lodash.once": { "node_modules/lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@ -2822,7 +3009,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@ -3104,6 +3290,15 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.10", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@ -3143,6 +3338,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3176,6 +3377,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -3185,6 +3395,13 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -3233,6 +3450,63 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-github2": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz",
"integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3243,6 +3517,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3296,6 +3579,11 @@
"node": ">= 14.16" "node": ">= 14.16"
} }
}, },
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pg": { "node_modules/pg": {
"version": "8.16.3", "version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@ -3524,6 +3812,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/randombytes": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -4125,6 +4422,92 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-jsdoc/node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.28.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz",
"integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/time-span": { "node_modules/time-span": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz",
@ -4282,6 +4665,24 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -4311,6 +4712,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/validator": { "node_modules/validator": {
"version": "13.12.0", "version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
@ -4704,6 +5114,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -4806,6 +5228,36 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
} }
} }
} }

9
backend/package.json

@ -18,11 +18,18 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.30.1", "moment": "^2.30.1",
"multer": "^2.0.1", "multer": "^2.0.1",
"pg": "^8.16.3" "nodemailer": "^7.0.5",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"pg": "^8.16.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"yaml": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"chai": "^5.2.0", "chai": "^5.2.0",

17
backend/requests/top-tags-videos.http

@ -0,0 +1,17 @@
### Get all videos from the three most watched tags
GET http://127.0.0.1:8000/api/videos/top-tags/videos
### Alternative localhost URL
GET http://localhost:8000/api/videos/top-tags/videos
### With frontend URL (if using nginx proxy)
GET http://localhost/api/videos/top-tags/videos
###
# This endpoint returns:
# - topTags: The 3 most used tags with their usage count
# - videos: All public videos that have any of these top 3 tags
# - totalVideos: Count of videos returned
#
# Videos are ordered by popularity score (calculated from views, likes, comments)
# and then by release date (newest first)

6
backend/requests/video.http

@ -1,4 +1,4 @@
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJhc3RyaWEiLCJpYXQiOjE3NTI5NDQxMDF9.dbGCL8qqqLR3e7Ngns-xPfZAvp0WQzAbjaEHjDVg1HI @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhc3RyaWEiLCJpYXQiOjE3NTMzODAyNjB9._rUcieo3acJp6tjQao7V3UQz0_ngHuB2z36_fG_fIX8
### UPDATE VIDEO ### UPDATE VIDEO
PUT http://127.0.0.1:8000/api/videos/3 PUT http://127.0.0.1:8000/api/videos/3
@ -16,7 +16,7 @@ GET http://127.0.0.1:8000/api/videos/14/like
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### ADD TAGS ### ADD TAGS
PUT http://127.0.0.1:8000/api/videos/2/tags PUT http://127.0.0.1:8000/api/videos/3/tags
Content-Type: application/json Content-Type: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
@ -26,7 +26,7 @@ Authorization: Bearer {{token}}
"Create Mod", "Create Mod",
"Redstone" "Redstone"
], ],
"channel": 2 "channel": 1
} }
### ###

64
backend/server.js

@ -1,4 +1,7 @@
import express from "express"; import express from "express";
import swaggerui from "swagger-ui-express";
import fs from "fs";
import YAML from "yaml";
import dotenv from "dotenv"; import dotenv from "dotenv";
import UserRoute from "./app/routes/user.route.js"; import UserRoute from "./app/routes/user.route.js";
import ChannelRoute from "./app/routes/channel.route.js"; import ChannelRoute from "./app/routes/channel.route.js";
@ -11,19 +14,71 @@ import PlaylistRoute from "./app/routes/playlist.route.js";
import {initDb} from "./app/utils/database.js"; import {initDb} from "./app/utils/database.js";
import MediaRoutes from "./app/routes/media.routes.js"; import MediaRoutes from "./app/routes/media.routes.js";
import SearchRoute from "./app/routes/search.route.js"; import SearchRoute from "./app/routes/search.route.js";
import OAuthRoute from "./app/routes/oauth.route.js";
import session from "express-session";
import passport from "passport";
import { Strategy as GitHubStrategy } from "passport-github2";
console.clear(); console.clear();
dotenv.config(); dotenv.config();
console.log(process.env)
const app = express(); const app = express();
// INITIALIZE DATABASE // Increase body size limits for file uploads
app.use(express.urlencoded({extended: true, limit: '500mb'}));
app.use(express.json({limit: '500mb'}));
app.use(session({
secret: "your-secret",
resave: false,
saveUninitialized: false,
}));
app.use(express.urlencoded({extended: true})); // Swagger setup
app.use(express.json()); const file = fs.readFileSync('./swagger.yaml', 'utf8');
app.use(cors()) const swaggerDocument = YAML.parse(file);
// Swagger UI options
const swaggerOptions = {
explorer: true,
swaggerOptions: {
requestInterceptor: (req) => {
req.headers['Content-Type'] = 'application/json';
return req;
}
}
};
app.use('/api/api-docs', swaggerui.serve, swaggerui.setup(swaggerDocument, swaggerOptions));
// --- Passport setup ---
app.use(passport.initialize());
app.use(passport.session());
// Serialize user -> session
passport.serializeUser((user, done) => {
done(null, user);
});
// Deserialize session -> user
passport.deserializeUser((obj, done) => {
done(null, obj);
});
// --- GitHub Strategy ---
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
callbackURL: "https://localhost/api/oauth/callback",
scope: ["user:email"]
},
(accessToken, refreshToken, profile, done) => {
return done(null, profile);
}
));
// ROUTES // ROUTES
app.use("/api/users/", UserRoute); app.use("/api/users/", UserRoute);
@ -34,6 +89,7 @@ app.use("/api/playlists", PlaylistRoute);
app.use("/api/recommendations", RecommendationRoute); app.use("/api/recommendations", RecommendationRoute);
app.use("/api/media", MediaRoutes); app.use("/api/media", MediaRoutes);
app.use("/api/search", SearchRoute); app.use("/api/search", SearchRoute);
app.use("/api/oauth", OAuthRoute);
const port = process.env.PORT; const port = process.env.PORT;

2317
backend/swagger.yaml

File diff suppressed because it is too large

21
backend/tools.js

@ -17,14 +17,13 @@ async function flushDatabase() {
port: 5432 port: 5432
}); });
await client.connect(); try {
await client.connect();
await client.query('TRUNCATE TABLE users CASCADE');
console.log('Database flushed successfully.');
await client.query('TRUNCATE TABLE users CASCADE', (err, res) => { } catch (err) {
if (err) { console.error('Error flushing database:', err);
console.error('Error flushing database:', err); } finally {
} else { await client.end();
console.log('Database flushed successfully.'); }
} }
});}

145
checklist.md

@ -22,122 +22,125 @@
## **ADMINISTRATION CHAÎNE FREETUBE** (55 points) ## **ADMINISTRATION CHAÎNE FREETUBE** (55 points)
### Mettre en ligne une vidéo (30 points) ### Mettre en ligne une vidéo (30 points) - 27/30 points ✅
- [x] Upload média vidéo (10 points) - [x] Upload média vidéo (10 points)
- [x] Upload miniature vidéo (2 points) - [x] Upload miniature vidéo (2 points)
- [x] Titre (2 points) - [x] Titre (2 points)
- [x] Description (2 points) - [x] Description (2 points)
- [x] Date de mise en ligne automatique (2 points) - [x] Date de mise en ligne automatique (2 points)
- [ ] Mots-clefs/hashtags jusqu'à 10 (2 points) - [ ] Mots-clefs/hashtags jusqu'à 10 (2 points)
- [ ] Visibilité publique/privée (5 points) - [x] Visibilité publique/privée (5 points)
- [ ] Génération lien partageable (5 points) - [x] Génération lien partageable (5 points) - via slug système
### Gestion vidéos existantes ### Gestion vidéos existantes (15 points) - 10/15 points
- [ ] Éditer une vidéo existante (5 points) - [x] Éditer une vidéo existante (5 points)
- [ ] Changer la visibilité (5 points) - [x] Changer la visibilité (5 points)
- [x] Supprimer une vidéo (5 points) - [x] Supprimer une vidéo (5 points)
### Statistiques ### Statistiques (10 points) - 5/10 points
- [ ] Statistiques par vidéo (vues, likes, commentaires) (5 points) - [x] Statistiques par vidéo (vues, likes, commentaires) (5 points)
- [ ] Statistiques globales de la chaîne (5 points) - [ ] Statistiques globales de la chaîne (5 points)
## **PAGE ACCUEIL** (30 points) ## **RECHERCHE ET NAVIGATION** (20 points) ✅
### Utilisateur authentifié (15 points) ### Système de recherche (20 points) - 20/20 points ✅
- [x] Recherche par titre de vidéo (8 points)
- [x] Recherche par chaîne (8 points)
- [x] Interface de recherche fonctionnelle (4 points)
## **PAGE ACCUEIL** (30 points) - 10/30 points
### Utilisateur authentifié (15 points) - 5/15 points
- [x] Section Tendances (contenu avec plus d'interactions récentes) (5 points)
- [ ] Section Recommendations (contenu similaire non vu) (5 points) - [ ] Section Recommendations (contenu similaire non vu) (5 points)
- [ ] Section "À consulter plus tard" (5 points) - [ ] Section "À consulter plus tard" (5 points)
- [ ] Section Tendances (contenu avec plus d'interactions récentes) (5 points)
### Utilisateur non-authentifié (15 points) ### Utilisateur non-authentifié (15 points) - 5/15 points
- [x] Section Tendances (5 points)
- [ ] Section Recommendations (3 mots-clefs les plus utilisés) (5 points) - [ ] Section Recommendations (3 mots-clefs les plus utilisés) (5 points)
- [ ] Section Tendances (5 points)
- [ ] Section Top créateurs (plus d'abonnés) (5 points) - [ ] Section Top créateurs (plus d'abonnés) (5 points)
## **PAGE ABONNEMENTS** (10 points) ## **PAGE ABONNEMENTS** (10 points)
- [ ] Fil d'actualité des abonnements (8 points) - [ ] Fil d'actualité des abonnements (8 points)
- [ ] Redirection pour non-authentifiés (2 points) - [ ] Redirection pour non-authentifiés (2 points)
## **PAGE UTILISATEUR** (15 points) ## **PAGE UTILISATEUR** (15 points) - 5/15 points
- [ ] Historique des vidéos regardées (10 points) - [ ] Historique des vidéos regardées (10 points)
- [ ] Gestion et liste des playlists (5 points) - [x] Gestion et liste des playlists (5 points)
## **PAGE PLAYLIST** (10 points) ## **PAGE PLAYLIST** (10 points) - 8/10 points ✅
- [ ] Affichage nom playlist et vidéos - [x] Affichage nom playlist et vidéos (4 points)
- [ ] Tri par date d'ajout - [x] Tri par date d'ajout (2 points)
- [ ] Navigation depuis page utilisateur - [x] Navigation depuis page utilisateur (2 points)
- [ ] Interface utilisateur complète (2 points)
## **PAGE VIDÉO** (50 points) ## **PAGE VIDÉO** (50 points) - 27/50 points
### Lecteur vidéo (20 points) ### Lecteur vidéo (20 points) - 10/20 points
- [x] Média visualisable (10 points) - [x] Média visualisable (10 points)
- [ ] Bouton Pause (2 points) - [ ] Bouton Pause (2 points)
- [ ] Bouton Play (2 points) - [ ] Bouton Play (2 points)
- [ ] Saut XX secondes en avant (3 points) - [ ] Saut XX secondes en avant (3 points)
- [ ] Saut XX secondes en arrière (3 points) - [ ] Saut XX secondes en arrière (3 points)
### Informations vidéo (20 points) ### Informations vidéo (20 points) - 12/20 points
- [x] Titre de la vidéo (2 points) - [x] Titre de la vidéo (2 points)
- [x] Description (2 points) - [x] Description (2 points)
- [x] Nom de la chaîne (2 points) - [x] Nom de la chaîne (2 points)
- [x] Compteur "J'aime" (2 points)
- [x] Compteur vues (2 points)
- [x] Bouton "J'aime" (2 points)
- [ ] Compteur abonnés (2 points) - [ ] Compteur abonnés (2 points)
- [ ] Compteur "J'aime" (2 points) - [ ] Bouton "S'abonner" (6 points)
- [ ] Bouton "J'aime" (5 points)
- [ ] Bouton "S'abonner" (5 points)
### Commentaires (10 points) ✅ ### Commentaires (10 points) ✅
- [x] Créer un commentaire (5 points) - [x] Créer un commentaire (5 points)
- [x] Voir les commentaires (5 points) - [x] Voir les commentaires (5 points)
### Recommendations (5 points) ### Recommendations (5 points)
- [ ] Section recommendations/tendances selon authentification - [ ] Section recommendations/tendances selon authentification (5 points)
## **FONCTIONNALITÉS SYSTÈME** ## **FONCTIONNALITÉS SYSTÈME**
### Système de playlists (15 points) ✅
- [x] Routes créer/gérer playlists (8 points)
- [x] Ajouter/retirer vidéos des playlists (4 points)
- [x] Playlist "À regarder plus tard" automatique (3 points)
### Système "J'aime" (10 points) ✅
- [x] Routes like/unlike vidéo (5 points)
- [x] Compteur de likes par vidéo (3 points)
- [x] Interface utilisateur (2 points)
### Système d'abonnements (18 points estimés) ### Système d'abonnements (18 points estimés)
- [ ] Routes s'abonner/désabonner à une chaîne - [ ] Routes s'abonner/désabonner à une chaîne (8 points)
- [ ] Modèle de données abonnements - [ ] Modèle de données abonnements (5 points)
- [ ] Compteur d'abonnés par chaîne - [ ] Compteur d'abonnés par chaîne (5 points)
### Système "J'aime" (10 points estimés) ### Système de tags/mots-clefs (8 points estimés)
- [ ] Routes aimer/ne plus aimer vidéo - [ ] Modèle de données tags (3 points)
- [ ] Modèle de données likes - [ ] Association vidéos-tags (3 points)
- [ ] Mise à jour compteur likes - [ ] Interface gestion tags (2 points)
### Gestion playlists (25 points estimés) ## **SÉCURITÉ ET MIDDLEWARE**
- [ ] Routes créer/supprimer playlists - [x] Middleware d'authentification JWT
- [ ] Ajout/suppression vidéos dans playlists - [x] Validation des données d'entrée
- [ ] Playlist "À consulter plus tard" par défaut - [x] Gestion des erreurs
- [ ] Affichage contenu playlist - [x] Upload sécurisé de fichiers
- [x] Logging des actions
### Historique utilisateur (10 points estimés)
- [ ] Enregistrement automatique vidéos regardées ## **INFRASTRUCTURE**
- [ ] Routes consultation historique - [x] Configuration Docker
- [x] Base de données PostgreSQL
### Système recommandations (15 points estimés) - [x] Serveur de fichiers médias
- [ ] Algorithme pour utilisateurs authentifiés - [x] Tests unitaires
- [ ] Recommendations mots-clés pour non-authentifiés
- [ ] Calcul tendances (interactions récentes)
### Compteurs et statistiques
- [ ] Compteur de vues par vidéo
- [ ] Mise à jour automatique lors du visionnage
- [ ] Statistiques complètes par vidéo et chaîne
## **POINTS CRITIQUES POUR ÉVITER L'AJOURNEMENT**
- **Fonctionnalités : 120/200 points minimum**
- **Qualité code : 60/100 points minimum**
- **Documentation : 30/50 points minimum**
- **Déploiement : 30/50 points minimum**
## **AMÉLIORATIONS TECHNIQUES**
- [ ] Validation robuste données d'entrée
- [ ] Gestion d'erreurs appropriée
- [ ] Middleware de sécurité complet
- [ ] Tests unitaires (fichiers présents à compléter)
- [ ] Architecture REST propre
- [ ] Optimisation performances base de données
--- ---
## **SCORE ESTIMÉ**
**Status actuel estimé : ~102/200 points fonctionnalités** **Backend: ~127/183 points (69%)**
**Objectif prioritaire : Atteindre 120 points minimum** **Points prioritaires manquants:**
- OAuth2 (10 points)
- Système d'abonnements (18 points)
- Tags/mots-clefs (8 points)
- Statistiques globales chaîne (5 points)
- Contrôles lecteur vidéo (10 points)

4
create_db.sql

@ -0,0 +1,4 @@
CREATE ROLE 'sacha' WITH PASSWORD 'sacha';
CREATE DATABASE 'sacha' OWNER 'sacha';

4
db.sql

@ -0,0 +1,4 @@
CREATE ROLE 'sacha' WITH PASSWORD 'sacha';
CREATE DATABASE 'sacha' OWNER 'sacha';

68
default.conf

@ -0,0 +1,68 @@
server {
server_name localhost;
listen 80;
return 301 https://$host$request_uri;
}
server {
server_name localhost;
listen 443 ssl;
root /usr/share/nginx/html;
index index.html index.htm;
# Allow large file uploads for videos (up to 500MB)
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# API routes - proxy to backend (MUST come before static file rules)
location /api/ {
# Handle preflight OPTIONS requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
proxy_buffering off;
# CORS headers for actual requests
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# Also set timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# Static assets - NO CACHING for development
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Handle React Router - all other routes should serve index.html
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files $uri $uri/ /index.html;
}
}

100
deploy.sh

@ -0,0 +1,100 @@
#!/bin/bash
if [[ $EUID -ne 0 ]]; then
echo "Ce script doit être lancé avec les droits root"
exit 1
fi
pwd=$PWD
# Environment variables
echo -e "\033[0;32m Création des variables d'environnement \033[0m"
echo -e "\033[1;33m Nom d'utilisateur de la base de données \033[0m"
read POSTGRES_USER
echo -e "\033[1;33m Mot de passe de la base de données \033[0m"
read POSTGRES_PASSWORD
echo -e "\033[1;33m Nom de la base de données \033[0m"
read POSTGRES_DB
echo -e "\033[1;33m Clé d'encryption des JWTs \033[0m"
read JWT_SECRET
echo -e "\033[1;33m Utilisateur Gmail \033[0m"
read GMAIL_USER
echo -e "\033[1;33m Mot de passe de l'application Gmail \033[0m"
read GMAIL_PASSWORD
echo -e "\033[1;33m Url du site web \033[0m"
read FRONTEND_URL
echo -e "\033[1;33m Client ID de l'application Github OAuth \033[0m"
read GITHUB_ID
echo -e "\033[1;33m Client secret de l'application Github OAuth \033[0m"
read GITHUB_PASSWORD
touch $pwd/backend/.env
echo "
POSTGRES_USER=$POSTGRES_USER
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_DB=$POSTGRES_DB
POSTGRES_HOST=localhost
BACKEND_PORT=8000
JWT_SECRET=$JWT_SECRET
LOG_FILE=/var/log/freetube/access.log
GMAIL_USER=$GMAIL_USER
GMAIL_PASSWORD=$GMAIL_PASSWORD
FRONTEND_URL=$FRONTEND_URL
GITHUB_ID=$GITHUB_ID
GITHUB_SECRET=$GITHUB_PASSWORD
" > $pwd/backend/.env
# Install dependencies (NodeJS 22/PostgreSQL/Nginx)
echo -e "\033[0;32m Installation des dépendances... \033[0m"
apt install postgresql nginx openssl curl &&
# Install NVM and NodeJS & NPM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash &&
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
\. "$HOME/.nvm/nvm.sh" &&
nvm install 22 &&
# Install node packages for backend
echo -e "\033[0;32m Installation des paquets NodeJS \033[0m"
cd $pwd/backend && npm install &&
cd $pwd/frontend && npm install &&
echo "Construction du frontend"
npx vite build
cd $pwd
# Create Nginx configuration
echo -e "\033[0;32m Création de la configuration Nginx \033[0m"
mkdir /etc/nginx/ssl/
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/nginx-selfsigned.key -out /etc/nginx/ssl/nginx-selfsigned.crt
touch /etc/nginx/conf.d/freetube.conf
cat $pwd/default.conf > /etc/nginx/conf.d/freetube.conf
echo -e "\033[0;32m Copie des fichiers vers /usr/share/nginx/html \033[0m"
rm /usr/share/nginx/html/index.html
mv $pwd/frontend/dist/* /usr/share/nginx/html/
# Create PostgreSQL database
echo -e "\033[0;32m Création de l'utilisateur $POSTGRES_USER \033[0m"
sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';"
sudo -u postgres psql -c "CREATE ROLE $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';"
sudo -u postgres psql -c "CREATE DATABASE $POSTGRES_DB OWNER $POSTGRES_USER;"
# Add log file
mkdir /var/log/freetube
touch /var/log/freetube/access.log
systemctl enable nginx
systemctl enable postgresql
systemctl start nginx
systemctl start postgresql

50
developpement.yaml

@ -1,32 +1,35 @@
services: services:
backend: resit_backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
network: host network: host
container_name: resit_backend container_name: resit_backend
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "8000:8000"
environment: environment:
DB_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST} POSTGRES_HOST: db
DB_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE} LOG_FILE: ${LOG_FILE}
PORT: ${BACKEND_PORT} PORT: ${BACKEND_PORT}
GMAIL_USER: ${GMAIL_USER}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
FRONTEND_URL: ${FRONTEND_URL}
volumes: volumes:
- ./backend/logs:/var/log/freetube - ./backend/logs:/var/log/freetube
- ./backend/app/uploads:/app/app/uploads - ./backend:/app
- ./backend/app/utils/wait-for-it.sh:/wait-for-it.sh
depends_on: depends_on:
- db db:
command: ["/wait-for-it.sh", "${POSTGRES_HOST}:5432", "--", "npm", "start"] condition: service_healthy
db: db:
image: postgres:latest image: postgres:latest
container_name: resit_db
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
@ -35,19 +38,28 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend: frontend:
build: image: nginx:alpine
context: ./frontend container_name: resit_frontend
dockerfile: Dockerfile
network: host
ports: ports:
- "5173:5173" - "80:80"
- "443:443"
volumes: volumes:
- ./frontend/:/app - ./frontend/dist:/usr/share/nginx/html
- /app/node_modules - ./frontend/nginx-selfsigned.crt:/etc/nginx/ssl/nginx-selfsigned.crt
- ./frontend/nginx-selfsigned.key:/etc/nginx/ssl/nginx-selfsigned.key
- ./frontend/default.conf:/etc/nginx/conf.d/default.conf
environment:
- VITE_API_BASE_URL=https://localhost/api
depends_on: depends_on:
- backend - resit_backend
volumes: volumes:
db_data: db_data:

36
docker-compose.yaml

@ -9,18 +9,24 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DB_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST} POSTGRES_HOST: db
DB_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE} LOG_FILE: ${LOG_FILE}
PORT: ${BACKEND_PORT} PORT: ${BACKEND_PORT}
GMAIL_USER: ${GMAIL_USER}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
FRONTEND_URL: ${FRONTEND_URL}
volumes: volumes:
- ./backend/logs:/var/log/freetube - ./backend/logs:/var/log/freetube
- ./backend:/app - ./backend:/app
depends_on: depends_on:
- db db:
condition: service_healthy
db: db:
image: postgres:latest image: postgres:latest
@ -32,15 +38,25 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend: frontend:
image: nginx:latest build:
network_mode: host context: ./frontend
dockerfile: Dockerfile
container_name: resit_frontend
ports: ports:
- "80:80" - "80:80"
volumes: - "443:443"
- ./frontend/dist:/usr/share/nginx/html environment:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf - VITE_API_BASE_URL=https://localhost/api
depends_on:
- resit_backend
volumes: volumes:
db_data: db_data:

49
documentation.md

@ -0,0 +1,49 @@
# 3RESIT Freetube
## Sommaire
1) Introduction au projet
2) Organisation
2) Les technologies utilisées
1) Le serveur
2) Le site web
3) La base de données
3) Le serveur
1) Les dépendances
2) Le fonctionnement
4) Le site web
1) Les dépendances
2) Le fonctionnement
5) La base de données
1) Diagramme des tables
6) Installation
1) Docker Compose
2) Script Shell
3) Manuelle
## Introduction
Pour ce projet il nous a été demandé de recréer une plateforme similaire a Youtube nommée Freetube. Cette application web devait être gratuite et sans publicités. Nous avions la main libre sur le choix de la pile technologique utilisée et sur l'organisation du projet.
## Organisation
J'ai commencé par faire un plan détaillés de toute les routes et vérifications a effectué pour chaque fonctionnalité, puis faire un plan de la base de données. Ceci allait définir toute la structure du projet.
Pour ce qui est du développement, j'ai choisis de procéder fonctionnalité par fonctionnalité en commençant par le serveur et la base de données pour les intégrer ensuite dans le site web. Pour séparer toute ces parties j'ai utilisé **git** en créant plusieurs branches, une par fonctionnalité.
## Les technologies utilisées
### Le serveur
Le serveur utilise **NodeJS**, j'ai choisis ce langage car il permet d'implementer une **API REST** efficacement grâce a son système d'asynchronisation natif très performant. Etant créer a partir du **Javascript** il est aussi plus simple a comprendre et a écrire. Même si NodeJS n'est pas le plus connu en terme de rapidité d'éxecution ce n'est pas un problème dans notre situation car nous travaillons avec une **API** qui ajoutera un temps de latence supplémentaire.
### Le site web
Le site web est programmé en **ReactJS** avec **Vite** ce qui donne un **backend** et un **frontend** dans le même langage ce qui permet une maintenance et des mises à jour plus simple. Tout comme NodeJS, ReactJS et créé a partir de Javascript, il bénéficie donc de la même intégration de l'asynchrone natif. Le système de **composant** de ReactJS permet aussi une gestion de mise à jour de l'interface en temps réel plus simple a mettre en place et évite la duplication de code.
### La base données
La base de données et une base **PostgreSQL**, un système basé sur le langage SQL largement connu. PostgreSQL est une alternative **OpenSource** à **MySQL**. Il possède une très bonne intégration du **JSON** très utilisé comme moyen d'envoyer des données via **requête HTTP** utilisé dans les API REST.
## Le serveur
### Les dépendances
Pour l'API REST j'ai choisis d'utiliser **ExpressJS** couplé avec **express-validator**

2
freetube.sh

@ -0,0 +1,2 @@
#!/bin/bash
node ./backend/server.js

7
frontend/Docker.Development

@ -0,0 +1,7 @@
# Build stage
FROM node:22-alpine3.20 as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

14
frontend/Dockerfile

@ -1,9 +1,17 @@
FROM node:21-alpine3.20 # Build stage
FROM node:22-alpine3.20 as build-stage
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
EXPOSE 5173 RUN npm run build
CMD ["npm", "run", "dev"]
# Production stage
FROM nginx:alpine
RUN mkdir -p /etc/nginx/ssl
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY default.conf /etc/nginx/conf.d/default.conf
COPY nginx-selfsigned.crt nginx-selfsigned.key /etc/nginx/ssl/
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

68
frontend/default.conf

@ -0,0 +1,68 @@
server {
server_name localhost;
listen 80;
return 301 https://$host$request_uri;
}
server {
server_name localhost;
listen 443 ssl;
root /usr/share/nginx/html;
index index.html index.htm;
# Allow large file uploads for videos (up to 500MB)
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# API routes - proxy to backend (MUST come before static file rules)
location /api/ {
# Handle preflight OPTIONS requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
proxy_pass http://resit_backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
proxy_buffering off;
# CORS headers for actual requests
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# Also set timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# Static assets - NO CACHING for development
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Handle React Router - all other routes should serve index.html
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files $uri $uri/ /index.html;
}
}

23
frontend/nginx-selfsigned.crt

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID5zCCAs+gAwIBAgIUXzNzqa/12lyIcoxXf+v371J3fWkwDQYJKoZIhvcNAQEL
BQAwgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQIDAhOb3JtYW5keTENMAsGA1UEBwwE
Q2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTATBgNVBAMMDFNhY2hhIEdVRVJJTjEn
MCUGCSqGSIb3DQEJARYYc2FjaGEuZ3VlcmluQHN1cGluZm8uY29tMB4XDTI1MDcy
MTEzMzgwMVoXDTI2MDcyMTEzMzgwMVowgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQI
DAhOb3JtYW5keTENMAsGA1UEBwwEQ2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTAT
BgNVBAMMDFNhY2hhIEdVRVJJTjEnMCUGCSqGSIb3DQEJARYYc2FjaGEuZ3Vlcmlu
QHN1cGluZm8uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvLg7
nR0UqRZ7UadhI8jrUjRMV1SZj+ljxEnV6tDOVMsvafsym1MhDZHb+cyv8769yqPv
CKtIOQKhMH0PkSqau8szNlF1Tg/1UzT+Mkd4zvLvGE5+aW/oDMg7E2LMJZuCyO4X
9SzWDVA5+b1QFIw6vvb3mCkUOtVDkOFreBBwryZKcWJ0b8o1hT60oB2wr18P14j0
0C2/TmHMtim0o4r3gKGvpatqt1fXJo0UlYOwTvfMrYhu2VHqsQ2qP7ocazXEWt5u
Alf1vNPkAenF0ZV/2UiaL41Q8GMoV1enDP7k7/qfgXvta/hOeYnLtmv5Qpi4XiWz
xKjSukTUD2sRtSX+YQIDAQABo1MwUTAdBgNVHQ4EFgQUVj9KtmjLFy4xWzkNI9Kq
NAxNsfUwHwYDVR0jBBgwFoAUVj9KtmjLFy4xWzkNI9KqNAxNsfUwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGpUPMoF/ASIqOOfX5anDtaPvnslj
DuEVbU7Uoyc4EuSD343DPV7iMUKoFvVLPxJJqMLhSo3aEGJyqOF6q3fvq/vX2VE7
9MhwS1t2DBGb5foWRosnT1EuqFU1/S0RJ/Y+GNcoY1PrUES4+r7zqqJJjwKOzneV
ktUVCdKl0C1gtw6W4Ajxse3fm9DNLxnZZXbyNqn+KbI8QdO0xSEl+gyiycvPu/NT
+EesdlFoYjO7gdA8dXkmu+Z7R61MYhE9Zvyop5KVMqgU8/Ym04UUWjWQYWWLMyuu
bxngE4XNEI5fhg+0e/I25xJJ9wVV/ZNAF4+XOylHz/CmU8V/SPKuGXBGHg==
-----END CERTIFICATE-----

28
frontend/nginx-selfsigned.key

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8uDudHRSpFntR
p2EjyOtSNExXVJmP6WPESdXq0M5Uyy9p+zKbUyENkdv5zK/zvr3Ko+8Iq0g5AqEw
fQ+RKpq7yzM2UXVOD/VTNP4yR3jO8u8YTn5pb+gMyDsTYswlm4LI7hf1LNYNUDn5
vVAUjDq+9veYKRQ61UOQ4Wt4EHCvJkpxYnRvyjWFPrSgHbCvXw/XiPTQLb9OYcy2
KbSjiveAoa+lq2q3V9cmjRSVg7BO98ytiG7ZUeqxDao/uhxrNcRa3m4CV/W80+QB
6cXRlX/ZSJovjVDwYyhXV6cM/uTv+p+Be+1r+E55icu2a/lCmLheJbPEqNK6RNQP
axG1Jf5hAgMBAAECggEAAj+hmDRx6jafAAf67sqi3ZgEGEmBkXNeeLGBTPc/qhxd
ip6krTELnz8TE26RG5LYXzslasUNrn42nIImvBT5ZkcjcosKpWfEqQEAjc1PQovC
9eyKnKfw4TpUvvmiveT4T98vCYEOOqHE0/WTdlOoaBY/f+sZKQYu+1NMtAjFcg2r
vVqwsZb5vGyh7CKmIHZnz3UP8P+7G5digiNRne18pGnE2oTnSoQ3/QIqUWBs69DS
k5ew+CSyTLiUFFnMnE4adwyg6wAud5fBlzowF6UF2agToX7pxEaGxGvpBGG034kk
1UXaB/d5YwcsBeH+x5cNMLKZy4zqjoxEEW31Q466NQKBgQDtKk1R/slpTpRqvtBT
NC7InvjcCBXkXttylQHJRN9glqhmflEOe8iMW1/qRwBPlQgK1wq/sXySanVv2+gO
JGq8XNRLbHyG3YRyshdnJHP1HoWQE0uedD/rfqgkNaW5S1IvHrD7Q7tOvCrF+KbS
612pmIgNVzn+inafDXPhMZc4pQKBgQDLtQGAu2eK58ewndyL8+7+VHZSTEtKpt+h
G/U/ccv+6NGqdxI5YUkrJ7k6vV81VeRMvmN9uUS/i8znORFQmm6noRVkhXytwW5B
HXq2co4WRvv9b/XqcqS0GSYVPJ1u4YNH6lvtDZ4UWPyBzYl700GdHrGa+erT44yL
tnibHx9GDQKBgFW1J+Qt85O+9hvtgVPQU+fkq4K42VCCh0PNXavi2+cICyufEqPt
T/iJPQxpRE9+SD3CoPvNpHs1ReN60U3rEzenRIFNX2NNwoPAoHyBy/YVZac/keBd
mov8Zb9QM+fWtIiaytLDE3nMvph017T5ogucN+66SxcV6vBn6CzFwySRAoGAcUf2
Tv1ohkGAtgIDrLx5cmvL5NZSpHAKOpDOoHqLA/W66v4OX2RviRUtF7JJ6OIb9GWH
9Fl8Fr0KtKbyrw1CbevRdrYY8JN52bIoFJ+9zjupVHXXnookd5boq7SqpAe6ttpo
RnplJ1GZEiIXy4lemp6AC/vhD/YhqWxOw4zaGl0CgYBslhqVt5F0EHf94p7NrCuY
hNHKHaNaULYP0VXKefQamt/ssDuktqb6DNSIvx2rbbB5+33nTlLTya67gimY1lKt
WeNB33/yBkCjfSP/J5UDD9mE/oPLt3vAOkOUgMCfp2IpC2Wez1QGqLHS260zpotP
VpgalHuSWtn8D4nO2pk1hg==
-----END PRIVATE KEY-----

30
frontend/package-lock.json

@ -9,7 +9,9 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.3", "react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
@ -1004,6 +1006,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19", "version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
@ -1794,6 +1802,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2999,6 +3019,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

5
frontend/package.json

@ -5,13 +5,16 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build --watch", "build": "vite build",
"build:watch": "vite build --watch",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.3", "react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"

BIN
frontend/src/assets/img/background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

1
frontend/src/assets/svg/check.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="m10 15.586-3.293-3.293-1.414 1.414L10 18.414l9.707-9.707-1.414-1.414z"></path></svg>

After

Width:  |  Height:  |  Size: 176 B

1
frontend/src/assets/svg/exit_fullscreen.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H8v4H4v2h6zM8 20h2v-6H4v2h4zm12-6h-6v6h2v-4h4zm0-6h-4V4h-2v6h6z"></path></svg>

After

Width:  |  Height:  |  Size: 175 B

4
frontend/src/assets/svg/eye-slash.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<!--Boxicons v3.0 https://boxicons.com | License https://docs.boxicons.com/free-->
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>

After

Width:  |  Height:  |  Size: 764 B

4
frontend/src/assets/svg/eye.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<!--Boxicons v3.0 https://boxicons.com | License https://docs.boxicons.com/free-->
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>

After

Width:  |  Height:  |  Size: 487 B

1
frontend/src/assets/svg/fullscreen.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 5h5V3H3v7h2zm5 14H5v-5H3v7h7zm11-5h-2v5h-5v2h7zm-2-4h2V3h-7v2h5z"></path></svg>

After

Width:  |  Height:  |  Size: 174 B

1
frontend/src/assets/svg/infinite.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M17 7c-2.094 0-3.611 1.567-5.001 3.346C10.609 8.567 9.093 7 7 7c-2.757 0-5 2.243-5 5a4.98 4.98 0 0 0 1.459 3.534A4.956 4.956 0 0 0 6.99 17h.012c2.089-.005 3.605-1.572 4.996-3.351C13.389 15.431 14.906 17 17 17c2.757 0 5-2.243 5-5s-2.243-5-5-5zM6.998 15l-.008 1v-1c-.799 0-1.55-.312-2.114-.878A3.004 3.004 0 0 1 7 9c1.33 0 2.56 1.438 3.746 2.998C9.558 13.557 8.328 14.997 6.998 15zM17 15c-1.33 0-2.561-1.44-3.749-3.002C14.438 10.438 15.668 9 17 9c1.654 0 3 1.346 3 3s-1.346 3-3 3z"></path></svg>

After

Width:  |  Height:  |  Size: 585 B

4
frontend/src/assets/svg/play.svg

@ -1,3 +1 @@
<svg width="30" height="34" viewBox="0 0 30 34" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 6v12l10-6z"></path></svg>
<path d="M28.5 14.4019C30.5 15.5566 30.5 18.4434 28.5 19.5981L4.5 33.4545C2.5 34.6092 2.14642e-06 33.1658 2.24736e-06 30.8564L3.45873e-06 3.14359C3.55968e-06 0.834193 2.5 -0.609184 4.5 0.545517L28.5 14.4019Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 120 B

1
frontend/src/assets/svg/plus.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"></path></svg>

After

Width:  |  Height:  |  Size: 139 B

1
frontend/src/assets/svg/trash.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path><path d="M9 10h2v8H9zm4 0h2v8h-2z"></path></svg>

After

Width:  |  Height:  |  Size: 260 B

1
frontend/src/assets/svg/user.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2a5 5 0 1 0 5 5 5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3 3 3 0 0 1-3 3zm9 11v-1a7 7 0 0 0-7-7h-4a7 7 0 0 0-7 7v1h2v-1a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v1z"></path></svg>

After

Width:  |  Height:  |  Size: 253 B

33
frontend/src/components/Alert.jsx

@ -0,0 +1,33 @@
import { useEffect } from 'react';
export default function Alert({ type, message, onClose }) {
const alertClass = `cursor-pointer flex items-center gap-4 p-4 rounded-md text-white transition-opacity duration-300 ${ type === "error" ? "glassmorphism-red" : "glassmorphism-green" } `;
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 5000); // 5 seconds
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
className={alertClass}
role="alert"
onClick={onClose}
>
{
type === 'success' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white" ><path d="m10 15.586-3.293-3.293-1.414 1.414L10 18.414l9.707-9.707-1.414-1.414z"></path></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white rotate-45" ><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"></path></svg>
)
}
{message}
</div>
);
}

17
frontend/src/components/AlertList.jsx

@ -0,0 +1,17 @@
import Alert from "./Alert.jsx";
export default function AlertList({ alerts, onCloseAlert }) {
return (
<div className="fixed bottom-2.5 right-0 flex flex-col gap-2 mt-2 mr-4 z-40">
{alerts.map((alert, index) => (
<Alert
key={index}
type={alert.type}
message={alert.message}
onClose={() => onCloseAlert(alert)}
/>
))}
</div>
);
}

20
frontend/src/components/ChannelLastVideos.jsx

@ -0,0 +1,20 @@
import VideoCard from "./VideoCard.jsx";
export default function ChannelLastVideos({ videos }) {
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{
videos && videos.length > 0 ? (
videos.map((video) => (
<VideoCard video={video} />
)
)
) : (
<p>Aucune vidéo trouvée</p>
)
}
</div>
)
}

129
frontend/src/components/Comment.jsx

@ -1,8 +1,9 @@
import {useAuth} from "../contexts/AuthContext.jsx"; import {useAuth} from "../contexts/AuthContext.jsx";
import {useRef, useState} from "react"; import {useRef, useState} from "react";
import { updateComment, deleteComment } from "../services/comment.service.js";
export default function Comment({ comment, index, videoId, refetchVideo }) { export default function Comment({ comment, index, videoId, refetchVideo, doShowCommands=true, addAlert }) {
let {user, isAuthenticated} = useAuth(); let {user, isAuthenticated} = useAuth();
let commentRef = useRef(); let commentRef = useRef();
@ -17,23 +18,11 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
} }
const handleDelete = async (id) => { const handleDelete = async (id) => {
try { const token = localStorage.getItem('token');
const token = localStorage.getItem('token'); const response = await deleteComment(id, token, addAlert);
const response = await fetch(`/api/comments/${id}`, { if (response) {
method: 'DELETE', refetchVideo();
headers: { }
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
// Refresh the video data to update the comments list
refetchVideo();
}
} catch (error) {
console.error('Error deleting comment:', error);
}
} }
const handleEditSubmit = async (id, content) => { const handleEditSubmit = async (id, content) => {
@ -41,36 +30,25 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
alert("Comment cannot be empty"); alert("Comment cannot be empty");
return; return;
} }
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
try { if (!token) {
// Retrieve the token from localStorage navigation('/login');
const token = localStorage.getItem('token'); return;
}
if (!token) { const body = {
navigation('/login'); content: content,
return; video: videoId
} };
const response = await fetch(`/api/comments/${comment.id}`, { const response = await updateComment(id, body, token, addAlert);
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
content: content,
video: videoId
})
});
if (!response.ok) { if (response) {
throw new Error('Failed to post comment');
}
setEditMode(false); setEditMode(false);
} catch (error) {
console.error('Error posting comment:', error);
} }
} }
return ( return (
@ -82,39 +60,44 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
className="w-8 h-8 rounded-full object-cover mr-3" className="w-8 h-8 rounded-full object-cover mr-3"
/> />
<span className="font-montserrat font-bold text-white">{comment.username}</span> <span className="font-montserrat font-bold text-white">{comment.username}</span>
<span className="text-gray-400 ml-2 text-sm">{new Date(comment.created_at).toLocaleDateString()}</span>
</div> </div>
<p className={(editMode) ? editClass : "text-white focus:outline-none "} ref={commentRef}>{comment.content}</p> <p className={(editMode) ? editClass : "text-white focus:outline-none "} ref={commentRef}>{comment.content}</p>
<div className="flex gap-2 items-center mt-2"> {
{ isAuthenticated && user.username === comment.username && editMode === false ? ( doShowCommands && (
<button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}> <div className="flex gap-2 items-center mt-2">
Modifier { isAuthenticated && user.username === comment.username && editMode === false ? (
</button> <button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}>
) : null } Modifier
{ isAuthenticated && user.username === comment.username && editMode === false ? ( </button>
) : null }
<button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}> { isAuthenticated && user.username === comment.username && editMode === false ? (
Supprimer
</button> <button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}>
) : null Supprimer
} </button>
{ isAuthenticated && user.username === comment.username && editMode ? ( ) : null
<button className="text-green-500 mt-2 hover:underline" onClick={() => { }
setEditMode(false); { isAuthenticated && user.username === comment.username && editMode ? (
commentRef.current.contentEditable = false; <button className="text-green-500 mt-2 hover:underline" onClick={() => {
handleEditSubmit(comment.id, commentRef.current.textContent); setEditMode(false);
}}> commentRef.current.contentEditable = false;
Enregistrer handleEditSubmit(comment.id, commentRef.current.textContent);
</button> }}>
) : null } Enregistrer
{ isAuthenticated && user.username === comment.username && editMode ? ( </button>
<button className="text-gray-500 mt-2 hover:underline" onClick={() => { ) : null }
setEditMode(false); { isAuthenticated && user.username === comment.username && editMode ? (
commentRef.current.contentEditable = false; <button className="text-gray-500 mt-2 hover:underline" onClick={() => {
}}> setEditMode(false);
Annuler commentRef.current.contentEditable = false;
</button> }}>
) : null } Annuler
</div> </button>
) : null }
</div>
)
}
</div> </div>
) )

21
frontend/src/components/CreatorCard.jsx

@ -0,0 +1,21 @@
export default function CreatorCard({ creator }) {
const handleClick = () => {
window.location.href = `/manage-channel/${creator.id}`;
};
return (
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer" onClick={handleClick}>
<img
src={creator.profilePicture}
alt={creator.name}
className="w-[128px] aspect-square object-cover rounded-full mx-auto"
/>
<h2 className="text-2xl font-medium font-inter mt-3 text-white">{creator.name}</h2>
<div className="text-sm text-gray-400 mt-1">
{creator.subscribers} abonné(e)s
</div>
</div>
);
}

23
frontend/src/components/GitHubLoginButton.jsx

@ -0,0 +1,23 @@
import oauthService from '../services/oauthService';
export default function GitHubLoginButton({ className = "" }) {
const handleGitHubLogin = () => {
oauthService.loginWithGitHub();
};
return (
<button
onClick={handleGitHubLogin}
className={`flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-800 border border-transparent rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors ${className}`}
>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
Continuer avec GitHub
</button>
);
}

52
frontend/src/components/LinearGraph.jsx

@ -0,0 +1,52 @@
import {Line} from "react-chartjs-2";
import {Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend} from "chart.js";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
export default function LinearGraph({ dataToGraph, className, legend, borderColor="rgba(75, 192, 192, 1)" }) {
const prepareData = () => {
if (!dataToGraph || dataToGraph.length === 0) {
return {
labels: [],
datasets: []
};
}
const labels = dataToGraph.map(item => item.date.split("T")[0]);
const data = dataToGraph.map(item => item.count);
return {
labels,
datasets: [{
label: legend,
data,
fill: false,
pointRadius: 3,
borderColor: borderColor,
tension: 0,
stepped: false
}]
};
}
const data = prepareData();
const options = {
}
return (
<div className={className}>
<Line options={options} data={data} className="w-full border-red-500 " />
</div>
)
}

246
frontend/src/components/Navbar.jsx

@ -1,65 +1,209 @@
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import React, { useState } from 'react';
import {useNavigate} from "react-router-dom";
import AlertList from "./AlertList.jsx";
export default function Navbar({ isSearchPage = false }) { export default function Navbar({ isSearchPage = false, alerts = [], onCloseAlert = () => {} }) {
const { user, logout, isAuthenticated } = useAuth(); const { user, logout, isAuthenticated } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
const [internalAlerts, setInternalAlerts] = useState([]);
const [isNavbarOpen, setIsNavbarOpen] = useState(false);
const navigate = useNavigate();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
}; };
const handleKeypress = (event) => {
if (event.key === 'Enter') {
const searchQuery = event.target.value;
if (searchQuery) {
navigate(`/search?q=${encodeURIComponent(searchQuery)}&type=videos`);
}
}
}
const onCloseInternalAlert = (alertToRemove) => {
setInternalAlerts(internalAlerts.filter(alert => alert !== alertToRemove));
}
// Combine internal alerts with external alerts
const allAlerts = [...internalAlerts, ...alerts];
const handleCloseAlert = (alertToRemove) => {
// Check if it's an internal alert or external alert
if (internalAlerts.includes(alertToRemove)) {
onCloseInternalAlert(alertToRemove);
} else {
onCloseAlert(alertToRemove);
}
};
return ( return (
<nav className="flex justify-between items-center p-4 text-white absolute top-0 left-0 w-screen"> <>
<div> <nav className="flex justify-between items-center p-4 text-white top-0 left-0 w-screen z-50 relative">
<h1 className="font-montserrat text-5xl font-black"> <div>
<a href="/">FreeTube</a> <h1 className="font-montserrat text-4xl lg:text-5xl font-black">
</h1> <a href="/">FreeTube</a>
</div> </h1>
<div> </div>
<ul className="flex items-center space-x-[44px] font-montserrat text-2xl font-black"> <div className="hidden lg:block" >
{isAuthenticated ? ( <ul className="flex items-center space-x-[44px] font-montserrat text-2xl font-black">
<> <li><a href="/">Accueil</a></li>
<li><a href="/">Abonnements</a></li> {isAuthenticated ? (
<li> <>
<a href="/profile" className="flex items-center space-x-4"> <li><a href="/subscriptions">Abonnements</a></li>
<span className="text-2xl">{user?.username}</span> <li>
{user?.picture && ( <a href="/profile" className="flex items-center space-x-4">
<img <span className="text-2xl">{user?.username}</span>
src={`${user.picture}`} {user?.picture && (
alt="Profile" <img
className="w-8 h-8 rounded-full object-cover" src={`${user.picture}`}
/> alt="Profile"
)} className="w-8 h-8 rounded-full object-cover"
</a> />
</li> )}
<li> </a>
<button </li>
onClick={handleLogout} <li>
className="bg-red-600 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer hover:bg-red-700" <button
> onClick={handleLogout}
Déconnexion className="bg-red-600 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer hover:bg-red-700"
</button> >
Déconnexion
</button>
</li>
</>
) : (
<>
<li><a href="/login">Se connecter</a></li>
<li className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer">
<a href="/register">
<p>Créer un compte</p>
</a>
</li>
</>
)}
{ !isSearchPage && (
<li className="bg-glass backdrop-blur-glass border-glass-full border rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer w-[600/1920] h-[50px] px-3 flex items-center justify-center">
<input
type="text"
name="search"
id="searchbar"
placeholder="Rechercher"
className="font-inter text-2xl font-normal focus:outline-none bg-transparent"
onKeyPress={(e) => handleKeypress(e)}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="18.381" cy="13.619" r="12.119" stroke="white" strokeWidth="3"/>
<line x1="9.63207" y1="22.2035" x2="1.06064" y2="30.7749" stroke="white" strokeWidth="3"/>
</svg>
</li> </li>
</> )}
) : (
<> </ul>
<li><a href="/login">Se connecter</a></li> </div>
<li className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer">
<a href="/register"> <div className="lg:hidden">
<p>Créer un compte</p> {/* Hamburger menu for mobile */}
</a> <button
</li> onClick={() => setIsNavbarOpen(!isNavbarOpen)}
</> >
)} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-8 h-8">
<li className="bg-glass backdrop-blur-glass border-glass-full border rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer w-[600/1920] h-[50px] px-3 flex items-center justify-center"> <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
<input type="text" name="search" id="searchbar" placeholder="Rechercher" className="font-inter text-2xl font-normal focus:outline-none bg-transparent"/>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="18.381" cy="13.619" r="12.119" stroke="white" strokeWidth="3"/>
<line x1="9.63207" y1="22.2035" x2="1.06064" y2="30.7749" stroke="white" strokeWidth="3"/>
</svg> </svg>
</li> </button>
</ul> </div>
</div> </nav>
</nav>
{/* Mobile menu overlay - moved outside of nav container */}
{isNavbarOpen && (
<div className="fixed inset-0 bg-primary z-50 lg:hidden w-screen h-screen">
<div className="flex justify-between items-center p-4">
<h1 className="font-montserrat text-4xl font-black text-white">
<a href="/">FreeTube</a>
</h1>
<button
onClick={() => setIsNavbarOpen(false)}
className="text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-8 h-8">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-4 py-8">
<ul className="space-y-6 font-montserrat text-2xl font-black text-white">
<li><a href="/" onClick={() => setIsNavbarOpen(false)}>Accueil</a></li>
{isAuthenticated ? (
<>
<li><a href="/" onClick={() => setIsNavbarOpen(false)}>Abonnements</a></li>
<li>
<a href="/profile" className="flex items-center space-x-4" onClick={() => setIsNavbarOpen(false)}>
<span className="text-2xl">{user?.username}</span>
{user?.picture && (
<img
src={`${user.picture}`}
alt="Profile"
className="w-8 h-8 rounded-full object-cover"
/>
)}
</a>
</li>
<li>
<button
onClick={() => {
handleLogout();
setIsNavbarOpen(false);
}}
className="bg-red-600 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer hover:bg-red-700"
>
Déconnexion
</button>
</li>
</>
) : (
<>
<li><a href="/login" onClick={() => setIsNavbarOpen(false)}>Se connecter</a></li>
<li>
<a href="/register" onClick={() => setIsNavbarOpen(false)} className="block bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black">
Créer un compte
</a>
</li>
</>
)}
{!isSearchPage && (
<li className="mt-8">
<div className="bg-glass backdrop-blur-glass border-glass-full border rounded-sm text-white font-montserrat text-2xl font-black p-3 flex items-center">
<input
type="text"
name="search"
id="searchbar-mobile"
placeholder="Rechercher"
className="font-inter text-2xl font-normal focus:outline-none bg-transparent flex-1"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleKeypress(e);
setIsNavbarOpen(false);
}
}}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="18.381" cy="13.619" r="12.119" stroke="white" strokeWidth="3"/>
<line x1="9.63207" y1="22.2035" x2="1.06064" y2="30.7749" stroke="white" strokeWidth="3"/>
</svg>
</div>
</li>
)}
</ul>
</div>
</div>
)}
<AlertList alerts={allAlerts} onCloseAlert={handleCloseAlert} />
</>
) )
} }

2
frontend/src/components/PlaylistCard.jsx

@ -4,7 +4,7 @@ export default function PlaylistCard(props){
const {playlist, onClick} = props; const {playlist, onClick} = props;
return ( return (
<div className="glassmorphism w-1/3 p-4 cursor-pointer" onClick={() => {onClick(playlist.id)}}> <div className="glassmorphism p-4 cursor-pointer" onClick={() => {onClick(playlist.id)}}>
<img src={playlist.thumbnail ? playlist.thumbnail : Default} alt={playlist.name} className="rounded-sm" /> <img src={playlist.thumbnail ? playlist.thumbnail : Default} alt={playlist.name} className="rounded-sm" />
<div className="playlist-info"> <div className="playlist-info">
<h3 className="font-montserrat font-semibold text-xl text-white mt-3">{playlist.name}</h3> <h3 className="font-montserrat font-semibold text-xl text-white mt-3">{playlist.name}</h3>

17
frontend/src/components/PlaylistVideoCard.jsx

@ -0,0 +1,17 @@
export default function PlaylistVideoCard({ video, playlistId, navigation, currentVideo }) {
return (
<div className="glassmorphism flex items-center gap-2 p-2" onClick={() => navigation(`/video/${video.id}?playlistId=${playlistId}`)} >
<div className="relative" >
<img src={video.thumbnail} alt={video.title} className="h-16 object-cover rounded-sm" />
{currentVideo == video.id && (
<div className="absolute inset-0 bg-black opacity-80 rounded-sm w-full h-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white"><path d="M7 6v12l10-6z"></path></svg>
</div>
)}
</div>
<h3 className="font-montserrat font-medium text-white">{video.title}</h3>
</div>
);
}

4
frontend/src/components/ProtectedRoute.jsx

@ -17,10 +17,6 @@ const ProtectedRoute = ({ children, requireAuth = true }) => {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (!requireAuth && isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children; return children;

18
frontend/src/components/Recommendations.jsx

@ -1,17 +1,17 @@
import VideoCard from "./VideoCard";
export default function Recommendations({videos}) { export default function Recommendations({videos}) {
console.log(videos);
return ( return (
<div> <div className="">
<h2 className="text-3xl font-bold mb-4 text-white">Recommendations</h2> <h2 className="text-3xl font-bold mb-4 text-white">Recommandations</h2>
<div> <div>
<div className="flex flex-wrap"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-2">
{videos && videos.map((video, index) => ( {videos && videos.length > 0 ? videos.map((video, index) => (
<VideoCard key={video.id || index} video={video} /> <VideoCard key={video.id || index} video={video} />
))} )) : (
<p className="text-gray-500">Aucune recommandation disponible</p>
)}
</div> </div>
</div> </div>
</div> </div>

21
frontend/src/components/SeeLater.jsx

@ -0,0 +1,21 @@
import VideoCard from "./VideoCard.jsx";
export default function SeeLater({videos}) {
return (
<div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">A regarder plus tard</h2>
<div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-2">
{videos && videos.length > 0 ? videos.map((video, index) => (
<VideoCard key={video.id || index} video={video} />
)) : (
<p className="text-gray-500">Aucune vidéo à regarder plus tard</p>
)}
</div>
</div>
</div>
)
}

43
frontend/src/components/TabLayout.jsx

@ -0,0 +1,43 @@
import React, { useState } from 'react';
export default function TabLayout({ tabs }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const onTabChange = (tabId) => {
setActiveTab(tabId);
};
return (
<div>
{/* TABS */}
<div className='flex items-center mt-8 gap-3 mb-4' >
{
tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={` px-4 py-2 font-montserrat font-medium text-lg cursor-pointer ${tab.id === activeTab ? 'bg-white text-black rounded-2xl border-2 border-white' : 'glassmorphism text-white'}`}
>
{tab.label}
</button>
))
}
</div>
{/* ELEMENT */}
<div className="glassmorphism w-full p-4">
{tabs.map((tab) => (
<div key={tab.id} className={`tab-content ${tab.id === activeTab ? 'block' : 'hidden'}`}>
{tab.element()}
</div>
))}
</div>
</div>
)
}

22
frontend/src/components/Tag.jsx

@ -0,0 +1,22 @@
export default function Tag({ tag, onSuppress, doShowControls=true }) {
return (
<div className="glassmorphism px-2 py-1 w-max flex flex-row items-center gap-2">
<span className="font-inter text-white">#{tag}</span>
{doShowControls && (
<span className="tag-controls cursor-pointer" onClick={onSuppress}>
<svg
className="w-6 h-6 fill-white"
stroke="#FFF"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
)}
</div>
)
}

27
frontend/src/components/TopCreators.jsx

@ -1,17 +1,26 @@
export default function TopCreators({ creators }) { export default function TopCreators({ creators, navigate }) {
return ( return (
<div className="mt-10"> <div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">Top Creators</h2> <h2 className="text-3xl font-bold mb-4 text-white">Top Créateurs</h2>
<div className="flex flex-wrap"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-8">
{creators && creators.map((creator, index) => ( {creators && creators.length > 0 ? creators.map((creator, index) => (
<div key={creator.id || index} className="flex flex-col items-center w-1/4 p-4"> <div
<img src={creator.avatar} alt={creator.name} className="w-full h-auto rounded-lg" /> key={creator.id || index}
<h3 className="text-xl font-bold mt-2">{creator.name}</h3> className="flex flex-col items-center glassmorphism py-2"
<span className="text-sm text-gray-500">{creator.subscribers} subscribers</span> onClick={() => navigate(`/channel/${creator.id}`)}
>
<img src={creator.profilepicture} alt={creator.name} className="w-[128px] aspect-square rounded-full" />
<h3 className="text-xl text-white font-bold mt-1">{creator.name}</h3>
<span className="text-sm text-gray-500">{creator.subscriber_count} abonné{creator.subscriber_count > 1 ? 's' : ''}</span>
<p className="text-center text-gray-400">
<span>{creator.description.slice(0, 100) + (creator.description.length > 100 ? '...' : '')}</span>
</p>
</div> </div>
))} )) : (
<p className="text-gray-500">Aucun créateur disponible</p>
)}
</div> </div>
</div> </div>
); );

10
frontend/src/components/TrendingVideos.jsx

@ -6,12 +6,12 @@ export default function TrendingVideos({ videos }) {
return ( return (
<div className="mt-10"> <div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">Tendances</h2> <h2 className="text-3xl font-bold mb-4 text-white">Tendances</h2>
<div className="flex flex-wrap gap-11"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-2">
{videos && videos.map((video, index) => ( {videos && videos.length > 0 ? videos.map((video, index) => (
<div className="w-445/1920" key={index}>
<VideoCard video={video} key={index} /> <VideoCard video={video} key={index} />
</div> )) : (
))} <p className="text-gray-500">Aucune vidéo tendance disponible</p>
)}
</div> </div>
</div> </div>
); );

20
frontend/src/components/UserCard.jsx

@ -0,0 +1,20 @@
export default function UserCard({ user, onSubmit, doShowControls, control }) {
return (
<div className="glassmorphism flex items-center justify-between p-2" id={user.id} >
<div className="flex items-center gap-4">
<img src={user.picture || '/default-profile.png'} alt={`${user.username}'s profile`} className="w-10 h-10 rounded-full mr-2" />
<span className="text-white text-lg font-montserrat font-semibold">{user.username}</span>
</div>
{doShowControls && (
<div className="flex items-center gap-2" onClick={(e) => {
onSubmit(user);
}}>
{control}
</div>
)}
</div>
);
}

67
frontend/src/components/VideoCard.jsx

@ -1,27 +1,64 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
export default function VideoCard({ video }) { // SUPPORTED JSON FORMAT
// [
// {
// "id": 1,
// "title": "Video minecraft",
// "thumbnail": "/api/media/thumbnail/78438E11ABA5D0C8.webp",
// "video_description": "Cest une super video minecraft",
// "channel": 1,
// "visibility": "public",
// "file": "/api/media/video/78438E11ABA5D0C8.mp4",
// "slug": "78438E11ABA5D0C8",
// "format": "mp4",
// "release_date": "2025-08-11T11:14:01.357Z",
// "channel_id": 1,
// "owner": 2,
// "views": "2",
// "creator": {
// "name": "astria",
// "profilePicture": "/api/media/profile/sacha.jpg",
// "description": "salut tout le monde"
// },
// "type": "video"
// }
// ]
export default function VideoCard({ video, showControls = false, onDelete = () => {}, link = `/video/${video.id}` }) {
const navigation = useNavigate(); const navigation = useNavigate();
const handleClick = () => { const handleClick = () => {
navigation(`/video/${video.id}`, { navigation(link, {
state: { video } state: { video }
}) })
} }
return ( return (
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer" onClick={handleClick} > <div className="flex flex-col glassmorphism w-full p-6 cursor-pointer relative">
<div className="aspect-video rounded-sm overflow-hidden"> <div onClick={handleClick} >
<img <div className="aspect-video rounded-sm overflow-hidden">
src={video.thumbnail} <img
alt={video.title} src={video.thumbnail}
className="w-full h-full object-cover" alt={video.title}
/> className="w-full h-full object-cover"
</div> />
<h2 className="text-2xl font-medium font-inter mt-3 text-white">{video.title}</h2> </div>
<div className="text-sm text-gray-400 mt-1 flex items-center"> <h2 className="text-2xl font-medium font-inter mt-3 text-white">{video.title}</h2>
<img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" /> <div className="text-sm text-gray-400 mt-1 flex items-center">
<span className="ml-2">{video.creator.name}</span> <img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" />
<span className="ml-3.5">{video.views} vues</span> <span className="ml-2">{video.creator.name}</span>
<span className="ml-3.5">{video.views} vues</span>
</div>
</div> </div>
{showControls && (
<div className="mt-4">
<button
className="absolute -bottom-5 -right-5 bg-red-500 ml-4 px-3 py-2 rounded-full aspect-square text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onDelete(video.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' ><path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path><path d="M9 10h2v8H9zm4 0h2v8h-2z"></path></svg>
</button>
</div>
)}
</div> </div>
); );
} }

19
frontend/src/components/VideoStatListElement.jsx

@ -0,0 +1,19 @@
export default function VideoStatListElement ({ video, onClick }) {
return (
<div className="flex p-4 gap-4 glassmorphism cursor-pointer" onClick={onClick} >
<img
src={video.thumbnail}
alt=""
className="w-1/4 aspect-video rounded-sm"
/>
<div>
<h3 className="text-white text-lg lg:text-2xl font-montserrat font-bold" >{video.title.slice(0, 25)}{video.title.length > 25 ? "...":""}</h3>
<p className="text-white text-lg font-montserrat font-normal">Vues: {video.views}</p>
<p className="text-white text-lg font-montserrat font-normal">Likes: {video.likes}</p>
<p className="text-white text-lg font-montserrat font-normal">Commentaires: {video.comments}</p>
</div>
</div>
);
}

12
frontend/src/contexts/AuthContext.jsx

@ -74,8 +74,8 @@ export const AuthProvider = ({ children }) => {
throw new Error(data.message || 'Erreur lors de la création du compte'); throw new Error(data.message || 'Erreur lors de la création du compte');
} }
// After successful registration, log the user in // // After successful registration, log the user in
await login(username, password); // await login(username, password);
return data; return data;
} catch (error) { } catch (error) {
@ -83,6 +83,13 @@ export const AuthProvider = ({ children }) => {
} }
}; };
const loginWithOAuth = (userData, token) => {
// Store token and user data
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
};
const logout = () => { const logout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
@ -97,6 +104,7 @@ export const AuthProvider = ({ children }) => {
const value = { const value = {
user, user,
login, login,
loginWithOAuth,
register, register,
logout, logout,
getAuthHeaders, getAuthHeaders,

41
frontend/src/index.css

@ -31,6 +31,47 @@
backdrop-filter: blur(27.5px); backdrop-filter: blur(27.5px);
} }
.glassmorphism-rounded-full {
border-radius: 50%;
border: 2px solid rgba(239, 239, 239, 0.60);
background: linear-gradient(93deg, rgba(239, 239, 239, 0.06) 0%, rgba(239, 239, 239, 0.01) 100%);
backdrop-filter: blur(27.5px);
}
.glassmorphism-top-round {
border-top-left-radius: 15px;
border-top-right-radius: 15px;
border: 1px solid rgba(239, 239, 239, 0.60);
background: linear-gradient(93deg, rgba(239, 239, 239, 0.06) 0%, rgba(239, 239, 239, 0.01) 100%);
backdrop-filter: blur(27.5px);
}
.glassmorphism-bottom-round {
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
border: 1px solid rgba(239, 239, 239, 0.60);
background: linear-gradient(93deg, rgba(239, 239, 239, 0.06) 0%, rgba(239, 239, 239, 0.01) 100%);
backdrop-filter: blur(27.5px);
}
.resizable-none {
resize: none;
}
.glassmorphism-red {
border-radius: 15px;
border: 1px solid rgba(239, 239, 239, 0.60);
background: linear-gradient(93deg, rgba(226, 107, 107, 0.40) 0%, rgba(226, 107, 107, 0.15) 100%);
backdrop-filter: blur(27.5px);
}
.glassmorphism-green {
border-radius: 15px;
border: 1px solid rgba(239, 239, 239, 0.60);
background: linear-gradient(93deg, rgba(127, 226, 107, 0.40) 0%, rgba(127, 226, 107, 0.15) 100%);
backdrop-filter: blur(27.5px);
}
@theme { @theme {
/* Fonts */ /* Fonts */
--font-inter: 'Inter', sans-serif; --font-inter: 'Inter', sans-serif;

71
frontend/src/modals/CreateChannelModal.jsx

@ -0,0 +1,71 @@
import {useState} from "react";
import { createChannel } from "../services/channel.service.js";
export default function CreateChannelModal({isOpen, onClose, addAlert}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const token = localStorage.getItem('token');
const userStored = localStorage.getItem('user');
const user = userStored ? JSON.parse(userStored) : {};
const onSubmit = async (e) => {
e.preventDefault();
const body = {
"name": name,
"description": description,
"owner": user.id
}
const data = await createChannel(body, token, addAlert);
console.log(data);
onClose();
}
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" >
<div className="glassmorphism p-4 w-full lg:w-1/4" >
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une chaine</h2>
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la chaine</label>
<input
type="text"
id="name"
name="name"
placeholder="Nom de la chaine"
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none"
onChange={(e) => setName(e.target.value)}
value={name}
/>
<label htmlFor="description" className="block text-xl text-white font-montserrat font-semibold mt-2" >Description</label>
<textarea
id="description"
name="description"
placeholder="Description de votre chaine"
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none"
onChange={(e) => setDescription(e.target.value)}
value={description}
>
</textarea>
<button
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={(e) => onSubmit(e) }
>
Valider
</button>
<button
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={onClose}
>
Annuler
</button>
</div>
</div>
)
}

46
frontend/src/modals/CreatePlaylistModal.jsx

@ -0,0 +1,46 @@
import { useState } from "react";
import { createPlaylist } from "../services/playlist.service.js";
export default function CreatePlaylistModal({ isOpen, onClose, addAlert }) {
const [name, setName] = useState('');
const onSubmit = async (e) => {
e.preventDefault();
const token = localStorage.getItem("token");
const body = { name };
await createPlaylist(body, token, addAlert);
onClose();
};
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" >
<div className="glassmorphism p-4 w-full lg:w-1/4" >
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une playlist</h2>
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la playlist</label>
<input
type="text"
id="name"
name="name"
placeholder="Nom de la playlist"
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none"
onChange={(e) => setName(e.target.value)}
value={name}
/>
<button
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={(e) => onSubmit(e) }
>
Valider
</button>
<button
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={onClose}
>
Annuler
</button>
</div>
</div>
)
}

26
frontend/src/modals/EmailVerificationModal.jsx

@ -0,0 +1,26 @@
import React, { useState } from 'react';
export default function EmailVerificationModal({ isOpen, onSubmit, onClose }) {
const [verificationCode, setVerificationCode] = useState('');
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" >
<div className="glassmorphism p-4 w-full lg:w-1/4" >
<h2 className="text-lg font-bold mb-2 font-montserrat text-white">Vérification de l'email</h2>
<p className="font-montserrat text-white">Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre boîte de réception.</p>
<input
type="text"
placeholder="Entrez le code de vérification"
className="glassmorphism w-full px-4 py-2 mt-4 text-white"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
<button className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer mt-2" onClick={() => {
console.log("Verification code submitted:", verificationCode);
onSubmit(verificationCode)
}}>Vérifier</button>
</div>
</div>
);
}

17
frontend/src/modals/LoadingVideoModal.jsx

@ -0,0 +1,17 @@
export default function LoadingVideoModal({state, message}) {
return state === "loading" && (
<div className="fixed inset-0 bg-[rgba(0,0,0,0.5)] flex items-center justify-center z-50">
<div className="glassmorphism p-8 flex flex-col items-center space-y-4">
{/* Spinner */}
<div className="relative w-16 h-16">
<div className="absolute inset-0 border-4 border-gray-300 border-opacity-30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<p className="text-white text-lg font-medium">{message}</p>
</div>
</div>
);
}

27
frontend/src/modals/VerificationModal.jsx

@ -0,0 +1,27 @@
export default function VerificationModal({title, onConfirm, onCancel, isOpen}) {
if (!isOpen) return null;
return (
<div className="fixed z-40 inset-0 flex items-center justify-center px-5 lg:px-0">
<div className="glassmorphism w-full lg:w-auto p-6">
<h2 className="text-lg text-white font-semibold mb-4">{title}</h2>
<div className="flex justify-start lg:justify-end">
<button
className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onConfirm()}
>
Confirmer
</button>
<button
className="bg-red-500 ml-4 px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onCancel()}
>
Annuler
</button>
</div>
</div>
</div>
);
}

260
frontend/src/pages/Account.jsx

@ -1,7 +1,12 @@
import Navbar from "../components/Navbar.jsx"; import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import PlaylistCard from "../components/PlaylistCard.jsx"; import PlaylistCard from "../components/PlaylistCard.jsx";
import VideoCard from "../components/VideoCard.jsx"; import VideoCard from "../components/VideoCard.jsx";
import { useNavigate } from "react-router-dom";
import CreateChannelModal from "../modals/CreateChannelModal.jsx";
import CreatePlaylistModal from "../modals/CreatePlaylistModal.jsx";
import { getChannel, getUserHistory, getPlaylists, updateUser, deleteUser } from "../services/user.service.js";
import VerificationModal from "../modals/VerificationModal.jsx";
export default function Account() { export default function Account() {
@ -16,68 +21,22 @@ export default function Account() {
const [isPictureEditActive, setIsPictureEditActive] = useState(false); const [isPictureEditActive, setIsPictureEditActive] = useState(false);
const [userHistory, setUserHistory] = useState([]); const [userHistory, setUserHistory] = useState([]);
const [userPlaylists, setUserPlaylists] = useState([]); const [userPlaylists, setUserPlaylists] = useState([]);
const [userChannel, setUserChannel] = useState(null); const [userChannel, setUserChannel] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false);
const [alerts, setAlerts] = useState([]);
const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false);
const navigation = useNavigate();
const fetchUserChannel = async () => { const fetchUserChannel = async () => {
try { setUserChannel(await getChannel(user.id, token, addAlert)); // Reset before fetching
const response = await fetch(`/api/users/${user.id}/channel`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
}
});
if (!response.ok) {
throw new Error("Failed to fetch user data");
}
const data = await response.json();
setUserChannel(data);
} catch (error) {
console.error("Error fetching user channel:", error);
return null;
}
} }
const fetchUserHistory = async () => { const fetchUserHistory = async () => {
if (!user.id || !token) { setUserHistory(await getUserHistory(user.id, token, addAlert));
console.warn("User ID or token missing, skipping history fetch");
return;
}
try {
const response = await fetch(`/api/users/${user.id}/history`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
}
});
if (!response.ok) {
throw new Error("Failed to fetch user history");
}
const data = await response.json();
setUserHistory(data);
} catch (error) {
console.error("Error fetching user history:", error);
}
} }
const fetchUserPlaylists = async () => { const fetchUserPlaylists = async () => {
if (!user.id || !token) { setUserPlaylists(await getPlaylists(user.id, token, addAlert));
console.warn("User ID or token missing, skipping playlists fetch");
return;
}
try {
const response = await fetch(`/api/playlists/user/${user.id}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
}
});
if (!response.ok) {
throw new Error("Failed to fetch user playlists");
}
const data = await response.json();
setUserPlaylists(data);
} catch (error) {
console.error("Error fetching user playlists:", error);
}
} }
useEffect(() => { useEffect(() => {
@ -89,16 +48,16 @@ export default function Account() {
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const nonEditModeClasses = "text-2xl font-bold text-white p-2 focus:text-white focus:outline-none w-full font-montserrat"; const nonEditModeClasses = "text-lg lg:text-2xl font-bold text-white p-2 focus:text-white focus:outline-none w-full font-montserrat";
const editModeClasses = nonEditModeClasses + " glassmorphism"; const editModeClasses = nonEditModeClasses + " glassmorphism";
const handlePlaylistClick = (playlistId) => { const handlePlaylistClick = (playlistId) => {
navigation(`/playlist/${playlistId}`);
} }
const handleUpdateUser = async () => { const handleUpdateUser = async () => {
if (password !== confirmPassword) { if (password !== confirmPassword) {
alert("Les mots de passe ne correspondent pas."); addAlert('error', "Les mots de passe ne correspondent pas.");
return; return;
} }
@ -108,55 +67,63 @@ export default function Account() {
password: password || undefined, // Only send password if it's not empty password: password || undefined, // Only send password if it's not empty
}; };
try { const result = await updateUser(user.id, token, updatedUser, addAlert);
const response = await fetch(`/api/users/${user.id}`, { if (result) {
method: "PUT", localStorage.setItem("user", JSON.stringify(result));
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(updatedUser),
});
if (!response.ok) {
throw new Error("Failed to update user");
}
const data = await response.json();
localStorage.setItem("user", JSON.stringify(data.user));
setEditMode(false); setEditMode(false);
alert("Profil mis à jour avec succès !"); addAlert('success', "Profil mis à jour avec succès.");
} catch (error) {
console.error("Error updating user:", error);
alert("Erreur lors de la mise à jour du profil.");
} }
} }
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
const closeModal = () => {
setIsModalOpen(false);
fetchUserChannel();
}
const closePlaylistModal = () => {
setIsCreatePlaylistModalOpen(false);
fetchUserPlaylists();
}
const onDeleteAccount = async () => {
await deleteUser(user.id, token, addAlert);
localStorage.removeItem("user");
navigation("/login");
}
return ( return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> <div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar/> <Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<main className="px-36 pt-[118px] flex justify-between items-start"> <main className="px-5 lg:px-36 pt-[48px] lg:pt-[118px] lg:flex justify-between items-start">
{/* Left side */} {/* Left side */}
{/* Profile / Edit profile */} {/* Profile / Edit profile */}
<form className="glassmorphism w-1/3 p-10"> <div className=" lg:w-1/3 ">
<form className="glassmorphism p-10">
<div className="relative w-1/3 aspect-square overflow-hidden mb-3 mx-auto" onMouseEnter={() => setIsPictureEditActive(true)} onMouseLeave={() => setIsPictureEditActive(false)} > <div className="relative w-1/3 aspect-square overflow-hidden mb-3 mx-auto" onMouseEnter={() => setIsPictureEditActive(true)} onMouseLeave={() => setIsPictureEditActive(false)} >
<label htmlFor="image"> <label htmlFor="image">
<img <img
src={user.picture} src={user.picture}
className="w-full aspect-square rounded-full object-cover" className="w-full aspect-square rounded-full object-cover"
/> />
<div className={`absolute w-full h-full bg-[#000000EF] flex items-center justify-center top-0 left-0 rounded-full ${(isPictureEditActive && editMode) ? "opacity-100 cursor-pointer" : "opacity-0 cursor-default"} ` } > <div className={`absolute w-full h-full bg-[#000000EF] flex items-center justify-center top-0 left-0 rounded-full ${(isPictureEditActive && editMode) ? "opacity-100 cursor-pointer" : "opacity-0 cursor-default"} `} >
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" className="fill-white" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" className="fill-white" viewBox="0 0 24 24">
<path d="M19.045 7.401c.378-.378.586-.88.586-1.414s-.208-1.036-.586-1.414l-1.586-1.586c-.378-.378-.88-.586-1.414-.586s-1.036.208-1.413.585L4 13.585V18h4.413L19.045 7.401zm-3-3 1.587 1.585-1.59 1.584-1.586-1.585 1.589-1.584zM6 16v-1.585l7.04-7.018 1.586 1.586L7.587 16H6zm-2 4h16v2H4z"></path> <path d="M19.045 7.401c.378-.378.586-.88.586-1.414s-.208-1.036-.586-1.414l-1.586-1.586c-.378-.378-.88-.586-1.414-.586s-1.036.208-1.413.585L4 13.585V18h4.413L19.045 7.401zm-3-3 1.587 1.585-1.59 1.584-1.586-1.585 1.589-1.584zM6 16v-1.585l7.04-7.018 1.586 1.586L7.587 16H6zm-2 4h16v2H4z"></path>
</svg> </svg>
</div> </div>
</label> </label>
<input type="file" accept="image/*" id="image" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" disabled={!editMode}/> <input type="file" accept="image/*" id="image" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" disabled={!editMode} />
</div> </div>
<label htmlFor="name" className="text-2xl text-white mb-1 block font-montserrat"> <label htmlFor="name" className="text-xl lg:text-2xl text-white mb-1 block font-montserrat">
Nom d'utilisateur Nom d'utilisateur
</label> </label>
<input <input
type="text" type="text"
@ -167,8 +134,7 @@ export default function Account() {
placeholder="Nom d'utilisateur" placeholder="Nom d'utilisateur"
disabled={!editMode} disabled={!editMode}
/> />
<label htmlFor="email" className="text-xl lg:text-2xl text-white mb-1 mt-4 block font-montserrat">
<label htmlFor="email" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Adresse e-mail Adresse e-mail
</label> </label>
<input <input
@ -180,10 +146,9 @@ export default function Account() {
placeholder="Adresse mail" placeholder="Adresse mail"
disabled={!editMode} disabled={!editMode}
/> />
{editMode && (
{ editMode && (
<> <>
<label htmlFor="password" className="text-2xl text-white mb-1 mt-4 block font-montserrat"> <label htmlFor="password" className="text-xl lg:text-2xl text-white mb-1 mt-4 block font-montserrat">
Mot de passe Mot de passe
</label> </label>
<input <input
@ -195,8 +160,7 @@ export default function Account() {
placeholder="**************" placeholder="**************"
disabled={!editMode} disabled={!editMode}
/> />
<label htmlFor="confirm-password" className="text-xl lg:text-2xl text-white mb-1 mt-4 block font-montserrat">
<label htmlFor="confirm-password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Confirmer le mot de passe Confirmer le mot de passe
</label> </label>
<input <input
@ -210,23 +174,21 @@ export default function Account() {
/> />
</> </>
) )
} }
<div className="flex justify-center mt-5"> <div className="flex justify-center mt-5">
{ {
editMode ? ( editMode ? (
<div> <div>
<button <button
type="button" type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer" className="bg-primary p-3 rounded-sm text-white font-montserrat text-lg text-2xl font-black cursor-pointer"
onClick={handleUpdateUser} onClick={handleUpdateUser}
> >
Enregistrer Enregistrer
</button> </button>
<button <button
type="button" type="button"
className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer ml-3" className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-lg lg:text-2xl font-bold cursor-pointer ml-3"
onClick={() => setEditMode(!editMode)} onClick={() => setEditMode(!editMode)}
> >
Annuler Annuler
@ -235,7 +197,7 @@ export default function Account() {
) : ( ) : (
<button <button
type="button" type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer" className="bg-primary p-3 rounded-sm text-white font-montserrat text-lg lg:text-2xl font-bold cursor-pointer"
onClick={() => setEditMode(!editMode)} onClick={() => setEditMode(!editMode)}
> >
Modifier le profil Modifier le profil
@ -244,55 +206,73 @@ export default function Account() {
} }
</div> </div>
</form> </form>
<button
className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-lg lg:text-2xl font-bold cursor-pointer mt-4 w-full"
onClick={() => setIsVerificationModalOpen(true)}
>
Supprimer le compte
</button>
<VerificationModal
title="Confirmer la suppression du compte"
isOpen={isVerificationModalOpen}
onCancel={() => setIsVerificationModalOpen(false)}
onConfirm={() => onDeleteAccount()}
/>
</div>
{ /* Right side */} { /* Right side */}
<div className="w-2/3 flex flex-col items-start pl-10"> <div className="lg:w-2/3 flex flex-col items-start lg:pl-10 mt-8 lg:mt-0">
{/* Channel */} {/* Channel */}
{userChannel ? ( {userChannel ? (
<div className="glassmorphism p-10 w-full flex justify-between"> <div className="glassmorphism p-10 w-full flex justify-between">
<p className="text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p> <p className="text-xl lg:text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p>
<button> <button>
<a href="" className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"> <span onClick={() => navigation(`/manage-channel/${userChannel.channel.id}`)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-lg lg:text-2xl font-semibold cursor-pointer">
Gérer la chaîne Gérer la chaîne
</a> </span>
</button> </button>
</div>
) : (
<div className="glassmorphism p-10 w-full">
<h2 className="text-3xl font-bold text-white mb-4">Chaîne</h2>
<p className="text-xl text-white mb-2">Aucune chaîne associée à ce compte.</p>
<button className=" mt-4">
<a href="/create-channel" className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Créer une chaîne
</a>
</button>
</div>
)}
{/* Playlists */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Playlists</h2>
<div className="w-full mt-5 flex flex-wrap" >
{
userPlaylists.map((playlist, index) => (
<PlaylistCard playlist={playlist} key={index} onClick={handlePlaylistClick} />
))
}
</div> </div>
{/* History */} ) : (
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Historique</h2> <div className="glassmorphism p-10 w-full">
<div className="w-full mt-5 flex flex-wrap gap-2" > <h2 className="text-3xl font-bold text-white mb-4">Chaîne</h2>
{ <p className="text-xl text-white mb-2">Aucune chaîne associée à ce compte.</p>
userHistory.map((video, index) => ( <button className=" mt-4">
<div className="w-1/3" key={index}> <a onClick={() => setIsModalOpen(true)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
<VideoCard video={video}/> Créer une chaîne
</div> </a>
)) </button>
}
</div> </div>
)}
{/* Playlists */}
<div className="flex justify-between items-center w-full mt-10">
<h2 className="font-montserrat font-bold text-3xl text-white" >Playlists</h2>
<button onClick={() => setIsCreatePlaylistModalOpen(true)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer">
Créer une playlist
</button>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8" >
{
userPlaylists && userPlaylists.map((playlist, index) => (
<PlaylistCard playlist={playlist} key={index} onClick={handlePlaylistClick} />
))
}
</div>
{/* History */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Historique</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8" >
{
userHistory && userHistory.map((video, index) => (
<VideoCard video={video} />
))
}
</div>
</div>
</main> </main>
<CreateChannelModal isOpen={isModalOpen} onClose={() => closeModal()} addAlert={addAlert} />
<CreatePlaylistModal isOpen={isCreatePlaylistModalOpen} onClose={() => closePlaylistModal()} addAlert={addAlert} />
</div> </div>
) )

362
frontend/src/pages/AddVideo.jsx

@ -0,0 +1,362 @@
import Navbar from "../components/Navbar.jsx";
import { useEffect, useState } from "react";
import Tag from "../components/Tag.jsx";
import { getChannel, searchByUsername } from "../services/user.service.js";
import { uploadVideo, uploadThumbnail, uploadTags } from "../services/video.service.js";
import UserCard from "../components/UserCard.jsx";
import LoadingVideoModal from "../modals/LoadingVideoModal.jsx";
import { useNavigate } from "react-router-dom";
export default function AddVideo() {
const storedUser = localStorage.getItem("user");
const user = storedUser ? JSON.parse(storedUser) : null;
const token = localStorage.getItem("token");
const navigation = useNavigate();
const [videoTitle, setVideoTitle] = useState("");
const [videoDescription, setVideoDescription] = useState("");
const [videoTags, setVideoTags] = useState([]);
const [visibility, setVisibility] = useState("private");
const [videoThumbnail, setVideoThumbnail] = useState(null);
const [videoFile, setVideoFile] = useState(null);
const [channel, setChannel] = useState(null);
const [searchUser, setSearchUser] = useState("");
const [authorizedUsers, setAuthorizedUsers] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [alerts, setAlerts] = useState([]);
const [loadingState, setLoadingState] = useState("none");
const [loadingMessage, setLoadingMessage] = useState("");
useEffect(() => {
fetchChannel();
}, [])
const fetchChannel = async () => {
const fetchedChannel = await getChannel(user.id, token, addAlert);
setChannel(fetchedChannel.channel);
console.log(fetchedChannel.channel);
}
const handleTagKeyDown = (e) => {
if (e.key === 'Enter' && videoTags.length < 10) {
e.preventDefault();
const newTag = e.target.value.trim();
if (newTag && !videoTags.includes(newTag)) {
setVideoTags([...videoTags, newTag]);
e.target.value = '';
}
}
}
const handleTagRemove = (tagToRemove) => {
setVideoTags(videoTags.filter(tag => tag !== tagToRemove));
};
// This function handles the submission of the video form
const handleSubmit = async (e) => {
e.preventDefault();
console.log(channel)
setLoadingState("loading");
setLoadingMessage("Envoie de la vidéo...");
if (!videoTitle || !videoDescription || !videoThumbnail || !videoFile) {
addAlert('error', 'Veuillez remplir tous les champs requis.');
return;
}
if (!channel || !channel.id) {
addAlert('error', 'Chaîne non valide veuillez recharger la page.');
return;
}
if (videoTags.length > 10) {
addAlert('error', 'Vous ne pouvez pas ajouter plus de 10 tags.');
return;
}
const formData = new FormData();
formData.append("title", videoTitle);
formData.append("description", videoDescription);
formData.append("channel", channel.id.toString());
formData.append("visibility", visibility);
formData.append("authorizedUsers", JSON.stringify(authorizedUsers.map(user => user.id)));
formData.append("file", videoFile);
const request = await uploadVideo(formData, token, addAlert);
setLoadingMessage("Envoie de la miniature...");
// If the video was successfully created, we can now upload the thumbnail
const response = await request.json();
const videoId = response.id;
const thumbnailFormData = new FormData();
thumbnailFormData.append("video", videoId);
thumbnailFormData.append("file", videoThumbnail);
thumbnailFormData.append("channel", channel.id.toString());
await uploadThumbnail(thumbnailFormData, token, addAlert);
setLoadingMessage("Envoie des tags...");
// if the thumbnail was successfully uploaded, we can send the tags
const body = {
tags: videoTags,
channel: channel.id.toString()
};
await uploadTags(body, videoId, token, addAlert);
// If everything is successful, redirect to the video management page
navigation("/manage-channel/" + channel.id);
addAlert('success', 'Vidéo ajoutée avec succès !');
};
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
const onUserSearch = (e) => {
const searchUser = e.target.value;
if (searchUser.trim() !== "") {
// Call the API to search for users
searchByUsername(searchUser, token, addAlert)
.then((results) => {
console.log(results);
setSearchResults(results);
})
.catch((error) => {
addAlert('error', 'Erreur lors de la recherche d\'utilisateurs.');
});
} else {
setSearchResults([]);
}
}
const onAuthorizedUserAdd = (user) => {
// Verify if user is not already authorized
if (authorizedUsers.find((u) => u.id === user.id)) {
addAlert('error', 'Utilisateur déjà autorisé.');
setSearchUser("")
setSearchResults([]);
return;
}
setAuthorizedUsers([...authorizedUsers, user]);
setSearchResults([]);
setSearchUser("")
};
const onAuthorizedUserRemove = (user) => {
setAuthorizedUsers(authorizedUsers.filter((u) => u.id !== user.id));
};
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<main className="px-5 lg:px-36 pt-[118px]">
<h1 className="font-montserrat text-2xl font-black text-white">
Ajouter une vidéo
</h1>
<div className="flex gap-8 mt-8">
{/* Left side: Form for adding video details */}
<form className="flex-1">
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoTitle">Titre de la vidéo</label>
<input
type="text"
id="videoTitle"
name="videoTitle"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez le titre de la vidéo"
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
required
/>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoDescription">Description de la vidéo</label>
<textarea
id="videoDescription"
name="videoDescription"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez la description de la vidéo"
rows="4"
value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)}
required
></textarea>
<label className="block text-white text-xl font-montserrat font-semibold mb-1" htmlFor="videoDescription">Tags</label>
<input
type="text"
id="videoTags"
name="videoTags"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez les tags de la vidéo (entrée pour valider) 10 maximum"
onKeyDown={handleTagKeyDown}
/>
<div className="flex flex-wrap gap-2 mb-2">
{videoTags.map((tag, index) => (
<Tag tag={tag} doShowControls={true} key={index} onSuppress={() => handleTagRemove(tag)} />
))}
</div>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="visibility">Visibilité</label>
<select
name="visibility"
id="visibility"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="public">Public</option>
<option value="private">Privé</option>
</select>
{
visibility == "private" && (
<div className="mb-4 glassmorphism p-4">
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="authorizedUsers">Utilisateurs autorisés</label>
<div className="mb-4">
<input
type="text"
id="authorizedUsers"
name="authorizedUsers"
className="w-full p-2 mb-2 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Rechercher un utilisateur"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
onKeyDown={(e) => {
onUserSearch(e)
}}
/>
<div>
{searchResults && searchResults.map((user, index) => (
<UserCard
user={user}
onSubmit={onAuthorizedUserAdd}
doShowControls={true}
key={index}
control={
<button type="button" className="bg-primary text-white font-montserrat p-3 rounded-lg text-lg font-bold cursor-pointer">
ajouter
</button>
}
/>
))}
</div>
</div>
<div className="max-h-40 overflow-y-auto">
{authorizedUsers.length > 0 ? authorizedUsers.map((user, index) => (
<UserCard
user={user}
onSubmit={onAuthorizedUserRemove}
doShowControls={true}
key={index}
control={
<button type="button" className="bg-red-500 text-white font-montserrat p-3 rounded-lg text-lg font-bold cursor-pointer">
supprimer
</button>
}
/>
)) : (
<p className="text-white">Aucun utilisateur autorisé</p>
)}
</div>
</div>
)
}
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoThumbnail">Miniature</label>
<input
type="file"
id="videoThumbnail"
name="videoThumbnail"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
accept="image/*"
onChange={(e) => setVideoThumbnail(e.target.files[0])}
required
/>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoFile">Fichier vidéo</label>
<input
type="file"
id="videoFile"
name="videoFile"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
accept="video/*"
onChange={(e) => setVideoFile(e.target.files[0])}
required
/>
<button
type="submit"
className="bg-primary text-white font-montserrat p-3 rounded-lg text-2xl font-bold w-full cursor-pointer"
onClick={(e) => { handleSubmit(e) }}
>
Ajouter la vidéo
</button>
</form>
{/* Right side: Preview of the video being added */}
<div className="flex-1 hidden lg:flex justify-center">
<div className="glassmorphism p-4 rounded-lg">
<img
src={videoThumbnail ? URL.createObjectURL(videoThumbnail) : "https://placehold.co/1280x720"} alt={videoTitle}
className="w-[480px] h-auto mb-4 rounded-lg"
/>
<h2 className="text-white text-xl font-montserrat font-semibold mb-2">{videoTitle || "Titre de la vidéo"}</h2>
<div className="glassmorphism p-4 rounded-sm">
<p className="text-white font-inter mb-2">
{videoDescription || "Description de la vidéo"}
</p>
<div className="flex flex-wrap gap-2">
{videoTags.length > 0 ? (
videoTags.map((tag, index) => (
<Tag tag={tag} doShowControls={false} key={index} />
))
) : (
<span className="text-gray-400">Aucun tag ajouté</span>
)}
</div>
</div>
</div>
</div>
</div>
</main>
<LoadingVideoModal state={loadingState} message={loadingMessage} />
</div>
);
}

115
frontend/src/pages/Channel.jsx

@ -0,0 +1,115 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import {fetchChannelDetails, subscribe} from "../services/channel.service.js";
import TabLayout from "../components/TabLayout.jsx";
import ChannelLastVideos from "../components/ChannelLastVideos.jsx";
import { isSubscribed } from "../services/user.service.js";
export default function Channel() {
const id = useParams().id;
const [alerts, setAlerts] = useState([]);
const [channel, setChannel] = useState(null);
const [isSubscribedToChannel, setIsSubscribedToChannel] = useState(false);
const tabs = [
{ id: 'last', label: 'Dernières vidéos', element: () => <ChannelLastVideos videos={channel && channel.videos.slice(0, 10)} /> },
{ id: 'all', label: 'Toutes les vidéos', element: () => <ChannelLastVideos videos={channel && channel.videos} /> },
];
useEffect(() => {
async function fetchData() {
const chan = await fetchChannelDetails(id, addAlert);
setChannel(chan);
// If not authenticated, isSubscribed may be undefined -> default to false
const subscribed = await isSubscribed(id, addAlert);
setIsSubscribedToChannel(Boolean(subscribed));
}
fetchData();
}, [id])
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts(prev => [...prev, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
}
const handleSubscribe = async () => {
try {
const result = await subscribe(id, addAlert);
// Update local counter from API response
const newCount = Number(result?.subscriptions ?? (channel?.subscriptions ?? 0));
setChannel(prev => (prev ? { ...prev, subscriptions: newCount } : prev));
// Toggle local subscription state and notify
const next = !isSubscribedToChannel;
setIsSubscribedToChannel(next);
} catch (e) {
// Error alert already handled in service
}
};
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<main className="pt-[48px] lg:pt-[118px] px-5 lg:px-36" >
{/* Channel Header */}
<div className="glassmorphism p-4" >
<div className="lg:flex items-center gap-4" >
<img
src={channel && channel.picture}
alt={channel && channel.name}
className="w-[96px] lg:w-[192px] aspect-square object-cover rounded-full border-2 border-white"
/>
<div className="flex items-center" >
<div>
<h1 className="font-montserrat font-bold text-3xl text-white" >{channel && channel.name}</h1>
<p className="font-montserrat font-medium text-xl text-white" >{channel && channel.subscriptions} abonné(es)</p>
</div>
{
isSubscribedToChannel ? (
<button
className="ml-5 bg-primary text-white font-montserrat font-bold px-4 py-1 h-1/2 rounded-md cursor-pointer"
onClick={handleSubscribe}
>
se désabonner
</button>
) : (
<button
className="ml-5 bg-primary text-white font-montserrat font-bold px-4 py-1 h-1/2 rounded-md cursor-pointer"
onClick={handleSubscribe}
>
s'abonner
</button>
)
}
</div>
</div>
<h2 className="font-bold font-montserrat text-white text-xl mt-4" >Description</h2>
<p className="text-white text-lg font-medium font-montserrat pl-[24px]">
{ channel && channel.description }
</p>
</div>
{/* Tab selector */}
<TabLayout tabs={tabs}/>
{/* 10 Last videos */}
</main>
</div>
)
}

92
frontend/src/pages/Home.jsx

@ -5,6 +5,10 @@ import {useState, useEffect} from "react";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import TopCreators from "../components/TopCreators.jsx"; import TopCreators from "../components/TopCreators.jsx";
import TrendingVideos from "../components/TrendingVideos.jsx"; import TrendingVideos from "../components/TrendingVideos.jsx";
import { getRecommendations, getTrendingVideos, getTopCreators } from '../services/recommendation.service.js';
import { useNavigate } from 'react-router-dom';
import SeeLater from "../components/SeeLater.jsx";
import {getSeeLater} from "../services/playlist.service.js";
export default function Home() { export default function Home() {
const { isAuthenticated, user } = useAuth(); const { isAuthenticated, user } = useAuth();
@ -12,65 +16,98 @@ export default function Home() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [topCreators, setTopCreators] = useState([]); const [topCreators, setTopCreators] = useState([]);
const [trendingVideos, setTrendingVideos] = useState([]); const [trendingVideos, setTrendingVideos] = useState([]);
const [seeLaterVideos, setSeeLaterVideos] = useState([]);
const [alerts, setAlerts] = useState([]);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
// Fetch recommendations, top creators, and trending videos // Fetch recommendations, top creators, and trending videos
const fetchData = async () => { const fetchData = async () => {
try { if (isAuthenticated) {
const response = await fetch('/api/recommendations'); const token = localStorage.getItem('token');
const data = await response.json(); try {
setRecommendations(data.recommendations); setRecommendations(await getRecommendations(token, addAlert));
} catch (error) { } finally {
console.error('Error fetching data:', error); setLoading(false);
} finally { }
setLoading(false); } else {
try {
setRecommendations(await getRecommendations(null, addAlert));
} finally {
setLoading(false);
}
} }
try { try {
const trendingResponse = await fetch('/api/recommendations/trending'); setTrendingVideos(await getTrendingVideos(addAlert));
const trendingData = await trendingResponse.json();
setTrendingVideos(trendingData);
} catch (error) {
console.error('Error fetching trending videos:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
if (isAuthenticated) {
try {
const token = localStorage.getItem('token');
setSeeLaterVideos(await getSeeLater(token, addAlert));
} finally {
setLoading(false);
}
} else {
try {
setTopCreators(await getTopCreators(addAlert));
} finally {
setLoading(false);
}
}
}; };
fetchData(); fetchData();
}, []); }, []);
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
return ( return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> <div className=" lg:min-w-screen lg:min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} /> <Navbar isSearchPage={false} />
<main className="px-36"> <main className=" px-5 lg:px-36">
{/* Hero section */} {/* Hero section */}
<div className="flex flex-col items-center w-full pt-[304px]"> <div className="flex flex-col items-center w-full pt-[128px] lg:pt-[304px]">
<img src={HeroImage} alt="" className="w-1046/1920" /> <img src={HeroImage} alt="" className=" w-1700/1920 lg:w-1046/1920" />
{isAuthenticated ? ( {isAuthenticated ? (
<h1 className="font-montserrat text-8xl font-black w-1200/1920 text-center text-white -translate-y-1/2"> <h1 className="font-montserrat text-4xl lg:text-8xl font-black w-1200/1920 text-center text-white -translate-y-1/2">
Bienvenue {user?.username} ! Bienvenue {user?.username} !
</h1> </h1>
) : ( ) : (
<> <>
<h1 className="font-montserrat text-8xl font-black w-1200/1920 text-center text-white -translate-y-1/2"> <h1 className="font-montserrat text-4xl lg:text-8xl font-black w-1200/1920 text-center text-white -translate-y-1/2">
Regarder des vidéos comme jamais auparavant Regarder des vidéos comme jamais auparavant
</h1> </h1>
<div className="flex justify-center gap-28 -translate-y-[100px] mt-10">
<button className="bg-white text-black font-montserrat p-3 rounded-sm text-2xl font-bold">
<div className="flex justify-center gap-10 -translate-y-[100px] mt-10">
<button className="bg-white text-black font-montserrat p-3 rounded-sm text-xl lg:text-2xl font-bold">
<a href="/login"> <a href="/login">
<p>Se connecter</p> <p>Se connecter</p>
</a> </a>
</button> </button>
<button className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-bold cursor-pointer"> <button className="bg-primary p-3 rounded-sm text-white font-montserrat text-xl lg:text-2xl font-bold cursor-pointer">
<a href="/register"> <a href="/register">
<p>Créer un compte</p> <p>Créer un compte</p>
</a> </a>
</button> </button>
</div> </div>
</> </>
)} )}
</div> </div>
@ -79,7 +116,16 @@ export default function Home() {
<Recommendations videos={recommendations} /> <Recommendations videos={recommendations} />
{/* Top Creators section */} {/* Top Creators section */}
<TopCreators/>
{
isAuthenticated ? (
<SeeLater videos={seeLaterVideos} />
) : (
<TopCreators creators={topCreators} navigate={navigate} />
)
}
{/* Trending Videos section */} {/* Trending Videos section */}
<TrendingVideos videos={trendingVideos} /> <TrendingVideos videos={trendingVideos} />

74
frontend/src/pages/Login.jsx

@ -2,14 +2,16 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import GitHubLoginButton from '../components/GitHubLoginButton';
export default function Login() { export default function Login() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: '',
password: '' password: ''
}); });
const [error, setError] = useState(''); const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth(); const { login } = useAuth();
@ -21,16 +23,25 @@ export default function Login() {
}); });
}; };
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setAlerts([]); // Clear existing alerts
setLoading(true); setLoading(true);
try { try {
await login(formData.username, formData.password); await login(formData.username, formData.password);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
setError(err.message || 'Erreur de connexion'); addAlert('error', err.message || 'Erreur de connexion');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -38,21 +49,15 @@ export default function Login() {
return ( return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> <div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} /> <Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<div className="flex justify-center items-center min-h-screen pt-20"> <div className="flex justify-center items-center min-h-screen pt-20">
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md"> <div className="glassmorphism p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat">Connexion</h2> <h2 className="text-3xl font-bold text-center mb-6 font-montserrat text-white">Connexion</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="username" className="block text-sm font-medium text-white mb-1">
Nom d'utilisateur Nom d'utilisateur
</label> </label>
<input <input
@ -62,27 +67,45 @@ export default function Login() {
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 glassmorphism text-white rounded-md focus:outline-none"
placeholder="Entrez votre nom d'utilisateur" placeholder="Entrez votre nom d'utilisateur"
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-white mb-1">
Mot de passe Mot de passe
</label> </label>
<input <div className='w-full glassmorphism flex items-center'>
type="password" <input
id="password" type={showPassword ? "text" : "password"}
name="password" id="password"
value={formData.password} name="password"
onChange={handleChange} value={formData.password}
required onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required
placeholder="Entrez votre mot de passe" className="flex-1 px-3 py-2 focus:outline-none text-white"
/> placeholder="Entrez votre mot de passe"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className='fill-white' viewBox="0 0 24 24" >
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' >
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>
)}
</button>
</div>
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
@ -90,6 +113,7 @@ export default function Login() {
> >
{loading ? 'Connexion...' : 'Se connecter'} {loading ? 'Connexion...' : 'Se connecter'}
</button> </button>
<GitHubLoginButton className="mt-1 cursor-pointer" />
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">

59
frontend/src/pages/LoginSuccess.jsx

@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function LoginSuccess() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { loginWithOAuth } = useAuth();
const hasProcessed = useRef(false);
useEffect(() => {
// Prevent multiple executions
if (hasProcessed.current) return;
const token = searchParams.get('token');
const userParam = searchParams.get('user');
console.log('Processing OAuth callback:', { token: !!token, userParam: !!userParam });
if (token && userParam) {
try {
hasProcessed.current = true;
const userData = JSON.parse(decodeURIComponent(userParam));
console.log('Parsed user data:', userData);
// Use the OAuth login method to update auth context
loginWithOAuth(userData, token);
// Small delay before navigation to ensure state is updated
setTimeout(() => {
navigate('/', { replace: true });
}, 100);
} catch (error) {
console.error('Error processing OAuth login:', error);
hasProcessed.current = true;
setTimeout(() => {
navigate('/login?error=invalid_data', { replace: true });
}, 100);
}
} else {
console.log('Missing token or user data');
hasProcessed.current = true;
setTimeout(() => {
navigate('/login?error=missing_data', { replace: true });
}, 100);
}
}, []); // Remove dependencies to prevent re-runs
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Finalisation de la connexion...</p>
</div>
</div>
);
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save