Compare commits

...

3 Commits

Author SHA1 Message Date
astria ac0a1d3439 staging 3 months ago
astria 27f7e42ba2 FINISHED 3 months ago
astria ed53928c26 STAGING 3 months ago
  1. 16
      .env
  2. 1
      .gitignore
  3. 23
      Makefile
  4. 4
      backend/app/controllers/channel.controller.js
  5. 2
      backend/app/controllers/comment.controller.js
  6. 14
      backend/app/controllers/media.controller.js
  7. 14
      backend/app/controllers/oauth.controller.js
  8. 3
      backend/app/controllers/recommendation.controller.js
  9. 6
      backend/app/controllers/search.controller.js
  10. 7
      backend/app/controllers/user.controller.js
  11. 29
      backend/app/controllers/video.controller.js
  12. 0
      backend/app/middlewares/recommendation.middleware.js
  13. 2
      backend/app/middlewares/video.middleware.js
  14. BIN
      backend/app/uploads/thumbnails/946FFC1D2D8C189D.jpg
  15. 13
      backend/app/utils/database.js
  16. 8
      backend/server.js
  17. 146
      checklist.md
  18. 4
      create_db.sql
  19. 4
      db.sql
  20. 68
      default.conf
  21. 49
      documentation.md
  22. 2
      freetube.sh
  23. 4
      frontend/index.html
  24. BIN
      frontend/public/favicon.png
  25. 2
      frontend/src/components/Alert.jsx
  26. 1
      frontend/src/components/Comment.jsx
  27. 2
      frontend/src/components/Navbar.jsx
  28. 1
      frontend/src/components/TabLayout.jsx
  29. 25
      frontend/src/components/VideoCard.jsx
  30. 6
      frontend/src/contexts/AuthContext.jsx
  31. 1
      frontend/src/modals/LoadingVideoModal.jsx
  32. 8
      frontend/src/pages/Account.jsx
  33. 10
      frontend/src/pages/AddVideo.jsx
  34. 13
      frontend/src/pages/Channel.jsx
  35. 1
      frontend/src/pages/Home.jsx
  36. 4
      frontend/src/pages/Login.jsx
  37. 5
      frontend/src/pages/LoginSuccess.jsx
  38. 2
      frontend/src/pages/ManageChannel.jsx
  39. 7
      frontend/src/pages/ManageVideo.jsx
  40. 3
      frontend/src/pages/Playlist.jsx
  41. 5
      frontend/src/pages/Register.jsx
  42. 2
      frontend/src/pages/Search.jsx
  43. 6
      frontend/src/pages/Subscription.jsx
  44. 21
      frontend/src/pages/Video.jsx
  45. 2
      frontend/src/services/channel.service.js
  46. 3
      frontend/src/services/oauthService.js
  47. 1
      frontend/src/services/user.service.js
  48. 7
      frontend/src/services/video.service.js
  49. 8
      nginx/Dockerfile
  50. 68
      nginx/default.conf
  51. 23
      nginx/nginx-selfsigned.crt
  52. 28
      nginx/nginx-selfsigned.key
  53. 227
      sujet.txt

16
.env

@ -0,0 +1,16 @@
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=freetube
POSTGRES_HOST=db
BACKEND_PORT=8000
JWT_SECRET=jhkdfgjhkerzuhsdfvjhklfvjkhsdfgq
LOG_FILE=/var/log/freetube/access.log
GMAIL_USER=sachaguerin.sg@gmail.com
GMAIL_PASSWORD=yuuu kvoi ytrf blla
GITHUB_ID=Ov23lihaZwgdcmuE86Y6
GITHUB_SECRET=61b95c5267c3ffe01f783387951d84fd307dff3b

1
.gitignore

@ -1,4 +1,5 @@
/backend/app/uploads/
/uploads/
# Ignore all files in the uploads directory
/frontend/node_modules

23
Makefile

@ -1,23 +0,0 @@
up:
docker compose up --build -d
down:
docker compose down
logs:
docker compose logs -f
dev:
docker compose -f developpement.yaml up --build -d
dev-down:
docker compose -f developpement.yaml down
dev-logs:
docker compose -f developpement.yaml logs -f
dev-volumes:
docker compose -f developpement.yaml down -v
dev-build:
docker compose -f developpement.yaml build

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

@ -184,11 +184,9 @@ export async function toggleSubscription(req, res) {
const result = await client.query(query, [id, userId]);
if (result.rows.length > 0) {
// Unsubscribe
const deleteQuery = `DELETE FROM subscriptions WHERE channel = $1 AND owner = $2`;
await client.query(deleteQuery, [id, userId]);
// Send back the number of remaining subscriptions
const countQuery = `SELECT COUNT(*) FROM subscriptions WHERE channel = $1`;
const countResult = await client.query(countQuery, [id]);
const remainingSubscriptions = countResult.rows[0].count;
@ -197,11 +195,9 @@ export async function toggleSubscription(req, res) {
client.release();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else {
// Subscribe
const insertQuery = `INSERT INTO subscriptions (channel, owner) VALUES ($1, $2)`;
await client.query(insertQuery, [id, userId]);
// Send back the number of subscriptions after subscribing
const countQuery = `SELECT COUNT(*) FROM subscriptions WHERE channel = $1`;
const countResult = await client.query(countQuery, [id]);
const totalSubscriptions = countResult.rows[0].count;

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

@ -24,8 +24,6 @@ export async function upload(req, res) {
const createdAt = result.rows[0].created_at;
comment.id = result.rows[0].id;
// Send back the comment
// Get the author's name and profile picture
const authorQuery = `SELECT username, picture FROM users WHERE id = $1`;
const authorResult = await client.query(authorQuery, [comment.author]);
const author = authorResult.rows[0];

14
backend/app/controllers/media.controller.js

@ -7,20 +7,6 @@ const __dirname = path.dirname(__filename);
export async function getProfilePicture(req, res) {
const file = req.params.file;
// Try different path approaches
const possiblePaths = [
path.join(__dirname, '../../uploads/profiles', file),
path.join(process.cwd(), 'app/uploads/profiles', file),
path.join('/app/app/uploads/profiles', file),
path.join('/app/uploads/profiles', file)
];
// Try the most likely path first (based on your volume mapping)
console.log(path.join(__dirname, '../uploads/profiles', file))
const filePath = path.join(__dirname, '../uploads/profiles', file);
try {

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

@ -19,7 +19,6 @@ export async function callback(req, res, next) {
}
try {
// Extract user information from GitHub profile
const githubId = user.id;
const username = user.username || user.login;
const email = user.emails[0].value || null;
@ -38,14 +37,12 @@ export async function callback(req, res, next) {
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
@ -66,16 +63,13 @@ export async function callback(req, res, next) {
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,
@ -91,20 +85,18 @@ export async function callback(req, res, next) {
email,
avatarUrl || "/api/media/profile/default.png",
githubId,
null, // No password for OAuth users
true // OAuth users are automatically verified
null,
true
]);
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,
@ -112,7 +104,6 @@ export async function callback(req, res, next) {
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,
@ -131,7 +122,6 @@ export async function callback(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) {

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

@ -13,7 +13,6 @@ export async function getRecommendations(req, res) {
let result = await client.query(queryMostUsedTags);
// GET 10 VIDEOS WITH THE TAGS
let tagIds = result.rows.map(tag => tag.id);
let queryVideosWithTags = `
@ -197,7 +196,6 @@ export async function getRecommendations(req, res) {
export async function getTrendingVideos(req, res) {
const client = await getClient();
try {
// Optimized single query to get all trending video data
let queryTrendingVideos = `
SELECT
v.id,
@ -254,7 +252,6 @@ export async function getTrendingVideos(req, res) {
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

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

@ -17,7 +17,6 @@ export async function search(req, res) {
if (type === 'videos') {
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,
@ -39,13 +38,11 @@ export async function search(req, res) {
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;
@ -61,7 +58,6 @@ export async function search(req, res) {
} 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
@ -78,7 +74,7 @@ export async function search(req, res) {
for (const channel of channelNames) {
channel.type = "channel";
channel.profilePicture = channel.profilepicture; // Rename for consistency
channel.profilePicture = channel.profilepicture;
delete channel.profilepicture;
channelResults.push(channel);
}

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

@ -45,7 +45,6 @@ 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;
@ -115,9 +114,8 @@ export async function register(req, res) {
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
expirationDate.setHours(expirationDate.getHours() + 1);
const insertQuery = `INSERT INTO email_verification (email, token, expires_at) VALUES ($1, $2, $3)`;
await client.query(insertQuery, [user.email, token, expirationDate]);
@ -150,8 +148,6 @@ export async function verifyEmail(req, res) {
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]);
@ -399,7 +395,6 @@ export async function getHistory(req, res) {
const videos = [];
for (const video of result.rows) {
// GET VIDEO VIEW COUNT
const videoQuery = `SELECT COUNT(*) as view_count FROM history WHERE video = $1`;
const videoResult = await client.query(videoQuery, [video.id]);
const videoToAdd = {

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

@ -10,7 +10,6 @@ const __dirname = dirname(__filename);
export async function upload(req, res) {
// HANDLE VIDEO FILE
const fileBuffer = req.file.buffer;
let isGenerate = false;
while (isGenerate === false) {
@ -38,7 +37,6 @@ export async function upload(req, res) {
const video = {
title: req.body.title,
description: req.body.description,
//TODO thumbnail
file: "/api/media/video/" + finalName,
channel: req.body.channel,
slug: req.body.slug,
@ -46,7 +44,6 @@ 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();
@ -57,11 +54,9 @@ export async function upload(req, res) {
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);
@ -77,7 +72,6 @@ export async function upload(req, res) {
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;
@ -195,7 +189,6 @@ export async function getById(req, res) {
const result = await client.query(query, [id]);
const video = result.rows[0];
// GET VIEWS AND LIKES COUNT
const viewsQuery = `SELECT COUNT(*) AS view_count FROM history WHERE video = $1`;
const viewsResult = await client.query(viewsQuery, [id]);
const likesQuery = `SELECT COUNT(*) AS like_count FROM likes WHERE video = $1`;
@ -203,32 +196,26 @@ export async function getById(req, res) {
video.views = viewsResult.rows[0].view_count;
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 ORDER BY c.created_at DESC`;
const commentsResult = await client.query(commentsQuery, [id]);
video.comments = commentsResult.rows;
// 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, [id]);
video.creator = creatorResult.rows[0];
// GET CREATOR PROFILE PICTURE
const profilePictureQuery = `SELECT picture FROM users WHERE id = $1`;
const profilePictureResult = await client.query(profilePictureQuery, [video.creator.owner]);
video.creator.profile_picture = profilePictureResult.rows[0].picture;
// GET CREATOR SUBSCRIBERS COUNT
const subscribersQuery = `SELECT COUNT(*) AS subscriber_count FROM subscriptions WHERE channel = $1`;
const subscribersResult = await client.query(subscribersQuery, [video.creator.id]);
video.creator.subscribers = subscribersResult.rows[0].subscriber_count;
// GET TAGS
const tagsQuery = `SELECT t.name FROM tags t JOIN video_tags vt ON t.id = vt.tag WHERE vt.video = $1`;
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;
@ -389,7 +376,6 @@ export async function toggleLike(req, res) {
const query = `INSERT INTO likes (video, owner) VALUES ($1, $2)`;
await client.query(query, [id, userId]);
// GET LIKES COUNT
const likesCountQuery = `SELECT COUNT(*) AS like_count FROM likes WHERE video = $1`;
const likesCountResult = await client.query(likesCountQuery, [id]);
const likesCount = likesCountResult.rows[0].like_count;
@ -401,7 +387,6 @@ export async function toggleLike(req, res) {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
await client.query(query, [userId, id]);
// GET LIKES COUNT
const likesCountQuery = `SELECT COUNT(*) AS like_count FROM likes WHERE video = $1`;
const likesCountResult = await client.query(likesCountQuery, [id]);
const likesCount = likesCountResult.rows[0].like_count;
@ -423,15 +408,12 @@ export async function addTags(req, res) {
const client = await getClient();
// DECREASE USAGE COUNT FOR ALL TAGS
const decreaseUsageCountQuery = `UPDATE tags SET usage_count = usage_count - 1 WHERE id IN (SELECT tag FROM video_tags WHERE video = $1)`;
await client.query(decreaseUsageCountQuery, [videoId]);
// DELETE ALL TAGS FOR VIDEO
let deleteQuery = `DELETE FROM video_tags WHERE video = $1`;
await client.query(deleteQuery, [videoId]);
// INSERT NEW TAGS
for (const tag of tags) {
const tagQuery = `SELECT * FROM tags WHERE name = $1`;
const tagResult = await client.query(tagQuery, [tag]);
@ -451,12 +433,10 @@ export async function addTags(req, res) {
id = tagResult.rows[0].id;
}
// INSERT INTO video_tags
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;
@ -474,7 +454,6 @@ export async function getSimilarVideos(req, res) {
const client = await getClient();
// Get tags for the video
const tagsQuery = `SELECT t.name FROM tags t JOIN video_tags vt ON t.id = vt.tag WHERE vt.video = $1`;
const tagsResult = await client.query(tagsQuery, [id]);
const tags = tagsResult.rows.map(row => row.name);
@ -485,7 +464,6 @@ export async function getSimilarVideos(req, res) {
return;
}
// Find similar videos based on tags which are not the same as the current video and limit to 10 results
const similarVideosQuery = `
SELECT v.id, v.title, v.thumbnail, v.file, v.slug, v.format, v.release_date, c.name AS creator_name, c.id AS creator_id, c.owner AS creator_owner
FROM videos v
@ -500,22 +478,18 @@ export async function getSimilarVideos(req, res) {
const result = await client.query(similarVideosQuery, [tags, id]);
for (let video of result.rows) {
// Get views count for each video
const viewsQuery = `SELECT COUNT(*) AS view_count FROM history WHERE video = $1`;
const viewsResult = await client.query(viewsQuery, [video.id]);
video.views = viewsResult.rows[0].view_count;
// Get creator profile picture
const profilePictureQuery = `SELECT picture FROM users WHERE id = $1`;
const profilePictureResult = await client.query(profilePictureQuery, [video.creator_owner]);
// Put creator info in video.creator
video.creator = {
id: video.creator_id,
name: video.creator_name,
profilePicture: profilePictureResult.rows[0].picture
};
// Remove creator_id and creator_name from the video object
delete video.creator_id;
delete video.creator_name;
@ -585,7 +559,6 @@ export async function addViews(req, res) {
const claims = jwt.decode(token);
const userId = claims.id;
// ADD VIEW TO HISTORY IF NOT EXISTS
const client = await getClient();
const query = `SELECT * FROM history WHERE video = $1 AND user_id = $2`;
const result = await client.query(query, [id, userId]);
@ -612,12 +585,10 @@ export async function updateAuthorizedUsers(req, res) {
const client = await getClient();
try {
// Remove all existing authorized users
const deleteQuery = `DELETE FROM video_authorized_users WHERE video_id = $1`;
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];

0
backend/app/middlewares/recommendation.middleware.js

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

@ -26,7 +26,6 @@ export const VideoCreate = {
authorizedUsers: body("authorizedUsers")
.optional({values: "falsy"})
.customSanitizer((value) => {
// Parse JSON string back to array
if (typeof value === 'string') {
try {
return JSON.parse(value);
@ -109,7 +108,6 @@ 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);

BIN
backend/app/uploads/thumbnails/946FFC1D2D8C189D.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

13
backend/app/utils/database.js

@ -1,25 +1,21 @@
import pg from "pg";
export async function getClient() {
// 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
max: 30,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
acquireTimeoutMillis: 10000,
});
// Use pool.connect() instead of creating new clients
return await pool.connect();
}
// Graceful shutdown
process.on('SIGINT', () => {
pool.end(() => {
@ -143,7 +139,6 @@ export async function initDb() {
)`;
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()`);

8
backend/server.js

@ -23,10 +23,9 @@ console.clear();
dotenv.config();
// --- Express setup ---
const app = express();
// Increase body size limits for file uploads
app.use(express.urlencoded({extended: true, limit: '500mb'}));
app.use(express.json({limit: '500mb'}));
@ -36,11 +35,10 @@ app.use(session({
saveUninitialized: false,
}));
// Swagger setup
// --- Swagger setup ---
const file = fs.readFileSync('./swagger.yaml', 'utf8');
const swaggerDocument = YAML.parse(file);
// Swagger UI options
const swaggerOptions = {
explorer: true,
swaggerOptions: {
@ -57,12 +55,10 @@ app.use('/api/api-docs', swaggerui.serve, swaggerui.setup(swaggerDocument, swagg
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);
});

146
checklist.md

@ -1,146 +0,0 @@
# FreeTube - Checklist de développement Backend
## **AUTHENTIFICATION** (15 points)
### Connexion utilisateur standard (5 points) ✅
- [x] Route de connexion avec nom d'utilisateur/mot de passe
- [x] Validation des credentials
- [x] Génération de token JWT
### Connexion OAuth2 (10 points)
- [ ] Intégration avec au moins un service OAuth2 (Google, Microsoft, GitHub)
- [ ] Routes pour gérer les callbacks OAuth2
- [ ] Middleware de gestion des tokens OAuth2
## **GESTION UTILISATEUR** (10 points) ✅
- [x] Modification adresse email (unique)
- [x] Modification nom d'utilisateur (unique)
- [x] Modification mot de passe sécurisé
- [x] Modification photo de profil
- [x] Création/modification nom d'affichage chaîne
- [x] Création/modification description chaîne
## **ADMINISTRATION CHAÎNE FREETUBE** (55 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)
- [x] Visibilité publique/privée (5 points)
- [x] Génération lien partageable (5 points) - via slug système
### 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 (10 points) - 5/10 points
- [x] Statistiques par vidéo (vues, likes, commentaires) (5 points)
- [ ] Statistiques globales de la chaîne (5 points)
## **RECHERCHE ET NAVIGATION** (20 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)
### 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 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) - 5/15 points
- [ ] Historique des vidéos regardées (10 points)
- [x] Gestion et liste des playlists (5 points)
## **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) - 27/50 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) - 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)
- [ ] 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 (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 (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
---
## **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

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

4
db.sql

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

68
default.conf

@ -1,68 +0,0 @@
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;
}
}

49
documentation.md

@ -1,49 +0,0 @@
# 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

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

4
frontend/index.html

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<title>FreeTube</title>
</head>
<body>
<div id="root"></div>

BIN
frontend/public/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

2
frontend/src/components/Alert.jsx

@ -6,7 +6,7 @@ export default function Alert({ type, message, onClose }) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 5000); // 5 seconds
}, 5000);
return () => clearTimeout(timer);
}, [onClose]);

1
frontend/src/components/Comment.jsx

@ -30,7 +30,6 @@ export default function Comment({ comment, index, videoId, refetchVideo, doShowC
alert("Comment cannot be empty");
return;
}
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
if (!token) {

2
frontend/src/components/Navbar.jsx

@ -27,11 +27,9 @@ export default function Navbar({ isSearchPage = false, alerts = [], onCloseAlert
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 {

1
frontend/src/components/TabLayout.jsx

@ -27,7 +27,6 @@ export default function TabLayout({ tabs }) {
</div>
{/* ELEMENT */}
<div className="glassmorphism w-full p-4">
{tabs.map((tab) => (
<div key={tab.id} className={`tab-content ${tab.id === activeTab ? 'block' : 'hidden'}`}>

25
frontend/src/components/VideoCard.jsx

@ -1,30 +1,5 @@
import { useNavigate } from 'react-router-dom';
// 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 = () => {

6
frontend/src/contexts/AuthContext.jsx

@ -14,7 +14,6 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Check if user is logged in on app start
useEffect(() => {
const token = localStorage.getItem('token');
const userData = localStorage.getItem('user');
@ -41,7 +40,6 @@ export const AuthProvider = ({ children }) => {
throw new Error(data.message || 'Erreur de connexion');
}
// Store token and user data
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
setUser(data.user);
@ -74,9 +72,6 @@ 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);
return data;
} catch (error) {
throw error;
@ -84,7 +79,6 @@ 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);

1
frontend/src/modals/LoadingVideoModal.jsx

@ -4,7 +4,6 @@ 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>

8
frontend/src/pages/Account.jsx

@ -30,7 +30,7 @@ export default function Account() {
const navigation = useNavigate();
const fetchUserChannel = async () => {
setUserChannel(await getChannel(user.id, token, addAlert)); // Reset before fetching
setUserChannel(await getChannel(user.id, token, addAlert));
}
const fetchUserHistory = async () => {
setUserHistory(await getUserHistory(user.id, token, addAlert));
@ -64,7 +64,7 @@ export default function Account() {
const updatedUser = {
username,
email,
password: password || undefined, // Only send password if it's not empty
password: password || undefined,
};
const result = await updateUser(user.id, token, updatedUser, addAlert);
@ -76,7 +76,7 @@ export default function Account() {
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -221,7 +221,7 @@ export default function Account() {
</div>
{ /* Right side */}
// { /* Right side */}
<div className="lg:w-2/3 flex flex-col items-start lg:pl-10 mt-8 lg:mt-0">
{/* Channel */}

10
frontend/src/pages/AddVideo.jsx

@ -52,7 +52,6 @@ export default function AddVideo() {
setVideoTags(videoTags.filter(tag => tag !== tagToRemove));
};
// This function handles the submission of the video form
const handleSubmit = async (e) => {
e.preventDefault();
@ -85,7 +84,6 @@ export default function AddVideo() {
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();
@ -95,13 +93,11 @@ export default function AddVideo() {
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 !');
@ -109,7 +105,7 @@ export default function AddVideo() {
};
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -120,7 +116,6 @@ export default function AddVideo() {
const onUserSearch = (e) => {
const searchUser = e.target.value;
if (searchUser.trim() !== "") {
// Call the API to search for users
searchByUsername(searchUser, token, addAlert)
.then((results) => {
@ -136,7 +131,6 @@ export default function AddVideo() {
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("")
@ -163,7 +157,6 @@ export default function AddVideo() {
</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
@ -325,7 +318,6 @@ export default function AddVideo() {
</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

13
frontend/src/pages/Channel.jsx

@ -23,7 +23,6 @@ export default function Channel() {
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));
}
@ -31,7 +30,7 @@ export default function Channel() {
}, [id])
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts(prev => [...prev, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
@ -41,15 +40,12 @@ export default function Channel() {
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
}
};
@ -59,7 +55,6 @@ export default function Channel() {
<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
@ -98,14 +93,8 @@ export default function Channel() {
</p>
</div>
{/* Tab selector */}
<TabLayout tabs={tabs}/>
{/* 10 Last videos */}
</main>

1
frontend/src/pages/Home.jsx

@ -22,7 +22,6 @@ export default function Home() {
const navigate = useNavigate();
useEffect(() => {
// Fetch recommendations, top creators, and trending videos
const fetchData = async () => {
if (isAuthenticated) {
const token = localStorage.getItem('token');

4
frontend/src/pages/Login.jsx

@ -24,7 +24,7 @@ export default function Login() {
};
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -34,7 +34,7 @@ export default function Login() {
const handleSubmit = async (e) => {
e.preventDefault();
setAlerts([]); // Clear existing alerts
setAlerts([]);
setLoading(true);
try {

5
frontend/src/pages/LoginSuccess.jsx

@ -9,7 +9,6 @@ export default function LoginSuccess() {
const hasProcessed = useRef(false);
useEffect(() => {
// Prevent multiple executions
if (hasProcessed.current) return;
const token = searchParams.get('token');
@ -24,10 +23,8 @@ export default function LoginSuccess() {
const userData = JSON.parse(decodeURIComponent(userParam));
// 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);
@ -46,7 +43,7 @@ export default function LoginSuccess() {
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">

2
frontend/src/pages/ManageChannel.jsx

@ -62,7 +62,7 @@ export default function ManageChannel() {
};
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};

7
frontend/src/pages/ManageVideo.jsx

@ -140,7 +140,7 @@ export default function ManageVideo() {
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -159,7 +159,6 @@ export default function ManageVideo() {
};
const onAuthorizedUserAdd = async (user) => {
// Don't modify video directly - use current state
const currentAuthorizedUsers = video?.authorizedUsers || [];
if (currentAuthorizedUsers.some(u => u.id === user.id)) {
@ -167,17 +166,13 @@ export default function ManageVideo() {
return;
}
// authorizedUsers = only ids
const body = {
authorizedUsers: [...currentAuthorizedUsers.map(u => u.id), user.id],
channel: video.channel
};
// Debug log
const request = await updateAuthorizedUsers(id, body, token, addAlert);
// Only update state after successful API call
setVideo({
...video,
authorizedUsers: [...currentAuthorizedUsers, user]

3
frontend/src/pages/Playlist.jsx

@ -26,7 +26,7 @@ export default function Playlist() {
}, [id]);
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -54,7 +54,6 @@ export default function Playlist() {
<main className="px-5 lg:px-36 w-full pt-[48px] lg:pt-[118px]">
<h1 className="font-bold font-montserrat text-3xl text-white" >{playlist && playlist.name}</h1>
{/* CONTROLS */}
<div className="mt-4">
<button
className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"

5
frontend/src/pages/Register.jsx

@ -33,7 +33,6 @@ export default function Register() {
profile: file
});
// Create preview
if (file) {
const reader = new FileReader();
reader.onload = (e) => setPreviewImage(e.target.result);
@ -52,7 +51,6 @@ export default function Register() {
const handleSubmit = async (e) => {
e.preventDefault();
// Validation
if (formData.password !== formData.confirmPassword) {
addAlert("error", "Les mots de passe ne correspondent pas");
return;
@ -82,7 +80,6 @@ export default function Register() {
const response = await verifyEmail(formData.email, token, addAlert);
if (response) {
// Email verified successfully
setEmailVerificationModalOpen(false);
navigate('/login');
}
@ -90,7 +87,7 @@ export default function Register() {
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};

2
frontend/src/pages/Search.jsx

@ -41,7 +41,7 @@ export default function Search() {
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};

6
frontend/src/pages/Subscription.jsx

@ -46,7 +46,7 @@ export default function Subscription() {
}
};
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -60,7 +60,7 @@ export default function Subscription() {
<main className="px-5 lg:px-36 w-full pt-[48px] lg:pt-[118px] lg:flex gap-8">
{/* LEFT SIDE (subscription list) */}
{/* LEFT SIDE */}
<div className="w-full lg:w-1/4 border-b border-gray-200 lg:border-b-0 mb-8 lg:mb-0 pb-2 lg:pb-0">
<h2 className="text-2xl text-white font-montserrat font-semibold mb-4">Mes Abonnements</h2>
<ul className="space-y-2">
@ -81,7 +81,7 @@ export default function Subscription() {
</ul>
</div>
{/* RIGHT SIDE (videos from subscriptions) */}
{/* RIGHT SIDE */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{videos.map(video => (
<VideoCard key={video.id} video={video} />

21
frontend/src/pages/Video.jsx

@ -37,10 +37,9 @@ export default function Video() {
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [currentPlaylist, setCurrentPlaylist] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isCommentVisible, setIsCommentVisible] = useState(window.innerWidth >= 1024); // Show comments by default on large screens
const [isCommentVisible, setIsCommentVisible] = useState(window.innerWidth >= 1024);
const fetchVideo = useCallback(async () => {
// Fetch video data and similar videos based on the video ID from the URL
if (!id) {
addAlert('error', 'Vidéo introuvable');
navigation('/');
@ -56,7 +55,6 @@ export default function Video() {
setSimilarVideos(similarVideosResponse);
}
// Add views to the video
await addView(id, addAlert);
}, [id, navigation]);
@ -89,7 +87,6 @@ export default function Video() {
//Find position of current video id in currentPlaylist.videos
const currentIndex = currentPlaylist?.videos.findIndex(video => {
return video.id.toString() === id.toString();
@ -111,7 +108,6 @@ export default function Video() {
}
// Navigate to the next video with playlist context
if (playlistId) {
navigation(`/video/${nextVideo.id}?playlistId=${playlistId}`);
} else {
@ -129,15 +125,12 @@ export default function Video() {
fetchNextVideo();
}, [currentPlaylist]);
// Handle fullscreen state changes
useEffect(() => {
if (!isFullscreen) {
// Clear timeout when exiting fullscreen
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
hideControlsTimeoutRef.current = null;
}
// Reset controls visibility for normal mode
setShowControls(false);
}
}, [isFullscreen]);
@ -229,12 +222,10 @@ export default function Video() {
if (isFullscreen) {
setShowControls(true);
// Clear existing timeout
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
// Hide controls after 3 seconds of no mouse movement
hideControlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 3000);
@ -269,7 +260,6 @@ export default function Video() {
return;
}
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
if (!token) {
@ -282,7 +272,7 @@ export default function Video() {
setVideo((prevVideo) => {
return {
...prevVideo,
likes: data.likes || prevVideo.likes + 1 // Update likes count
likes: data.likes || prevVideo.likes + 1
};
})
};
@ -297,7 +287,6 @@ export default function Video() {
alert("Comment cannot be empty");
return;
}
// Retrieve the token from localStorage
const token = localStorage.getItem('token');
if (!token) {
@ -306,7 +295,7 @@ export default function Video() {
}
const data = await addComment(video.id, comment, token, addAlert);
setComment(""); // Clear the comment input
setComment("");
setVideo((prevVideo) => ({
...prevVideo,
@ -315,7 +304,7 @@ export default function Video() {
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
const newAlert = { type, message, id: Date.now() };
setAlerts([...alerts, newAlert]);
};
@ -364,7 +353,7 @@ export default function Video() {
onLoadedMetadata={handleLoadedMetadata}
onEnded={passToNextVideo}
className={`w-full h-full object-cover cursor-pointer ${isFullscreen ? 'fixed top-0 left-0 w-full h-full z-50 bg-black' : ''}`}
controls={window.innerWidth < 1024} // Show native controls on small screens
controls={window.innerWidth < 1024}
>
<source src={`${video.file}`} type="video/mp4" />
Your browser does not support the video tag.

2
frontend/src/services/channel.service.js

@ -91,7 +91,7 @@ export async function createChannel(body, token, addAlert) {
if (!request.ok) {
console.error("Not able to create channel");
return; // Prevent further execution if the request failed
return;
}
addAlert('success', 'Chaîne créée avec succès');

3
frontend/src/services/oauthService.js

@ -1,12 +1,10 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://localhost/api';
export const oauthService = {
// Start GitHub OAuth flow
loginWithGitHub() {
window.location.href = `${API_BASE_URL}/oauth/github`;
},
// Get current user info using token
async getCurrentUser(token) {
const response = await fetch(`${API_BASE_URL}/oauth/me`, {
headers: {
@ -22,7 +20,6 @@ export const oauthService = {
return response.json();
},
// Verify if token is still valid
async verifyToken(token) {
try {
const userInfo = await this.getCurrentUser(token);

1
frontend/src/services/user.service.js

@ -126,7 +126,6 @@ export async function verifyEmail(email, token, addAlert) {
export async function searchByUsername(username, token, addAlert) {
try {
// Validate input before sending request
if (!username || username.trim().length === 0) {
addAlert('error', "Le nom d'utilisateur ne peut pas être vide.");
return;

7
frontend/src/services/video.service.js

@ -4,7 +4,7 @@ export async function uploadVideo(formData, token, addAlert) {
const request = await fetch("/api/videos", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
"Authorization": `Bearer ${token}`
},
body: formData
});
@ -12,7 +12,6 @@ export async function uploadVideo(formData, token, addAlert) {
const errorData = await request.json();
console.error("Backend validation errors:", errorData);
// Display specific validation errors if available
if (errorData.errors && errorData.errors.length > 0) {
const errorMessages = errorData.errors.map(error =>
`${error.path}: ${error.msg}`
@ -55,9 +54,9 @@ export async function uploadTags(body, videoId, token, addAlert) {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(body) // Ensure channel ID is a string
body: JSON.stringify(body)
});
addAlert('success', 'Tags mis à jour avec succès');
if (!tagsRequest.ok) {

8
nginx/Dockerfile

@ -1,8 +0,0 @@
FROM nginx:latest
COPY ../frontend/dist/ /usr/share/nginx/html
COPY ./default.conf /etc/nginx/conf.d
EXPOSE 80:80

68
nginx/default.conf

@ -1,68 +0,0 @@
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
nginx/nginx-selfsigned.crt

@ -1,23 +0,0 @@
-----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
nginx/nginx-selfsigned.key

@ -1,28 +0,0 @@
-----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-----

227
sujet.txt

@ -1,227 +0,0 @@
1 - Contexte du projet
L’entreprise “Framinfo” souhaite construire FreeTube, une alternative française à Youtube, leader mondial de l’hébergement de vidéos.
En anticipation d’une levée de fond, “Framinfo” vous a choisi en tant que prestataire pour la réaliser un MVP (Minimum Viable Product).
Le projet est individuel. Tout plagiat ou utilisation d’intelligence artificielle est strictement interdit. Le code que vous allez rendre doit être votre propre création et non des éléments copiés/collés. Si du plagiat est constaté, votre note sera d’office de 0/20.
2 - Description du projet
2.1 - Généralités
Freetube est une application d’hébergement et de partage de vidéos destinée à deux profils d’utilisateurs :
Les créateurs de contenus, qui mettent en ligne leurs productions afin de s’adresser à leur audience (Divertissement, professionnels, publicités, etc.),
Les “consommateurs”, qui regardent le contenu postés par les créateurs de contenus.
Afin de concurrencer les plateformes existantes, il est important que l’expérience utilisateur soit parfaitement adaptée à chaque utilisateur en fonction de son profil : Un soin tout particulier doit donc être accordé à l’interface et l’expérience utilisateur.
2.2 - Fonctionnalités de l’application à implémenter
2.2.1 - Connexion
Un utilisateur pourra se connecter à l'application via un compte créé spécifiquement ou en utilisant au moins un service d’Oauth2 (Google, Microsoft, Github, etc.).
2.2.2 - Gestion utilisateur
Chaque utilisateur doit pouvoir administrer son compte, à savoir :
Modifier son adresse email et nom d’utilisateur (qui doivent être uniques),
Modifier son mot de passe (doit être suffisamment sécurisé),
Modifier sa photo de profil,
Créer/Modifier un nom d’affichage pour sa chaîne Freetube,
Créer/Modifier une description pour sa chaîne Freetube,
2.2.3 - Administration d’une chaine Freetube
Les créateurs de contenus doivent évidemment pouvoir administrer leur chaine Freetube, publier des vidéos mais aussi suivre toutes leurs statistiques sur un panneau d’administration leur étant dédié.
Un utilisateur peut :
Mettre en ligne une vidéo qui générera un lien partageable et qui comprend :
Un média vidéo, tout type de fichier vidéo est accepté (MP4, AVI, WEBM, etc.)
Une miniature de vidéo, tout type de format image est accepté (JPG, PNG, WEBP, etc.)
Un titre,
Une description,
La date de mise en ligne, définie automatiquement lorsque la vidéo est rendue disponible,
La possibilité de mettre la vidéo en publique (accessible à tous les utilisateurs de Freetube via les fonctionnalités de recherche et de mise en avant) ou de la mettre en privée (accessible uniquement par les utilisateur possédant le lien pour accéder à cette dernière).
Jusqu’à 10 mots-clefs prenant la forme de hashtags afin de permettre aux créateurs de contenus de mieux se référencer sur les fonctionnalités de recherches.
Éditer n’importe quel élément une vidéo précédemment créée (tous les points précédemment décrits)
Changer la visibilité d’une vidéo (privée/publique)
Supprimer une vidéo
Consulter les statistiques de chaque vidéo individuellement (Nombre de “J’aime” et commentaire)
Consulter les statistiques globales de sa chaine Freetube dans une page dédiée (Cumul des statistiques de l’ensemble des vidéos)
2.2.4 - Utilisation de Freetube
N’importe quel utilisateur de Freetube, authentifié ou non, peut accéder à la plateforme gratuitement et librement.
Page d’accueil :
Bien que la disposition de la page d’accueil de l’application soit similaire pour tous les utilisateurs, qu’ils soient authentifiés ou non, les sections et recommendations diffèrent :
Pour un utilisateur authentifié :
Une section “Recommendations”, mettant en avant du contenu que l’utilisateur n’a pas encore jamais vu et similaire au contenu qu’il regardé et avec lequel il a pu interagir (“J’aime”, commentaire, ajout à une playlist),
Une section “À consulter plus tard” comprenant les vidéos qu’un utilisateur a ajouté à la playlist du même nom,
Une section “Tendances” avec le contenu de la plateforme ayant généré le plus d’interaction récemment.
Pour une utilisateur non-authentifié :
Une section “Recommendations”, mettant en avant les 3 mots-clefs les plus utilisés de la plateforme (par exemple “Informations”, “Jeu vidéo”, etc.)
Une section “Tendances” avec le contenu de la plateforme ayant généré le plus d’interaction récemment,
Une section “Top Créateurs” affichant sous forme de cartes, la photo de profil, le nom et la description de la chaine, des créateurs ayant le plus d’abonnés de la plateforme.
Page “Abonnements” :
Pour les utilisateurs non-authentifiés, la page redirige vers la page de connexion/création de compte.
Affiche le fil d’actualités et les dernières vidéos publiées par les créateurs de contenu auquel l’utilisateur est abonné.
Page “Utilisateur” :
Pour les utilisateurs non-authentifiés, la page redirige vers la page de connexion/création de compte.
Cette page met en avant deux sections :
Historique des vidéos regardées par l’utilisateur,
Playlists de l’utilisateur :
Liste des playlists de l’utilisateur (dont celle “À consulter plus tard” créée par défaut)
Création/Suppression de playlists
Page “Playlist” :
Un utilisateur accède à cette page depuis la liste de ses playlists dans la page “Utilisateur”.
Cette page comprend uniquement le nom de la playlist et les vidéos (miniature, titre et chaine Freetube) contenus dans cette dernière, trié par date d’ajout dans cette dernière.
Page pour les vidéos :
Lorsque un utilisateur clique sur la miniature d’une vidéo ou utilise le lien de partage d’une vidéo, il arrive sur cette page qui affiche :
Un lecteur vidéo qui comprend :
La vidéo publiée,
Les fonctionnalités de base d’un lecteur vidéo :
Play,
Pause,
Avance de XX secondes dans la vidéo,
Recul de XX secondes dans la vidéo,
Les informations du créateur de contenu :
Image de profil
Nom de la chaine
Nombre de “J’aime(s)” de la vidéo,
Nombre d’abonnés du créateur
Un bouton pour s’abonner à la chaine Freetube du créateur de la vidéo,
Un bouton pour aimer la vidéo,
Un bouton pour ajouter à une playlist (soit la playlist créée par défaut “À consulter plus tard”, soit une playlist personnalisée)
La description de la vidéo,
Un espace commentaire :
Possibilité de laisser un commentaire,
Feed de commentaires, affichés par date de publication (les commentaires les plus récents en premier)
Une section “Recommendations” pour un utilisateur authentifié ou une section “Tendance” pour un utilisateur non-authentifié,
2.3 - Architecture et déploiement :
2.3.1 - Architecture
Votre application doit comporter trois briques distinctes :
un serveur, devant être une API REST ou GraphQL et devant implémenter l'ensemble des fonctionnalités précédemment énoncées,
un client web devant uniquement interagir avec le serveur.,
une base de données (choix libre).
Aucune logique ne doit avoir lieu sur le client qui ne sert que d'interface et redirige les différentes requêtes vers le serveur.
2.3.2 - Containérisation
Le projet doit comporter un fichier docker-compose.yml à la racine du projet permettant de déployer au moins 3 services Docker distincts, respectivement pour le serveur, le client web et la base de données.
L'application doit pouvoir être lancée intégralement via docker compose et être fonctionnelle.
3 - Rendu
Il se fera sous la forme d'une archive au format "zip" contenant le code source, les fichiers annexes (sons, images, etc.), la documentations technique et le manuel utilisateur.
La documentation technique est à destination de professionnels du domaine et contiendra au moins les éléments suivants :
Informations et éléments à renseigner nécessaires au fonctionnement de l'application,
Guide de déploiement de l'application,
Justification du choix des langages et des librairies,
Diagrammes UML,
Schéma de la base de données,
Le manuel utilisateur explique lui comment se servir de la solution et présente les différentes fonctionnalités.
Aucun secret (clef d'API, mot de passe, etc.) ne doit être présent dans le rendu. Un secret présent en clair entrainera un malus de points sur la notation en fonction de la criticité de ce dernier.
Tout rendu effectué en retard ne pourra pas être pris en compte.
Ce projet est noté sur 500 points avec possibilité d'obtenir 50 points en bonus :
Documentations : 50 points (une note inférieure à 30 points sur cette partie entraînera un ajournement à ce projet)
Documentation technique : 30 points
Manuel utilisateur : 20 points
Déploiement : 50 points (une note inférieure à 30 points sur cette partie entraînera un ajournement à ce projet)
Architecture et abstraction : 20 points
Containérisation : 30 points
Interface utilisateur : 50 points
Expérience utilisateur : 50 points
Fonctionnalités : 200 points (une note inférieure à 120 points sur cette partie entraînera un ajournement à ce projet) :
Inscription et connexion : 15 points
Connexion utilisateur standard (nom d’utilisateur et mot de passe) : 5 points
Connexion via OAuth2 (Facebook, Google, etc.) : 10 points
Gestion d’utilisateur : 10 points
Administration d’une chaine Freetube : 55 points
Mettre en ligne une vidéo : 30 points
Un média vidéo : 10 points
Une miniature de vidéo : 2 points
Un titre : 2 points
Une description : 2 points
La date de mise en ligne : 2 points
Mots-clefs : 2 points
Visibilité : 5 points
Lien partageable : 5 points
Éditer une vidéo existante : 5 points
Changer la visibilité : 5 points
Supprimer une vidéo : 5 points
Statistiques d’une vidéo : 5 points
Statistiques globales : 5 poins
Page Accueil : 30 points
Authentifié : 15 points
Recommendations : 5 points
À consulter plus tard : 5 points
Tendances : 5 points
Non-authentifié : 15 points
Recommendations : 5 points
Tendances : 5 points
Top créateurs : 5 points
Page Abonnements : 10 points
Fil d’actualité : 8 points
Redirection non-authentifié : 2 points
Page Utilisateur : 15 points :
Historique des vidéos : 10 points
Gestion et liste des playlists : 5 points
Page Playlist : 10 points
Page Vidéo : 50 points
Lecteur vidéo : 20 points :
Média visualisable : 10 points
Pause : 2 points
Play : 2 points
Saut XX secondes en avant : 3 points
Saut XX secondes en arrière : 3 points
Informations de la vidéo : 20 points
Titre de la vidéo : 2 points
Description : 2 points
Nom de la chaine : 2 points
Compteur abonnés : 2 points
Compteur “J’aime(s)” : 2 points
Bouton “J’aime” : 5 points
Bouton “S’abonner” : 5 points
Commentaires : 10 points
Créer un commentaire : 5 points
Voir les commentaires : 5 points
Recommendations : 5 points
Qualité du code : 100 points (une note inférieure à 60 points sur cette partie entraînera un ajournement à ce projet) :
Le barème item par item est identique à celui des fonctionnalités. Pour un item non réalisé ou complètement dysfonctionnel, la note de qualité de code correspondante sera automatiquement égale à zéro.
Les critères appréciés ici sont essentiellement :
Structures de données adaptées.
Absence de duplication inutile de code.
Lisibilité du code (cela inclut la cohérence et le sens du nommage des variables et sous-programmes).
Facilité de maintenance.
Abstraction du code
Loading…
Cancel
Save