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. 72
      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. 52
      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. 94
      frontend/src/pages/Home.jsx
  99. 78
      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
```bash
cd frontend
npm install
npm run dev
```
## Sommaire
1) Introduction
2) Description du projet
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
- Install PostgreSQL
- Create database and user
- Run migrations (if available)
</details>
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**.
## ⚙️ Configuration
## Le serveur
### Environment Variables
### Les dépendances
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `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 | ❌ |
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. 
### 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:
- `./backend/logs:/var/log/freetube` - Application logs
- `./backend/app/uploads:/app/app/uploads` - Uploaded files (videos, images)
- Database data volume for PostgreSQL persistence
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é.
## 📖 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**
- 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
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.
2. **Login**
- Click "Se connecter" on the homepage
- Enter your username and password
- You'll be redirected to the authenticated homepage
## Le site web
3. **Upload Videos**
- Use the API endpoints to upload videos (see API documentation)
- Videos are stored in the uploads directory
### Les dépendances
4. **Browse Content**
- View recommendations on the homepage
- Search for videos using the search bar
- Browse trending videos and top creators
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. 
### 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:
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
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. 
## 📚 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
```http
POST /api/users/
Content-Type: multipart/form-data
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.
email: user@example.com
username: johndoe
password: securepassword
picture: [file upload]
### Le fonctionnement
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.  
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
```http
POST /api/users/login
Content-Type: application/json
#### Paramétrages NGINX
Quelque modification doivent être faites pour le fonctionnement de NGINX
```nginx
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
```http
GET /api/media/profile/{filename}
#### Mise en place des variables d'environnements
A la racine du projet créer un fichier `.env`
```bash
touch .env
```
#### Get Video Thumbnail
```http
GET /api/media/thumbnail/{filename}
A l'aide de l'éditeur de votre choix entrez dans le fichier
```
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/`
- **Comments**: `/api/comments/`
- **Channels**: `/api/channels/`
- **Playlists**: `/api/playlists/`
- **Recommendations**: `/api/recommendations/`
BACKEND_PORT=8000
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
```
3RESIT_DOCKER/
├── backend/ # Node.js Express backend
│ ├── app/
│ │ ├── 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
```
GMAIL_USER=<adresse e-mail>
GMAIL_PASSWORD=<mot_de_passe_créer_precedemment>
FRONTEND_URL=<URL_HTTPS_de_votre_nginx>
## 🔧 Development
GITHUB_ID=<ID_github>
### Available Scripts
GITHUB_SECRET=<secret_github>
#### Backend
#### Lancement
Pour lancer le groupe de conteneur
```bash
npm run dev # Start development server with hot reload
npm run start # Start production server
npm run test # Run tests
docker compose up -d # pour détacher de la session
```
#### Frontend
### Installation via le Script Shell
#### Ajout des autorisations
Pour ajouter les autorisations nécessaire au lancement du script
```bash
npm run dev # Start development server
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
chmod +x ./deploy.sh
```
#### Docker Commands
```bash
# Start all services
docker-compose up --build
#### Création de clé d'API Gmail
# Stop all services
docker-compose down
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
# View logs
docker-compose logs [service-name]
#### Création Application OAuth Github
# Restart specific service
docker-compose restart [service-name]
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
# Reset database
docker-compose down --volumes
#### Lancer l'installation
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
2. **Frontend Changes**: Hot module replacement with Vite
3. **Database Changes**: Restart containers to apply schema changes
4. **Nginx Changes**: Restart nginx service
#### Création de clé d'API Gmail
### 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:
- `user.http` - User registration and authentication
- `video.http` - Video management
- `medias.http` - Media file serving
- `comment.http` - Comment system
#### Création Application OAuth Github
## 🧪 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
# Backend tests
cd backend
npm test
touch .env
```
# Frontend tests (if configured)
cd frontend
npm test
A l'aide de l'éditeur de votre choix entrez dans le fichier
```
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
- **Integration Tests**: API endpoint testing
- **E2E Tests**: Full application workflow testing
JWT_SECRET=<votre_clé_JWT>
Current test coverage includes:
- User authentication
- Video management
- Comment system
- Channel operations
- Playlist functionality
LOG_FILE=/var/log/freetube/access.log
## 🔍 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
- **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
GITHUB_ID=<ID_github>
#### Media File Issues
- **404 on images**: Verify nginx proxy configuration
- **Upload fails**: Check file permissions and upload directory
GITHUB_SECRET=<secret_github>
```
#### Docker Issues
- **Containers won't start**: Check port conflicts
- **Database connection fails**: Verify environment variables
- **Build failures**: Clear Docker cache with `docker system prune`
#### Installation des paquets
### 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
# Check container status
docker-compose ps
# Download and install nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# View service logs
docker-compose logs -f [service-name]
# in lieu of restarting the shell
\. "$HOME/.nvm/nvm.sh"
# Access container shell
docker-compose exec [service-name] /bin/bash
# Download and install Node.js:
nvm install 22
# Reset everything
docker-compose down --volumes --rmi all
docker system prune -a
# Verify the Node.js version:
node -v # Should print "v22.19.0".
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
- Implement image optimization for uploads
- Use CDN for media file delivery
- Database query optimization
- Frontend code splitting
Pour le serveur
```bash
cd backend && npm i --production
```
## 🤝 Contributing
Pour le site web
```bash
cd frontend && npm i --production
npx vite build # pour la construction du site
```
1. Fork the repository
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
#### Configuration de NGINX
### 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
- **Frontend**: ESLint with React rules
- **Formatting**: Prettier for consistent code style
- **Commits**: Conventional commit messages
server {
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;
}
## 📜 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]
- **Institution**: [Institution Name]
- **Course**: 3 RESIT - Web Development
#### Activer les services
Pour activer et lancer les services
```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:
- Create an issue in the repository
- Check the troubleshooting section
- Review the API documentation
## Documentations externes
**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
# Set the working directory
FROM node:22-alpine
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
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
RUN apk add --no-cache netcat-openbsd
COPY . .
# Install the cli tools
RUN chmod +x ./freetube.sh
RUN cp ./freetube.sh /usr/local/bin/freetube
EXPOSE 8000
# Start the application
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.write("Successfully created new channel with name " + channel.name, 200);
client.release();
res.status(200).json(channel);
}
@ -27,10 +28,67 @@ export async function getById(req, res) {
const logger = req.body.logger;
logger.action("try to get channel with id " + id);
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 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);
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);
client.release();
res.status(200).json(result);
}
@ -88,6 +147,7 @@ export async function update(req, res) {
const nameResult = await client.query(nameQuery, [channel.name]);
if (nameResult.rows.length > 0) {
logger.write("failed to update channel because name already taken", 400);
client.release();
res.status(400).json({error: 'Name already used'});
return
}
@ -96,6 +156,7 @@ export async function update(req, res) {
const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`;
await client.query(updateQuery, [channel.name, channel.description, id]);
logger.write("Successfully updated channel", 200);
client.release();
res.status(200).json(channel);
}
@ -108,6 +169,7 @@ export async function del(req, res) {
const query = `DELETE FROM channels WHERE id = $1`;
await client.query(query, [id]);
logger.write("Successfully deleted channel", 200);
client.release();
res.status(200).json({message: 'Successfully deleted'});
}
@ -132,6 +194,7 @@ export async function toggleSubscription(req, res) {
const remainingSubscriptions = countResult.rows[0].count;
logger.write("Successfully unsubscribed from channel", 200);
client.release();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else {
// Subscribe
@ -144,6 +207,37 @@ export async function toggleSubscription(req, res) {
const totalSubscriptions = countResult.rows[0].count;
logger.write("Successfully subscribed to channel", 200);
client.release();
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
}
client.release();
res.status(200).json(responseComment);
}
@ -52,6 +52,7 @@ export async function getByVideo(req, res) {
const query = `SELECT * FROM comments WHERE video = $1`;
const result = await client.query(query, [videoId]);
logger.write("successfully get comment", 200);
client.release()
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 result = await client.query(query, [id]);
logger.write("successfully get comment", 200);
client.release();
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 result = await client.query(query, [req.body.content, id]);
logger.write("successfully update comment", 200);
client.release();
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 result = await client.query(query, [id]);
logger.write("successfully deleted comment", 200);
client.release();
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 {
const result = await client.query(query, [name, userId]);
logger.write("Playlist created with id " + result.rows[0].id, 200);
client.release()
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error creating playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" });
}
}
@ -31,9 +33,11 @@ export async function addVideo(req, res) {
try {
const result = await client.query(query, [video, id]);
logger.write("Video added to playlist with id " + id, 200);
client.release();
res.status(200).json({id: result.rows[0].id});
} catch (error) {
logger.write("Error adding video to playlist: " + error.message, 500);
client.release();
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]);
if (result.rows.length === 0) {
logger.write("No playlists found for user with id " + id, 404);
client.release();
res.status(404).json({ error: "No playlists found" });
return;
}
logger.write("Playlists retrieved for user with id " + id, 200);
client.release();
res.status(200).json(result.rows);
} catch (error) {
logger.write("Error retrieving playlists: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" });
}
}
@ -76,19 +83,78 @@ export async function getById(req, res) {
const logger = req.body.logger;
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 {
const result = await client.query(query, [id]);
if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404);
client.release();
res.status(404).json({ error: "Playlist not found" });
return;
}
logger.write("Playlist retrieved with id " + id, 200);
client.release();
res.status(200).json(result.rows[0]);
} catch (error) {
logger.write("Error retrieving playlist: " + error.message, 500);
client.release();
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]);
if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404);
client.release();
res.status(404).json({ error: "Playlist not found", result: result.rows, query: query });
return;
}
logger.write("Playlist updated with id " + result.rows[0].id, 200);
client.release();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error updating playlist: " + error.message, 500);
client.release();
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]);
if (result.rows.length === 0) {
logger.write("No video found in playlist with id " + id, 404);
client.release();
res.status(404).json({ error: "Video not found in playlist" });
return;
}
logger.write("Video deleted from playlist with id " + id, 200);
client.release();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error deleting video from playlist: " + error.message, 500);
client.release();
res.status(500).json({ error: "Internal server error" });
}
}
@ -148,9 +220,86 @@ export async function del(req, res) {
try {
const result = await client.query(query, [id]);
logger.write("Playlist deleted", 200);
client.release()
res.status(200).json({ "message": "playlist deleted" });
} catch (error) {
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" });
}
}

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

@ -1,88 +1,277 @@
import {getClient} from "../utils/database.js";
import jwt from 'jsonwebtoken';
export async function getRecommendations(req, res) {
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 queryMostUsedToken = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`;
let result = await client.query(queryMostUsedToken);
let queryMostUsedTags = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`;
let result = await client.query(queryMostUsedTags);
// GET 10 VIDEOS WITH THE TAGS
const recommendations = result.rows;
let tagIds = result.rows.map(tag => tag.id);
let queryVideosWithTags = `
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);
} else {
// Recuperer les 20 derniere vu de l'historique
let client = await getClient();
let queryLastVideos = `SELECT video_id FROM history WHERE user_id = $1 ORDER BY viewed_at DESC LIMIT 20;`;
// TODO: Implement retrieval of recommendations based on user history and interactions
// Recuperer les likes de l'utilisateur sur les 20 derniere videos recuperees
// Recuperer les commentaires de l'utilisateur sur les 20 derniere videos recuperees
const claims = jwt.decode(token)
const query = `
-- Recommandation de contenu similaire non vu basée sur les interactions utilisateur
-- 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 3 tags avec lesquels l'utilisateur a le plus interagi
`;
let result = await client.query(query, [claims.id]);
// Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur
res.status(200).json({
message: "Recommendations based on user history and interactions are not yet implemented."
});
client.release()
res.status(200).json(result.rows);
}
}
export async function getTrendingVideos(req, res) {
const client = await getClient();
try {
// GET 10 VIDEOS WITH THE MOST LIKES AND COMMENTS
let client = await getClient();
// Optimized single query to get all trending video data
let queryTrendingVideos = `
SELECT v.id, v.title, v.description, v.release_date, v.thumbnail,
COUNT(DISTINCT l.id) AS like_count, COUNT(DISTINCT c.id) AS comment_count
SELECT
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
LEFT JOIN likes l ON v.id = l.video
LEFT JOIN comments c ON v.id = c.video
GROUP BY v.id
ORDER BY like_count DESC, comment_count DESC
LIMIT 10;
`;
LEFT JOIN history h ON v.id = h.video
LEFT JOIN channels ch ON v.channel = ch.id
LEFT JOIN users u ON ch.owner = u.id
WHERE v.visibility = 'public'
GROUP BY v.id, ch.id, ch.name, u.picture
ORDER BY like_count DESC, comment_count DESC, views DESC
LIMIT 10
`;
let result = await client.query(queryTrendingVideos);
const trendingVideos = result.rows;
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 profilePictureQuery = `SELECT u.picture FROM users u JOIN channels c ON u.id = c.owner WHERE c.id = $1;`;
let profilePictureResult = await client.query(profilePictureQuery, [video.creator.id]);
if (profilePictureResult.rows.length > 0) {
video.creator.profilePicture = profilePictureResult.rows[0].picture;
} else {
video.creator.profilePicture = null; // Default or placeholder image can be set here
const trendingVideos = result.rows.map(video => ({
id: video.id,
title: video.title,
description: video.description,
release_date: video.release_date,
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);
} catch (error) {
console.error("Error fetching trending videos:", error);
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);
const query = req.query.q;
const type = req.query.type || 'all';
const offset = req.query.offset || 0;
const limit = req.query.limit || 20;
const offset = parseInt(req.query.offset) || 0;
const limit = parseInt(req.query.limit) || 20;
const client = await getClient();
@ -15,71 +15,80 @@ export async function search(req, res) {
}
if (type === 'videos') {
// Search video in database based on the query, video title, tags and author
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 from tags
const tagQuery = `SELECT id FROM tags WHERE name ILIKE $1 OFFSET $3 LIMIT $2`;
const tagResult = await client.query(tagQuery, [`%${query}%`, limit, offset]);
const tags = tagResult.rows.map(tag => tag.name);
for (const tag of tags) {
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`;
const videoTagResult = await client.query(videoTagQuery, [tag, limit, offset]);
videoNameResult.rows.push(...videoTagResult.rows);
let videoResults = [];
// Search video in database based on the video title
const videoNameQuery = `
SELECT
v.id, v.title, v.thumbnail, v.description as video_description, v.channel, v.visibility, v.file, v.slug, v.format, v.release_date,
c.id as channel_id, c.owner, c.description as channel_description, c.name,
u.picture as profilePicture,
COUNT(h.id) as views
FROM videos as v
JOIN public.channels c on v.channel = c.id
JOIN public.users u on c.owner = u.id
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
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}%`]);
for (const author of authorResult.rows) {
if (!videoNameResult.rows.some(video => video.id === author.id)) {
videoNameResult.rows.push(author);
}
}
const videos = [];
for (let video of videoNameResult.rows) {
video = video.id; // Extracting the video ID
let videoDetails = {};
// Fetching video details
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) {
continue; // Skip if no video details found
}
videoDetails = videoDetailsResult.rows[0];
// Setting the type
videoDetails.type = 'video';
// 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(videoResults);
} else if (type === 'channel') {
let channelResults = [];
// 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
FROM public.channels c
JOIN public.users u on c.owner = u.id
LEFT JOIN public.subscriptions s ON s.channel = c.id
WHERE c.name ILIKE $1
group by c.name, c.id, c.description, c.owner, u.picture
OFFSET $2
LIMIT $3;
`;
const channelNameResult = await client.query(channelNameQuery, [`%${query}%`, offset, limit]);
const channelNames = channelNameResult.rows;
for (const channel of channelNames) {
channel.type = "channel";
channel.profilePicture = channel.profilepicture; // Rename for consistency
delete channel.profilepicture;
channelResults.push(channel);
}
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 {getClient} from "../utils/database.js";
import { getClient } from "../utils/database.js";
import jwt from "jsonwebtoken";
import path, {dirname} from "path";
import path, { dirname } from "path";
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) {
try {
@ -43,15 +45,128 @@ export async function register(req, res) {
const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`;
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");
client.end();
logger.write("successfully registered", 200);
res.status(200).send({user: user});
res.status(200).send({ user: user });
} catch (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) {
@ -65,42 +180,49 @@ export async function login(req, res) {
const client = await getClient();
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];
try {
let query = `SELECT id, username, email, picture, password FROM users WHERE username = $1`;
const result = await client.query(query, [user.username]);
if (!userInBase) {
logger.write("failed to login", 401)
res.status(401).json({error: "Invalid credentials"});
return
}
const userInBase = result.rows[0];
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) {
logger.write("failed to login", 401)
res.status(401).json({error: "Invalid credentials"});
return
}
const isPasswordValid = await bcrypt.compare(req.body.password, userInBase.password);
const payload = {
id: userInBase.id,
username: userInBase.username,
}
if (!isPasswordValid) {
logger.write("failed to login", 401)
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 = {
id: userInBase.id,
username: userInBase.username,
email: userInBase.email,
picture: userInBase.picture
}
const token = jwt.sign(payload, process.env.JWT_SECRET);
logger.write("Successfully logged in", 200);
res.status(200).json({token: token, user: userData});
const 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) {
@ -108,15 +230,20 @@ export async function getById(req, res) {
const logger = req.body.logger;
logger.action("try to retrieve user " + id);
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]);
if (!result.rows[0]) {
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
}
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) {
@ -128,11 +255,13 @@ export async function getByUsername(req, res) {
const result = await client.query(query, [username]);
if (!result.rows[0]) {
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
}
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) {
@ -159,7 +288,8 @@ export async function update(req, res) {
const emailResult = await client.query(emailQuery, [user.email]);
if (emailResult.rows[0]) {
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]);
if (usernameResult.rows[0]) {
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;
}
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 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);
res.status(200).send({user: result.rows[0]});
client.release();
res.status(200).json(result.rows[0]);
} catch (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`;
await client.query(query, [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) {
@ -217,12 +368,14 @@ export async function getChannel(req, res) {
if (!result.rows[0]) {
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;
}
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) {
@ -267,10 +420,141 @@ export async function getHistory(req, res) {
if (!result.rows[0]) {
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;
}
logger.write("successfully retrieved history of user " + id, 200);
client.release();
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 fs from "node:fs";
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import jwt from "jsonwebtoken";
import {query} from "express-validator";
import { sendEmail } from "../utils/mail.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export async function upload(req, res) {
// HANDLE VIDEO FILE
const fileBuffer = req.file.buffer;
let isGenerate = false;
while (isGenerate === false) {
@ -22,7 +23,7 @@ export async function upload(req, res) {
const query = `SELECT * FROM videos WHERE slug = $1`;
const result = await client.query(query, [hex]);
client.end();
client.release();
if (result.rows.length === 0) {
isGenerate = true;
req.body.slug = hex;
@ -45,14 +46,119 @@ export async function upload(req, res) {
visibility: req.body.visibility,
}
// HANDLE VIDEO DETAILS
const logger = req.body.logger;
logger.write("try to upload video");
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)`;
await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]);
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`;
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);
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`;
await client.query(updateQuery, [file, req.body.video]);
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) {
@ -97,7 +204,7 @@ export async function getById(req, res) {
video.likes = likesResult.rows[0].like_count;
// 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]);
video.comments = commentsResult.rows;
@ -121,7 +228,13 @@ export async function getById(req, res) {
const tagsResult = await client.query(tagsQuery, [id]);
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);
client.release()
res.status(200).json(video);
}
@ -133,6 +246,7 @@ export async function getByChannel(req, res) {
const query = `SELECT * FROM videos WHERE channel = $1`;
const result = await client.query(query, [id]);
logger.write("successfully get video from channel " + id, 200);
client.release()
res.status(200).json(result.rows);
}
@ -143,8 +257,48 @@ export async function update(req, res) {
const client = await getClient();
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]);
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);
res.status(200).json({"message": "Successfully updated video"});
client.release()
res.status(200).json(result.rows[0]);
}
export async function updateVideo(req, res) {
@ -157,14 +311,15 @@ export async function updateVideo(req, res) {
const video = videoResult.rows[0];
const slug = video.slug;
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) => {
if (error) {
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
}
logger.action("successfully deleted video " + slug + "." + format );
logger.action("successfully deleted video " + slug + "." + format);
const fileBuffer = req.file.buffer;
const finalName = slug + "." + format;
const destinationPath = path.join(__dirname, "../uploads/videos/" + finalName)
@ -172,7 +327,8 @@ export async function updateVideo(req, res) {
fs.writeFileSync(destinationPath, fileBuffer);
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) => {
if (error) {
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
}
@ -201,13 +358,14 @@ export async function del(req, res) {
fs.unlink(pathToDelete, async (error) => {
if (error) {
logger.write(error, 500);
res.status(500).json({"message": "Failed to delete video"});
res.status(500).json({ "message": "Failed to delete video" });
return
}
const query = `DELETE FROM videos WHERE id = $1`;
await client.query(query, [id]);
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;
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 {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
await client.query(query, [userId, id]);
@ -248,7 +407,8 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count;
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)`;
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);
await client.end();
res.status(200).json({"message": "Successfully added tags to video"});
await client.release();
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) {
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;
}
@ -326,7 +492,7 @@ export async function getSimilarVideos(req, res) {
JOIN video_tags vt ON v.id = vt.video
JOIN tags t ON vt.tag = t.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
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);
}
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) {
const id = req.params.id;
const logger = req.body.logger;
@ -378,5 +596,41 @@ export async function addViews(req, res) {
}
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", {
ignore: " _-"
}).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"),
}
@ -15,62 +15,77 @@ export const ChannelCreate = {
name: body("name").notEmpty().isAlphanumeric("fr-FR", {
ignore: " _-"
}).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(),
}
export async function doUserHaveChannel(req, res, next) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE owner = ${req.body.owner}`;
const result = await client.query(query);
try {
const query = `SELECT id FROM channels WHERE owner = $1`;
const result = await client.query(query, [req.body.owner]);
if (result.rows[0]) {
logger.write("failed because user already has a channel", 400);
res.status(400).json({error: "User already own a channel"})
} else {
next()
if (result.rows[0]) {
logger.write("failed because user already has a channel", 400);
res.status(400).json({error: "User already own a channel"})
} else {
next()
}
} finally {
client.release();
}
}
export async function doChannelNameExists(req, res, next) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT * FROM channels WHERE name = '${req.body.name}'`;
const result = await client.query(query);
try {
const query = `SELECT * FROM channels WHERE name = $1`;
const result = await client.query(query, [req.body.name]);
if (result.rows[0]) {
logger.write("failed because channel name already exist", 400)
res.status(400).json({error: "Channel name already used"})
} else {
next()
if (result.rows[0]) {
logger.write("failed because channel name already exist", 400)
res.status(400).json({error: "Channel name already used"})
} else {
next()
}
} finally {
client.release();
}
}
export async function doChannelExists(req, res, next) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE id = '${req.params.id}'`;
const result = await client.query(query);
if (result.rows[0]) {
next()
} else {
logger.write("failed to retrieve channel because it doesn't exist", 404);
res.status(404).json({error: "Not Found"})
try {
const query = `SELECT id FROM channels WHERE id = $1`;
const result = await client.query(query, [req.params.id]);
if (result.rows[0]) {
next()
} else {
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) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT id FROM channels WHERE id = ${req.body.channel}`;
const result = await client.query(query);
if (result.rows[0]) {
next()
} else {
logger.write("failed to retrieve channel because it doesn't exist", 404);
res.status(404).json({error: "Not Found"})
try {
const query = `SELECT id FROM channels WHERE id = $1`;
const result = await client.query(query, [req.body.channel]);
if (result.rows[0]) {
next()
} else {
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 client = await getClient();
const query = `SELECT id, owner FROM channels WHERE id = ${id}`;
const result = await client.query(query);
const channel = result.rows[0];
if (channel.owner != claims.id) {
logger.write("failed because user do not own the channel", 403);
res.status(403).json({error: "You're not the owner of the channel"})
} else {
next()
try {
const query = `SELECT id, owner FROM channels WHERE id = $1`;
const result = await client.query(query, [id]);
const channel = result.rows[0];
if (channel.owner != claims.id) {
logger.write("failed because user do not own the channel", 403);
res.status(403).json({error: "You're not the owner of the channel"})
} else {
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 client = await getClient();
const query = `SELECT * FROM comments WHERE id = ${id}`;
const result = await client.query(query);
if (result.rows.length === 0) {
logger.write("comment not found", 404);
res.status(404).json({error: "comment not found"});
return
try {
const query = `SELECT * FROM comments WHERE id = $1`;
const result = await client.query(query, [id]);
if (result.rows.length === 0) {
logger.write("comment not found", 404);
res.status(404).json({error: "comment not found"});
return
}
next()
} finally {
client.release();
}
next()
}
export async function isAuthor(req, res, next) {
@ -37,12 +41,16 @@ export async function isAuthor(req, res, next) {
const userId = claims.id;
const logger = req.body.logger;
const client = await getClient();
const query = `SELECT * FROM comments WHERE id = ${id}`;
const result = await client.query(query);
if (userId !== result.rows[0].author) {
logger.write("is not author of the comment", 403);
res.status(403).json({error: "You do not have permission"});
return
try {
const query = `SELECT * FROM comments WHERE id = $1`;
const result = await client.query(query, [id]);
if (userId !== result.rows[0].author) {
logger.write("is not author of the comment", 403);
res.status(403).json({error: "You do not have permission"});
return
}
next()
} finally {
client.release();
}
next()
}

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

@ -5,7 +5,7 @@ import jwt from "jsonwebtoken";
export const Playlist = {
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(),
videoId: param("videoId").notEmpty().isNumeric().trim(),
}
@ -16,20 +16,23 @@ export async function doPlaylistExists(req, res, next) {
const logger = req.body.logger;
const client = await getClient();
const query = `SELECT id FROM playlists WHERE id = ${id}`;
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) {
logger.write("Error checking playlist existence: " + error.message, 500);
res.status(500).json({error: error});
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) {
@ -41,18 +44,20 @@ export async function isOwner(req, res, next) {
const logger = req.body.logger;
const client = await getClient();
const query = `SELECT owner FROM playlists WHERE id = ${id}`;
const result = await client.query(query);
if(result.rows[0].owner !== userId) {
logger.write("user not the owner of the playlist with id " + id, 403);
res.status(403).json({error: "You do not have permission"});
return;
try {
const query = `SELECT owner FROM playlists WHERE id = $1`;
const result = await client.query(query, [id]);
if(result.rows[0].owner !== userId) {
logger.write("user not the owner of the playlist with id " + id, 403);
res.status(403).json({error: "You do not have permission"});
return;
}
next();
} finally {
client.release();
}
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 logger = req.body.logger;
const client = await getClient();
const query = `SELECT id FROM playlist_elements WHERE video = ${videoId} AND playlist = ${id}`;
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) {
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 });
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 jwt from "jsonwebtoken";
export const User = {
id: param("id").notEmpty().isNumeric().trim(),
email: body("email").notEmpty().isEmail().trim(),
username: body("username").notEmpty().isAlphanumeric().trim(),
password: body("password").notEmpty().isAlphanumeric().trim(),
username: body("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
password: body("password").notEmpty().trim(),
picture: body("picture").notEmpty().isAlphanumeric().trim(),
}
export const UserRegister = {
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({
minLength: 8,
maxLength: 32,
@ -23,7 +23,7 @@ export const UserRegister = {
}
export const UserLogin = {
username: body("username").notEmpty().isAlphanumeric().trim(),
username: body("username").notEmpty().isAlphanumeric("fr-FR", {ignore: " _-"}).trim(),
password: body("password").notEmpty().isStrongPassword({
minLength: 8,
maxLength: 32,
@ -34,61 +34,79 @@ export const UserLogin = {
}
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) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
const result = await client.query(query);
try {
const query = `SELECT * FROM users WHERE email = $1`;
const result = await client.query(query, [req.body.email]);
if (result.rows.length > 0) {
logger.write("failed because email already exists", 400)
res.status(400).send({error: "Email already exists"})
} else {
next()
if (result.rows.length > 0) {
logger.write("failed because email already exists", 400)
res.status(400).send({error: "Email already exists"})
} else {
next()
}
} finally {
client.release();
}
}
export async function doUsernameExists(req, res, next) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT * FROM users WHERE username = '${req.body.username}'`;
const result = await client.query(query);
if (result.rows.length > 0) {
logger.write("failed because username already exists", 400)
res.status(400).send({error: "Username already exists"})
} else {
next()
try {
const query = `SELECT * FROM users WHERE username = $1`;
const result = await client.query(query, [req.body.username]);
if (result.rows.length > 0) {
logger.write("failed because username already exists", 400)
res.status(400).send({error: "Username already exists"})
} else {
next()
}
} finally {
client.release();
}
}
export async function doUserExists(req, res, next) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT id FROM users WHERE id = ${req.params.id}`;
const result = await client.query(query);
if (result.rows.length > 0) {
next()
}else{
logger.write("failed because user doesn't exists", 404)
res.status(404).json({error: "Not Found"})
try {
const query = `SELECT id FROM users WHERE id = $1`;
const result = await client.query(query, [req.params.id]);
if (result.rows.length > 0) {
next()
}else{
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) {
const client = await getClient();
const logger = req.body.logger;
const query = `SELECT id FROM users WHERE id = ${req.body.owner}`;
const result = await client.query(query);
if (result.rows.length > 0) {
next()
}else{
logger.write("failed because user doesn't exists", 404)
res.status(404).json({error: "Not Found"})
try {
const query = `SELECT id FROM users WHERE id = $1`;
const result = await client.query(query, [req.body.owner]);
if (result.rows.length > 0) {
next()
}else{
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 = {
id: param("id").notEmpty().isNumeric().trim(),
title: body("title").notEmpty().isAlphanumeric("fr-FR", {'ignore': " _-"}).trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric("fr-FR", {ignore: " -_"}).trim(),
title: body("title").notEmpty().trim(),
description: body("description").optional({values: "falsy"}).trim(),
channel: body("channel").notEmpty().isNumeric().trim(),
visibility: body("visibility").notEmpty().isAlpha().trim(),
idBody: body("video").notEmpty().isNumeric().trim(),
@ -19,10 +19,25 @@ export const Video = {
}
export const VideoCreate = {
title: body("title").notEmpty().isAlphanumeric("fr-FR", {'ignore': " _-"}).trim(),
description: body("description").optional({values: "falsy"}).isAlphanumeric("fr-FR", {ignore: " -_"}).trim(),
title: body("title").notEmpty().trim(),
description: body("description").optional({values: "falsy"}).trim(),
channel: body("channel").notEmpty().isNumeric().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 = {
@ -35,15 +50,19 @@ export async function isOwner(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
const claims = jwt.decode(token);
const client = await getClient();
const channelQuery = `SELECT owner FROM channels WHERE id = '${channelId}'`;
const channelResult = await client.query(channelQuery);
const channelInBase = channelResult.rows[0];
if (channelInBase.owner !== claims.id) {
logger.write("failed because user is not owner", 403);
res.status(403).json({error: "Not authorized"});
return
try {
const channelQuery = `SELECT owner FROM channels WHERE id = $1`;
const channelResult = await client.query(channelQuery, [channelId]);
const channelInBase = channelResult.rows[0];
if (channelInBase.owner !== claims.id) {
logger.write("failed because user is not owner", 403);
res.status(403).json({error: "Not authorized"});
return
}
next()
} finally {
client.release();
}
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 client = await getClient();
const query = `SELECT * FROM videos WHERE id = ${videoId}`;
const result = await client.query(query);
const videos = result.rows;
if (videos.length === 0) {
logger.write("failed because video not found", 404);
res.status(404).json({error: "Not Found"});
return
try {
const query = `SELECT * FROM videos WHERE id = $1`;
const result = await client.query(query, [videoId]);
const videos = result.rows;
if (videos.length === 0) {
logger.write("failed because video not found", 404);
res.status(404).json({error: "Not Found"});
return
}
next()
} finally {
client.release();
}
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 client = await getClient();
const query = `SELECT * FROM videos WHERE id = ${videoId}`;
const result = await client.query(query);
const video = result.rows[0];
if (!video) {
logger.write("failed because video not found", 404);
res.status(404).json({error: "Not Found"});
return
try {
const query = `SELECT * FROM videos WHERE id = $1`;
const result = await client.query(query, [videoId]);
const video = result.rows[0];
if (!video) {
logger.write("failed because video not found", 404);
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()
}
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 {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 {
Channel,
@ -14,11 +14,48 @@ import {addLogger} from "../middlewares/logger.middleware.js";
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);
// 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
router.get("/", [addLogger, isTokenValid], getAll);
@ -32,4 +69,8 @@ router.delete("/:id", [addLogger, isTokenValid, Channel.id, validator, doChannel
// TOGGLE SUBSCRIPTION
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;

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 {doPlaylistExists, Playlist, isOwner, isVideoInPlaylist} from "../middlewares/playlist.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 {doUserExists, User, isOwner as isOwnerUser} from "../middlewares/user.middleware.js";
@ -12,6 +12,9 @@ const router = new Router();
// CREATE PLAYLIST
router.post("/", [addLogger, isTokenValid, Playlist.name, validator], create);
// GET SEE LATER PLAYLIST
router.get("/see-later", [addLogger, isTokenValid], getSeeLater);
// ADD VIDEO TO PLAYLIST
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
router.delete("/:id", [addLogger, isTokenValid, Playlist.id, validator, doPlaylistExists, isOwner], del);
export default router;

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

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

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

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

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

@ -6,7 +6,12 @@ import {
getByUsername,
update,
deleteUser,
getChannel, getHistory
getChannel, getHistory,
isSubscribed,
verifyEmail,
searchByUsername,
getAllSubscriptions,
getAllSubscriptionVideos
} from "../controllers/user.controller.js";
import {
UserRegister,
@ -16,12 +21,14 @@ import {
isOwner,
UserLogin,
User,
UserRequest
UserRequest,
UserSearch
} from "../middlewares/user.middleware.js";
import validator from "../middlewares/error.middleware.js";
import {isTokenValid} from "../middlewares/jwt.middleware.js";
import {addLogger} from "../middlewares/logger.middleware.js";
import {profileUpload} from "../middlewares/file.middleware.js";
import {Channel} from "../middlewares/channel.middleware.js";
const router = Router();
@ -31,6 +38,9 @@ router.post("/", [profileUpload.single("profile"), addLogger, UserRegister.email
// LOGIN A USER
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
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);
// 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
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
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;

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

@ -10,7 +10,7 @@ import {
uploadThumbnail,
updateVideo,
toggleLike,
addTags, getSimilarVideos, addViews
addTags, getSimilarVideos, addViews, getLikesPerDay, updateAuthorizedUsers
} from "../controllers/video.controller.js";
import {
doVideoExists,
@ -18,7 +18,9 @@ import {
isOwner,
Video,
VideoCreate,
VideoThumbnail
VideoThumbnail,
doAuthorizedUserExists,
hasAccess
} from "../middlewares/video.middleware.js";
import {Channel, doChannelExistBody, doChannelExists} from "../middlewares/channel.middleware.js";
import {thumbnailUpload, videoUpload} from "../middlewares/file.middleware.js";
@ -27,19 +29,20 @@ import validator from "../middlewares/error.middleware.js";
const router = Router();
// 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
router.post("/thumbnail", [thumbnailUpload.single('file'), addLogger, isTokenValid, VideoThumbnail.video, Video.channel, validator, doChannelExistBody, isOwner, doVideoExists], uploadThumbnail )
// 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
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
router.put("/:id", [addLogger, isTokenValid, Video.id, VideoCreate.title, VideoCreate.description, VideoCreate.visibility, VideoCreate.channel, validator, doVideoExistsParam, doChannelExistBody, isOwner], update);
// UPDATE VIDEO
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);
// 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
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;

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";
// 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() {
const client = new pg.Client({
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
port: 5432
})
await client.connect();
return client;
// Use pool.connect() instead of creating new clients
return await pool.connect();
}
// Graceful shutdown
process.on('SIGINT', () => {
pool.end(() => {
console.log('Pool has ended');
process.exit(0);
});
});
export async function initDb() {
const client = await getClient();
@ -20,10 +33,14 @@ export async function initDb() {
try {
let query = `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
email VARCHAR(255),
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
picture VARCHAR(255)
password 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);
@ -31,7 +48,7 @@ export async function initDb() {
id SERIAL PRIMARY KEY,
name VARCHAR(255) 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);
@ -40,7 +57,7 @@ export async function initDb() {
title VARCHAR(255) NOT NULL,
thumbnail VARCHAR(255) 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',
file VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
@ -53,37 +70,38 @@ export async function initDb() {
(
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
author INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id),
author INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS likes (
id SERIAL PRIMARY KEY,
owner INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id)
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS playlists (
id SERIAL PRIMARY KEY,
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);
query = `CREATE TABLE IF NOT EXISTS playlist_elements (
id SERIAL PRIMARY KEY,
video INTEGER NOT NULL REFERENCES videos(id),
playlist INTEGER NOT NULL REFERENCES playlists(id)
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS subscriptions (
id SERIAL PRIMARY KEY,
channel INTEGER NOT NULL REFERENCES channels(id),
owner INTEGER NOT NULL REFERENCES users(id)
channel INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`;
await client.query(query);
@ -95,21 +113,50 @@ export async function initDb() {
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS video_tags (
video INTEGER NOT NULL REFERENCES videos(id),
tag INTEGER NOT NULL REFERENCES tags(id)
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
tag INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
)`
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
viewed_at TIMESTAMP NOT NULL DEFAULT NOW()
)`;
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) {
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",
"dotenv": "^17.0.1",
"express": "^5.1.0",
"express-session": "^1.18.2",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.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": {
"chai": "^5.2.0",
@ -31,6 +38,50 @@
"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": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
@ -481,6 +532,12 @@
"dev": true,
"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": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -795,6 +852,13 @@
"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": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@ -826,6 +890,12 @@
"dev": true,
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -1034,7 +1104,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/asap": {
@ -1065,9 +1134,17 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -1119,7 +1196,6 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@ -1217,6 +1293,12 @@
"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": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@ -1467,6 +1549,15 @@
"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": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@ -1481,7 +1572,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
@ -1666,6 +1756,18 @@
"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": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz",
@ -1863,6 +1965,15 @@
"@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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -1924,6 +2035,46 @@
"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": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
@ -2094,6 +2245,12 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2354,6 +2511,17 @@
"dev": true,
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2531,7 +2699,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@ -2605,6 +2772,13 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -2617,6 +2791,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"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": {
"version": "4.0.4",
"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==",
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@ -2822,7 +3009,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -3104,6 +3290,15 @@
"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": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@ -3143,6 +3338,12 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3176,6 +3377,15 @@
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -3185,6 +3395,13 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -3233,6 +3450,63 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3243,6 +3517,15 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3296,6 +3579,11 @@
"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": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@ -3524,6 +3812,15 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -4125,6 +4422,92 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz",
@ -4282,6 +4665,24 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"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": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -4311,6 +4712,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
@ -4704,6 +5114,18 @@
"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": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -4806,6 +5228,36 @@
"funding": {
"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",
"dotenv": "^17.0.1",
"express": "^5.1.0",
"express-session": "^1.18.2",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.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": {
"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
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}}
### 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
Authorization: Bearer {{token}}
@ -26,7 +26,7 @@ Authorization: Bearer {{token}}
"Create Mod",
"Redstone"
],
"channel": 2
"channel": 1
}
###

64
backend/server.js

@ -1,4 +1,7 @@
import express from "express";
import swaggerui from "swagger-ui-express";
import fs from "fs";
import YAML from "yaml";
import dotenv from "dotenv";
import UserRoute from "./app/routes/user.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 MediaRoutes from "./app/routes/media.routes.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();
dotenv.config();
console.log(process.env)
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}));
app.use(express.json());
app.use(cors())
// Swagger setup
const file = fs.readFileSync('./swagger.yaml', 'utf8');
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
app.use("/api/users/", UserRoute);
@ -34,6 +89,7 @@ app.use("/api/playlists", PlaylistRoute);
app.use("/api/recommendations", RecommendationRoute);
app.use("/api/media", MediaRoutes);
app.use("/api/search", SearchRoute);
app.use("/api/oauth", OAuthRoute);
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
});
await client.connect();
await client.query('TRUNCATE TABLE users CASCADE', (err, res) => {
if (err) {
console.error('Error flushing database:', err);
} else {
console.log('Database flushed successfully.');
}
});}
try {
await client.connect();
await client.query('TRUNCATE TABLE users CASCADE');
console.log('Database flushed successfully.');
} catch (err) {
console.error('Error flushing database:', err);
} finally {
await client.end();
}
}

145
checklist.md

@ -22,122 +22,125 @@
## **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 miniature vidéo (2 points)
- [x] Titre (2 points)
- [x] Description (2 points)
- [x] Date de mise en ligne automatique (2 points)
- [ ] Mots-clefs/hashtags jusqu'à 10 (2 points)
- [ ] Visibilité publique/privée (5 points)
- [ ] Génération lien partageable (5 points)
- [x] Visibilité publique/privée (5 points)
- [x] Génération lien partageable (5 points) - via slug système
### Gestion vidéos existantes
- [ ] Éditer une vidéo existante (5 points)
- [ ] Changer la visibilité (5 points)
### Gestion vidéos existantes (15 points) - 10/15 points
- [x] Éditer une vidéo existante (5 points)
- [x] Changer la visibilité (5 points)
- [x] Supprimer une vidéo (5 points)
### Statistiques
- [ ] Statistiques par vidéo (vues, likes, commentaires) (5 points)
### Statistiques (10 points) - 5/10 points
- [x] Statistiques par vidéo (vues, likes, commentaires) (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 "À 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 Tendances (5 points)
- [ ] Section Top créateurs (plus d'abonnés) (5 points)
## **PAGE ABONNEMENTS** (10 points)
- [ ] Fil d'actualité des abonnements (8 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)
- [ ] Gestion et liste des playlists (5 points)
- [x] Gestion et liste des playlists (5 points)
## **PAGE PLAYLIST** (10 points)
- [ ] Affichage nom playlist et vidéos
- [ ] Tri par date d'ajout
- [ ] Navigation depuis page utilisateur
## **PAGE PLAYLIST** (10 points) - 8/10 points ✅
- [x] Affichage nom playlist et vidéos (4 points)
- [x] Tri par date d'ajout (2 points)
- [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)
- [ ] Bouton Pause (2 points)
- [ ] Bouton Play (2 points)
- [ ] Saut XX secondes en avant (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] Description (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 "J'aime" (2 points)
- [ ] Bouton "J'aime" (5 points)
- [ ] Bouton "S'abonner" (5 points)
- [ ] Bouton "S'abonner" (6 points)
### Commentaires (10 points) ✅
- [x] Créer un commentaire (5 points)
- [x] Voir les commentaires (5 points)
### Recommendations (5 points)
- [ ] Section recommendations/tendances selon authentification
- [ ] Section recommendations/tendances selon authentification (5 points)
## **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)
- [ ] Routes s'abonner/désabonner à une chaîne
- [ ] Modèle de données abonnements
- [ ] Compteur d'abonnés par chaîne
### Système "J'aime" (10 points estimés)
- [ ] Routes aimer/ne plus aimer vidéo
- [ ] Modèle de données likes
- [ ] Mise à jour compteur likes
### Gestion playlists (25 points estimés)
- [ ] Routes créer/supprimer playlists
- [ ] Ajout/suppression vidéos dans playlists
- [ ] Playlist "À consulter plus tard" par défaut
- [ ] Affichage contenu playlist
### Historique utilisateur (10 points estimés)
- [ ] Enregistrement automatique vidéos regardées
- [ ] Routes consultation historique
### Système recommandations (15 points estimés)
- [ ] Algorithme pour utilisateurs authentifiés
- [ ] 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
- [ ] Routes s'abonner/désabonner à une chaîne (8 points)
- [ ] Modèle de données abonnements (5 points)
- [ ] Compteur d'abonnés par chaîne (5 points)
### Système de tags/mots-clefs (8 points estimés)
- [ ] Modèle de données tags (3 points)
- [ ] Association vidéos-tags (3 points)
- [ ] Interface gestion tags (2 points)
## **SÉCURITÉ ET MIDDLEWARE**
- [x] Middleware d'authentification JWT
- [x] Validation des données d'entrée
- [x] Gestion des erreurs
- [x] Upload sécurisé de fichiers
- [x] Logging des actions
## **INFRASTRUCTURE**
- [x] Configuration Docker
- [x] Base de données PostgreSQL
- [x] Serveur de fichiers médias
- [x] Tests unitaires
---
**Status actuel estimé : ~102/200 points fonctionnalités**
**Objectif prioritaire : Atteindre 120 points minimum**
## **SCORE ESTIMÉ**
**Backend: ~127/183 points (69%)**
**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

52
developpement.yaml

@ -1,32 +1,35 @@
services:
backend:
resit_backend:
build:
context: ./backend
dockerfile: Dockerfile
network: host
container_name: resit_backend
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "8000:8000"
environment:
DB_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST}
DB_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_HOST: db
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE}
PORT: ${BACKEND_PORT}
GMAIL_USER: ${GMAIL_USER}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
FRONTEND_URL: ${FRONTEND_URL}
volumes:
- ./backend/logs:/var/log/freetube
- ./backend/app/uploads:/app/app/uploads
- ./backend/app/utils/wait-for-it.sh:/wait-for-it.sh
- ./backend:/app
depends_on:
- db
command: ["/wait-for-it.sh", "${POSTGRES_HOST}:5432", "--", "npm", "start"]
db:
condition: service_healthy
db:
image: postgres:latest
container_name: resit_db
ports:
- "5432:5432"
environment:
@ -35,20 +38,29 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- 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:
build:
context: ./frontend
dockerfile: Dockerfile
network: host
image: nginx:alpine
container_name: resit_frontend
ports:
- "5173:5173"
- "80:80"
- "443:443"
volumes:
- ./frontend/:/app
- /app/node_modules
- ./frontend/dist:/usr/share/nginx/html
- ./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:
- backend
- resit_backend
volumes:
db_data:
driver: local
driver: local

36
docker-compose.yaml

@ -9,18 +9,24 @@ services:
ports:
- "8000:8000"
environment:
DB_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST}
DB_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_HOST: db
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE}
PORT: ${BACKEND_PORT}
GMAIL_USER: ${GMAIL_USER}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
FRONTEND_URL: ${FRONTEND_URL}
volumes:
- ./backend/logs:/var/log/freetube
- ./backend:/app
depends_on:
- db
db:
condition: service_healthy
db:
image: postgres:latest
@ -32,15 +38,25 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- 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:
image: nginx:latest
network_mode: host
build:
context: ./frontend
dockerfile: Dockerfile
container_name: resit_frontend
ports:
- "80:80"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- "443:443"
environment:
- VITE_API_BASE_URL=https://localhost/api
depends_on:
- resit_backend
volumes:
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
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
RUN npm run build
# 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",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11"
@ -1004,6 +1006,12 @@
"@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": {
"version": "1.0.0-beta.19",
"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"
}
},
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2999,6 +3019,16 @@
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

5
frontend/package.json

@ -5,13 +5,16 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build --watch",
"build": "vite build",
"build:watch": "vite build --watch",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.3",
"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">
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 6v12l10-6z"></path></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 {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 commentRef = useRef();
@ -17,23 +18,11 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
}
const handleDelete = async (id) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/comments/${id}`, {
method: 'DELETE',
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 token = localStorage.getItem('token');
const response = await deleteComment(id, token, addAlert);
if (response) {
refetchVideo();
}
}
const handleEditSubmit = async (id, content) => {
@ -41,36 +30,25 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
alert("Comment cannot be empty");
return;
}
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
try {
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
if (!token) {
navigation('/login');
return;
}
if (!token) {
navigation('/login');
return;
}
const body = {
content: content,
video: videoId
};
const response = await fetch(`/api/comments/${comment.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
content: content,
video: videoId
})
});
const response = await updateComment(id, body, token, addAlert);
if (!response.ok) {
throw new Error('Failed to post comment');
}
if (response) {
setEditMode(false);
} catch (error) {
console.error('Error posting comment:', error);
}
}
return (
@ -82,39 +60,44 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
className="w-8 h-8 rounded-full object-cover mr-3"
/>
<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>
<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 ? (
<button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}>
Modifier
</button>
) : null }
{ isAuthenticated && user.username === comment.username && editMode === false ? (
<button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}>
Supprimer
</button>
) : null
}
{ isAuthenticated && user.username === comment.username && editMode ? (
<button className="text-green-500 mt-2 hover:underline" onClick={() => {
setEditMode(false);
commentRef.current.contentEditable = false;
handleEditSubmit(comment.id, commentRef.current.textContent);
}}>
Enregistrer
</button>
) : null }
{ isAuthenticated && user.username === comment.username && editMode ? (
<button className="text-gray-500 mt-2 hover:underline" onClick={() => {
setEditMode(false);
commentRef.current.contentEditable = false;
}}>
Annuler
</button>
) : null }
</div>
{
doShowCommands && (
<div className="flex gap-2 items-center mt-2">
{ isAuthenticated && user.username === comment.username && editMode === false ? (
<button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}>
Modifier
</button>
) : null }
{ isAuthenticated && user.username === comment.username && editMode === false ? (
<button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}>
Supprimer
</button>
) : null
}
{ isAuthenticated && user.username === comment.username && editMode ? (
<button className="text-green-500 mt-2 hover:underline" onClick={() => {
setEditMode(false);
commentRef.current.contentEditable = false;
handleEditSubmit(comment.id, commentRef.current.textContent);
}}>
Enregistrer
</button>
) : null }
{ isAuthenticated && user.username === comment.username && editMode ? (
<button className="text-gray-500 mt-2 hover:underline" onClick={() => {
setEditMode(false);
commentRef.current.contentEditable = false;
}}>
Annuler
</button>
) : null }
</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 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 [searchQuery, setSearchQuery] = useState('');
const [internalAlerts, setInternalAlerts] = useState([]);
const [isNavbarOpen, setIsNavbarOpen] = useState(false);
const navigate = useNavigate();
const handleLogout = () => {
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 (
<nav className="flex justify-between items-center p-4 text-white absolute top-0 left-0 w-screen">
<div>
<h1 className="font-montserrat text-5xl font-black">
<a href="/">FreeTube</a>
</h1>
</div>
<div>
<ul className="flex items-center space-x-[44px] font-montserrat text-2xl font-black">
{isAuthenticated ? (
<>
<li><a href="/">Abonnements</a></li>
<li>
<a href="/profile" className="flex items-center space-x-4">
<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}
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>
<>
<nav className="flex justify-between items-center p-4 text-white top-0 left-0 w-screen z-50 relative">
<div>
<h1 className="font-montserrat text-4xl lg:text-5xl font-black">
<a href="/">FreeTube</a>
</h1>
</div>
<div className="hidden lg:block" >
<ul className="flex items-center space-x-[44px] font-montserrat text-2xl font-black">
<li><a href="/">Accueil</a></li>
{isAuthenticated ? (
<>
<li><a href="/subscriptions">Abonnements</a></li>
<li>
<a href="/profile" className="flex items-center space-x-4">
<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}
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">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><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>
</>
)}
<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"/>
<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"/>
)}
</ul>
</div>
<div className="lg:hidden">
{/* Hamburger menu for mobile */}
<button
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">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</li>
</ul>
</div>
</nav>
</button>
</div>
</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;
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" />
<div className="playlist-info">
<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 />;
}
if (!requireAuth && isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;

18
frontend/src/components/Recommendations.jsx

@ -1,17 +1,17 @@
import VideoCard from "./VideoCard";
export default function Recommendations({videos}) {
console.log(videos);
return (
<div>
<h2 className="text-3xl font-bold mb-4 text-white">Recommendations</h2>
<div className="">
<h2 className="text-3xl font-bold mb-4 text-white">Recommandations</h2>
<div>
<div className="flex flex-wrap">
{videos && videos.map((video, index) => (
<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 recommandation disponible</p>
)}
</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 (
<div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">Top Creators</h2>
<div className="flex flex-wrap">
{creators && creators.map((creator, index) => (
<div key={creator.id || index} className="flex flex-col items-center w-1/4 p-4">
<img src={creator.avatar} alt={creator.name} className="w-full h-auto rounded-lg" />
<h3 className="text-xl font-bold mt-2">{creator.name}</h3>
<span className="text-sm text-gray-500">{creator.subscribers} subscribers</span>
<h2 className="text-3xl font-bold mb-4 text-white">Top Créateurs</h2>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-8">
{creators && creators.length > 0 ? creators.map((creator, index) => (
<div
key={creator.id || index}
className="flex flex-col items-center glassmorphism py-2"
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>
))}
)) : (
<p className="text-gray-500">Aucun créateur disponible</p>
)}
</div>
</div>
);

10
frontend/src/components/TrendingVideos.jsx

@ -6,12 +6,12 @@ export default function TrendingVideos({ videos }) {
return (
<div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">Tendances</h2>
<div className="flex flex-wrap gap-11">
{videos && videos.map((video, index) => (
<div className="w-445/1920" key={index}>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-2">
{videos && videos.length > 0 ? videos.map((video, index) => (
<VideoCard video={video} key={index} />
</div>
))}
)) : (
<p className="text-gray-500">Aucune vidéo tendance disponible</p>
)}
</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';
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 handleClick = () => {
navigation(`/video/${video.id}`, {
navigation(link, {
state: { video }
})
}
return (
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer" onClick={handleClick} >
<div className="aspect-video rounded-sm overflow-hidden">
<img
src={video.thumbnail}
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 className="text-sm text-gray-400 mt-1 flex items-center">
<img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" />
<span className="ml-2">{video.creator.name}</span>
<span className="ml-3.5">{video.views} vues</span>
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer relative">
<div onClick={handleClick} >
<div className="aspect-video rounded-sm overflow-hidden">
<img
src={video.thumbnail}
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 className="text-sm text-gray-400 mt-1 flex items-center">
<img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" />
<span className="ml-2">{video.creator.name}</span>
<span className="ml-3.5">{video.views} vues</span>
</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>
);
}

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');
}
// After successful registration, log the user in
await login(username, password);
// // After successful registration, log the user in
// await login(username, password);
return data;
} 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 = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
@ -97,6 +104,7 @@ export const AuthProvider = ({ children }) => {
const value = {
user,
login,
loginWithOAuth,
register,
logout,
getAuthHeaders,

41
frontend/src/index.css

@ -31,6 +31,47 @@
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 {
/* Fonts */
--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 {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import PlaylistCard from "../components/PlaylistCard.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() {
@ -16,68 +21,22 @@ export default function Account() {
const [isPictureEditActive, setIsPictureEditActive] = useState(false);
const [userHistory, setUserHistory] = useState([]);
const [userPlaylists, setUserPlaylists] = useState([]);
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 () => {
try {
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;
}
setUserChannel(await getChannel(user.id, token, addAlert)); // Reset before fetching
}
const fetchUserHistory = async () => {
if (!user.id || !token) {
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);
}
setUserHistory(await getUserHistory(user.id, token, addAlert));
}
const fetchUserPlaylists = async () => {
if (!user.id || !token) {
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);
}
setUserPlaylists(await getPlaylists(user.id, token, addAlert));
}
useEffect(() => {
@ -89,16 +48,16 @@ export default function Account() {
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 handlePlaylistClick = (playlistId) => {
navigation(`/playlist/${playlistId}`);
}
const handleUpdateUser = async () => {
if (password !== confirmPassword) {
alert("Les mots de passe ne correspondent pas.");
addAlert('error', "Les mots de passe ne correspondent pas.");
return;
}
@ -108,55 +67,63 @@ export default function Account() {
password: password || undefined, // Only send password if it's not empty
};
try {
const response = await fetch(`/api/users/${user.id}`, {
method: "PUT",
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));
const result = await updateUser(user.id, token, updatedUser, addAlert);
if (result) {
localStorage.setItem("user", JSON.stringify(result));
setEditMode(false);
alert("Profil mis à jour avec succès !");
} catch (error) {
console.error("Error updating user:", error);
alert("Erreur lors de la mise à jour du profil.");
addAlert('success', "Profil mis à jour 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 closeModal = () => {
setIsModalOpen(false);
fetchUserChannel();
}
const closePlaylistModal = () => {
setIsCreatePlaylistModalOpen(false);
fetchUserPlaylists();
}
const onDeleteAccount = async () => {
await deleteUser(user.id, token, addAlert);
localStorage.removeItem("user");
navigation("/login");
}
return (
<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 */}
{/* Profile / Edit profile */}
<form className="glassmorphism w-1/3 p-10">
{/* Profile / Edit profile */}
<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)} >
<label htmlFor="image">
<img
src={user.picture}
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">
<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>
</div>
</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>
<label htmlFor="name" className="text-2xl text-white mb-1 block font-montserrat">
Nom d'utilisateur
<label htmlFor="name" className="text-xl lg:text-2xl text-white mb-1 block font-montserrat">
Nom d'utilisateur
</label>
<input
type="text"
@ -167,8 +134,7 @@ export default function Account() {
placeholder="Nom d'utilisateur"
disabled={!editMode}
/>
<label htmlFor="email" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
<label htmlFor="email" className="text-xl lg:text-2xl text-white mb-1 mt-4 block font-montserrat">
Adresse e-mail
</label>
<input
@ -180,10 +146,9 @@ export default function Account() {
placeholder="Adresse mail"
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
</label>
<input
@ -195,8 +160,7 @@ export default function Account() {
placeholder="**************"
disabled={!editMode}
/>
<label htmlFor="confirm-password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
<label htmlFor="confirm-password" className="text-xl lg:text-2xl text-white mb-1 mt-4 block font-montserrat">
Confirmer le mot de passe
</label>
<input
@ -210,23 +174,21 @@ export default function Account() {
/>
</>
)
}
<div className="flex justify-center mt-5">
{
editMode ? (
<div>
<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}
>
Enregistrer
</button>
<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)}
>
Annuler
@ -235,7 +197,7 @@ export default function Account() {
) : (
<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)}
>
Modifier le profil
@ -244,55 +206,73 @@ export default function Account() {
}
</div>
</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 */}
<div className="w-2/3 flex flex-col items-start pl-10">
{/* Channel */}
{userChannel ? (
<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>
<button>
<a href="" className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Gérer la chaîne
</a>
</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 className="lg:w-2/3 flex flex-col items-start lg:pl-10 mt-8 lg:mt-0">
{/* Channel */}
{userChannel ? (
<div className="glassmorphism p-10 w-full flex justify-between">
<p className="text-xl lg:text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p>
<button>
<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
</span>
</button>
</div>
{/* History */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Historique</h2>
<div className="w-full mt-5 flex flex-wrap gap-2" >
{
userHistory.map((video, index) => (
<div className="w-1/3" key={index}>
<VideoCard video={video}/>
</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 onClick={() => setIsModalOpen(true)} 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 */}
<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 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>
<CreateChannelModal isOpen={isModalOpen} onClose={() => closeModal()} addAlert={addAlert} />
<CreatePlaylistModal isOpen={isCreatePlaylistModalOpen} onClose={() => closePlaylistModal()} addAlert={addAlert} />
</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>
)
}

94
frontend/src/pages/Home.jsx

@ -5,6 +5,10 @@ import {useState, useEffect} from "react";
import { useAuth } from '../contexts/AuthContext';
import TopCreators from "../components/TopCreators.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() {
const { isAuthenticated, user } = useAuth();
@ -12,65 +16,98 @@ export default function Home() {
const [loading, setLoading] = useState(true);
const [topCreators, setTopCreators] = useState([]);
const [trendingVideos, setTrendingVideos] = useState([]);
const [seeLaterVideos, setSeeLaterVideos] = useState([]);
const [alerts, setAlerts] = useState([]);
const navigate = useNavigate();
useEffect(() => {
// Fetch recommendations, top creators, and trending videos
const fetchData = async () => {
try {
const response = await fetch('/api/recommendations');
const data = await response.json();
setRecommendations(data.recommendations);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
if (isAuthenticated) {
const token = localStorage.getItem('token');
try {
setRecommendations(await getRecommendations(token, addAlert));
} finally {
setLoading(false);
}
} else {
try {
setRecommendations(await getRecommendations(null, addAlert));
} finally {
setLoading(false);
}
}
try {
const trendingResponse = await fetch('/api/recommendations/trending');
const trendingData = await trendingResponse.json();
setTrendingVideos(trendingData);
} catch (error) {
console.error('Error fetching trending videos:', error);
setTrendingVideos(await getTrendingVideos(addAlert));
} finally {
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();
}, []);
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
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} />
<main className="px-36">
<main className=" px-5 lg:px-36">
{/* Hero section */}
<div className="flex flex-col items-center w-full pt-[304px]">
<img src={HeroImage} alt="" className="w-1046/1920" />
<div className="flex flex-col items-center w-full pt-[128px] lg:pt-[304px]">
<img src={HeroImage} alt="" className=" w-1700/1920 lg:w-1046/1920" />
{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} !
</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
</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">
<p>Se connecter</p>
</a>
</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">
<p>Créer un compte</p>
</a>
</button>
</div>
</>
)}
</div>
@ -79,7 +116,16 @@ export default function Home() {
<Recommendations videos={recommendations} />
{/* Top Creators section */}
<TopCreators/>
{
isAuthenticated ? (
<SeeLater videos={seeLaterVideos} />
) : (
<TopCreators creators={topCreators} navigate={navigate} />
)
}
{/* Trending Videos section */}
<TrendingVideos videos={trendingVideos} />

78
frontend/src/pages/Login.jsx

@ -2,15 +2,17 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import Navbar from '../components/Navbar';
import GitHubLoginButton from '../components/GitHubLoginButton';
export default function Login() {
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [error, setError] = useState('');
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
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) => {
e.preventDefault();
setError('');
setAlerts([]); // Clear existing alerts
setLoading(true);
try {
await login(formData.username, formData.password);
navigate('/');
} catch (err) {
setError(err.message || 'Erreur de connexion');
addAlert('error', err.message || 'Erreur de connexion');
} finally {
setLoading(false);
}
@ -38,21 +49,15 @@ export default function Login() {
return (
<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="bg-white 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>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<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 text-white">Connexion</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<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
</label>
<input
@ -62,27 +67,45 @@ export default function Login() {
value={formData.username}
onChange={handleChange}
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"
/>
</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
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Entrez votre mot de passe"
/>
<div className='w-full glassmorphism flex items-center'>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
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>
<button
type="submit"
disabled={loading}
@ -90,6 +113,7 @@ export default function Login() {
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
<GitHubLoginButton className="mt-1 cursor-pointer" />
</form>
<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