Browse Source

Merge branch 'developpement' of github.com:Astri4-4/3RESIT_DOCKER into developpement

features/search
Astri4-4 4 months ago
parent
commit
a55182ec1a
  1. 9
      .gitignore
  2. 62
      backend/app/controllers/channel.controller.js
  3. 6
      backend/app/controllers/comment.controller.js
  4. 18
      backend/app/controllers/playlist.controller.js
  5. 2
      backend/app/controllers/recommendation.controller.js
  6. 2
      backend/app/controllers/search.controller.js
  7. 40
      backend/app/controllers/user.controller.js
  8. 83
      backend/app/controllers/video.controller.js
  9. 6
      backend/app/routes/channel.route.js
  10. 2
      backend/app/routes/user.route.js
  11. 5
      backend/app/routes/video.route.js
  12. BIN
      backend/app/uploads/profiles/astri6.jpg
  13. BIN
      backend/app/uploads/profiles/astri7.jpg
  14. BIN
      backend/app/uploads/profiles/astria.png
  15. BIN
      backend/app/uploads/profiles/astria2.jpg
  16. BIN
      backend/app/uploads/profiles/astria3.jpg
  17. BIN
      backend/app/uploads/videos/946FFC1D2D8C189D.mp4
  18. 3
      backend/app/utils/database.js
  19. 2605
      backend/logs/access.log
  20. 6
      backend/requests/video.http
  21. 6
      backend/server.js
  22. 9
      docker-compose.yaml
  23. 37
      frontend/package-lock.json
  24. 2
      frontend/package.json
  25. 7
      frontend/src/components/Comment.jsx
  26. 52
      frontend/src/components/LinearGraph.jsx
  27. 22
      frontend/src/components/Tag.jsx
  28. 19
      frontend/src/components/VideoStatListElement.jsx
  29. 4
      frontend/src/index.css
  30. 80
      frontend/src/modals/CreateChannelModal.jsx
  31. 17
      frontend/src/pages/Account.jsx
  32. 275
      frontend/src/pages/AddVideo.jsx
  33. 208
      frontend/src/pages/ManageChannel.jsx
  34. 417
      frontend/src/pages/ManageVideo.jsx
  35. 8
      frontend/src/pages/Video.jsx
  36. 27
      frontend/src/routes/routes.jsx
  37. 21
      nginx/default.conf
  38. 23
      nginx/nginx-selfsigned.crt
  39. 28
      nginx/nginx-selfsigned.key

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

62
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.end();
res.status(200).json(channel);
}
@ -27,10 +28,31 @@ 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 *
FROM channels
WHERE channels.id = $1
`;
const result = await client.query(query, [id]);
const videoQuery = `
SELECT v.*, COUNT(h.video) as views, COUNT(l.id) as likes, COUNT(c.id) as comments
FROM videos v
LEFT JOIN history h ON v.id = h.video
LEFT JOIN likes l ON v.id = l.video
LEFT JOIN comments c ON v.id = c.video
WHERE v.channel = $1
GROUP BY v.id, title, thumbnail, description, channel, visibility, file, slug, format, release_date
`;
const videoResult = await client.query(videoQuery, [id]);
result.rows[0].videos = videoResult.rows;
logger.write("Successfully get channel with id " + id, 200);
res.status(200).json(result);
client.end();
res.status(200).json(result.rows[0]);
}
@ -65,6 +87,7 @@ export async function getAll(req, res) {
})
logger.write("Successfully get all channels", 200);
client.end();
res.status(200).json(result);
}
@ -88,6 +111,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.end();
res.status(400).json({error: 'Name already used'});
return
}
@ -96,6 +120,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.end();
res.status(200).json(channel);
}
@ -108,6 +133,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.end();
res.status(200).json({message: 'Successfully deleted'});
}
@ -132,6 +158,7 @@ export async function toggleSubscription(req, res) {
const remainingSubscriptions = countResult.rows[0].count;
logger.write("Successfully unsubscribed from channel", 200);
client.end();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else {
// Subscribe
@ -144,6 +171,37 @@ export async function toggleSubscription(req, res) {
const totalSubscriptions = countResult.rows[0].count;
logger.write("Successfully subscribed to channel", 200);
client.end();
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.end();
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.end();
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.end()
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.end();
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.end();
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.end();
res.status(200).json(result.rows[0]);
}

18
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.end()
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error creating playlist: " + error.message, 500);
client.end();
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.end();
res.status(200).json({id: result.rows[0].id});
} catch (error) {
logger.write("Error adding video to playlist: " + error.message, 500);
client.end();
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.end();
res.status(404).json({ error: "No playlists found" });
return;
}
logger.write("Playlists retrieved for user with id " + id, 200);
client.end();
res.status(200).json(result.rows);
} catch (error) {
logger.write("Error retrieving playlists: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -82,13 +89,16 @@ export async function getById(req, res) {
const result = await client.query(query, [id]);
if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404);
client.end();
res.status(404).json({ error: "Playlist not found" });
return;
}
logger.write("Playlist retrieved with id " + id, 200);
client.end();
res.status(200).json(result.rows[0]);
} catch (error) {
logger.write("Error retrieving playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -105,13 +115,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.end();
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.end();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error updating playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -127,13 +140,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.end();
res.status(404).json({ error: "Video not found in playlist" });
return;
}
logger.write("Video deleted from playlist with id " + id, 200);
client.end();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error deleting video from playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -148,9 +164,11 @@ export async function del(req, res) {
try {
const result = await client.query(query, [id]);
logger.write("Playlist deleted", 200);
client.end()
res.status(200).json({ "message": "playlist deleted" });
} catch (error) {
logger.write("Error deleting playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}

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

@ -30,6 +30,7 @@ export async function getRecommendations(req, res) {
// Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur
client.end()
res.status(200).json({
message: "Recommendations based on user history and interactions are not yet implemented."
});
@ -80,6 +81,7 @@ export async function getTrendingVideos(req, res) {
}
client.end();
res.status(200).json(trendingVideos);
} catch (error) {
console.error("Error fetching trending videos:", error);

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

@ -76,7 +76,7 @@ export async function search(req, res) {
}
client.end()
return res.status(200).json(videos);

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

@ -99,6 +99,7 @@ export async function login(req, res) {
}
logger.write("Successfully logged in", 200);
client.end();
res.status(200).json({token: token, user: userData});
}
@ -108,16 +109,21 @@ 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);
client.end()
res.status(404).json({error: "Not Found"});
return
}
logger.write("successfully retrieved user " + id, 200);
if (result.rows[0].picture) {
return res.status(200).json({user: result.rows[0]});
}
}
export async function getByUsername(req, res) {
const username = req.params.username;
@ -128,10 +134,12 @@ 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);
client.end()
res.status(404).json({error: "Not Found"});
return
}
logger.write("successfully retrieved user " + username, 200);
client.end();
return res.status(200).json({user: result.rows[0]});
}
@ -159,6 +167,7 @@ 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)
client.end();
res.status(400).json({error: "Email already exists"});
}
}
@ -168,6 +177,7 @@ 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)
client.end();
res.status(400).json({error: "Username already exists"});
}
}
@ -184,12 +194,31 @@ 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.end();
res.status(200).json(result.rows[0]);
} catch (err) {
console.log(err);
client.end()
res.status(500).json({error: err});
}
@ -203,6 +232,7 @@ 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);
client.end();
res.status(200).json({message: 'User deleted'});
}
@ -217,11 +247,13 @@ 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);
client.end();
res.status(404).json({error: "Channel Not Found"});
return;
}
logger.write("successfully retrieved channel of user " + id, 200);
client.end();
res.status(200).json({channel: result.rows[0]});
}
@ -268,9 +300,11 @@ 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"});
client.end();
return;
}
logger.write("successfully retrieved history of user " + id, 200);
client.end();
res.status(200).json(videos);
}

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

@ -49,10 +49,12 @@ export async function upload(req, res) {
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;
logger.write("successfully uploaded video", 200);
res.status(200).json({"message": "Successfully uploaded video"});
await client.end()
res.status(200).json({"message": "Successfully uploaded video", "id":id});
}
@ -75,6 +77,7 @@ 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);
await client.end();
res.status(200).json({"message": "Successfully uploaded thumbnail"});
}
@ -97,7 +100,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;
@ -122,6 +125,7 @@ export async function getById(req, res) {
video.tags = tagsResult.rows.map(tag => tag.name);
logger.write("successfully get video " + id, 200);
client.end()
res.status(200).json(video);
}
@ -133,6 +137,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.end()
res.status(200).json(result.rows);
}
@ -144,6 +149,7 @@ export async function update(req, res) {
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]);
logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"});
}
@ -161,6 +167,7 @@ export async function updateVideo(req, res) {
fs.unlink(pathToDelete, (error) => {
if (error) {
logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"});
return
}
@ -172,6 +179,7 @@ export async function updateVideo(req, res) {
fs.writeFileSync(destinationPath, fileBuffer);
logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"});
})
@ -193,6 +201,7 @@ export async function del(req, res) {
fs.unlink(pathToDelete, (error) => {
if (error) {
logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"});
return
}
@ -207,6 +216,7 @@ export async function del(req, res) {
const query = `DELETE FROM videos WHERE id = $1`;
await client.query(query, [id]);
logger.write("successfully deleted video", 200);
client.end()
res.status(200).json({"message": "Successfully deleted video"});
})
})
@ -237,6 +247,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count;
logger.write("no likes found adding likes for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully added like", "likes": likesCount});
} else {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
@ -248,6 +259,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count;
logger.write("likes found, removing like for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully removed like", "likes": likesCount});
}
@ -295,9 +307,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"});
res.status(200).json({"message": "Successfully added tags to video", "tags" : updatedTags.map(tag => tag.name)});
}
@ -355,10 +373,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.end();
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.end();
}
}
export async function addViews(req, res) {
const id = req.params.id;
const logger = req.body.logger;
@ -378,5 +448,6 @@ export async function addViews(req, res) {
}
logger.write("successfully added views for video " + id, 200);
await client.end();
res.status(200).json({"message": "Successfully added views"});
}

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

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

@ -38,7 +38,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);

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

@ -10,7 +10,7 @@ import {
uploadThumbnail,
updateVideo,
toggleLike,
addTags, getSimilarVideos, addViews
addTags, getSimilarVideos, addViews, getLikesPerDay
} from "../controllers/video.controller.js";
import {
doVideoExists,
@ -58,5 +58,8 @@ router.get("/:id/similar", [addLogger, Video.id, validator, doVideoExistsParam],
// ADD VIEWS
router.get("/:id/views", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], addViews);
// GET LIKE PER DAY
router.get("/:id/likes/day", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], getLikesPerDay);
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: 122 KiB

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/videos/946FFC1D2D8C189D.mp4

Binary file not shown.

3
backend/app/utils/database.js

@ -62,7 +62,8 @@ export async function initDb() {
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)
video INTEGER NOT NULL REFERENCES videos(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);`;
await client.query(query);

2605
backend/logs/access.log

File diff suppressed because it is too large

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
}
###

6
backend/server.js

@ -20,9 +20,9 @@ const app = express();
// INITIALIZE DATABASE
app.use(express.urlencoded({extended: true}));
app.use(express.json());
// Increase body size limits for file uploads
app.use(express.urlencoded({extended: true, limit: '500mb'}));
app.use(express.json({limit: '500mb'}));
app.use(cors())
// ROUTES

9
docker-compose.yaml

@ -4,14 +4,13 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
network: host
container_name: resit_backend
ports:
- "8000:8000"
environment:
DB_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST}
DB_HOST: db
DB_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE}
@ -35,12 +34,16 @@ services:
frontend:
image: nginx:latest
network_mode: host
ports:
- "80:80"
- "443:443"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./nginx/nginx-selfsigned.crt:/etc/nginx/ssl/nginx-selfsigned.crt
- ./nginx/nginx-selfsigned.key:/etc/nginx/ssl/nginx-selfsigned.key
depends_on:
- resit_backend
volumes:
db_data:

37
frontend/package-lock.json

@ -9,7 +9,10 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"chartjs": "^0.3.24",
"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 +1007,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 +1803,24 @@
"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/chartjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/chartjs/-/chartjs-0.3.24.tgz",
"integrity": "sha512-h6G9qcDqmFYnSWqjWCzQMeOLiypS+pM6Fq2Rj7LPty8Kjx5yHonwwJ7oEHImZpQ2u9Pu36XGYfardvvBiQVrhg==",
"license": "MIT"
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2999,6 +3026,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",

2
frontend/package.json

@ -11,7 +11,9 @@
},
"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"

7
frontend/src/components/Comment.jsx

@ -2,7 +2,7 @@ import {useAuth} from "../contexts/AuthContext.jsx";
import {useRef, useState} from "react";
export default function Comment({ comment, index, videoId, refetchVideo }) {
export default function Comment({ comment, index, videoId, refetchVideo, doShowCommands=true }) {
let {user, isAuthenticated} = useAuth();
let commentRef = useRef();
@ -82,8 +82,11 @@ 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>
{
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)}>
@ -115,6 +118,8 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
</button>
) : null }
</div>
)
}
</div>
)

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>
)
}

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>
)
}

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-2xl font-montserrat font-bold" >{video.title}</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>
);
}

4
frontend/src/index.css

@ -31,6 +31,10 @@
backdrop-filter: blur(27.5px);
}
.resizable-none {
resize: none;
}
@theme {
/* Fonts */
--font-inter: 'Inter', sans-serif;

80
frontend/src/modals/CreateChannelModal.jsx

@ -0,0 +1,80 @@
import {useState} from "react";
export default function CreateChannelModal({isOpen, onClose}) {
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 request = await fetch(`/api/channels/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
"name": name,
"description": description,
"owner": user.id
})
})
if (!request.ok) {
console.error("Not able to create channel");
return; // Prevent further execution if the request failed
}
const data = await request.json();
console.log(data);
}
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center" >
<div className="glassmorphism p-4 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>
)
}

17
frontend/src/pages/Account.jsx

@ -2,6 +2,8 @@ import Navbar from "../components/Navbar.jsx";
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";
export default function Account() {
@ -16,8 +18,10 @@ 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 navigation = useNavigate();
const fetchUserChannel = async () => {
try {
@ -93,7 +97,7 @@ export default function Account() {
const editModeClasses = nonEditModeClasses + " glassmorphism";
const handlePlaylistClick = (playlistId) => {
navigation(`/playlist/${playlistId}`);
}
const handleUpdateUser = async () => {
@ -132,6 +136,7 @@ export default function Account() {
}
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar/>
@ -253,9 +258,9 @@ export default function Account() {
<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">
<span onClick={() => navigation(`/manage-channel/${userChannel.channel.id}`)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Gérer la chaîne
</a>
</span>
</button>
</div>
) : (
@ -263,7 +268,7 @@ export default function Account() {
<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">
<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>
@ -292,7 +297,7 @@ export default function Account() {
</div>
</main>
<CreateChannelModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
)

275
frontend/src/pages/AddVideo.jsx

@ -0,0 +1,275 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import Tag from "../components/Tag.jsx";
export default function AddVideo() {
const storedUser = localStorage.getItem("user");
const user = storedUser ? JSON.parse(storedUser) : null;
const token = localStorage.getItem("token");
const [videoTitle, setVideoTitle] = useState("");
const [videoDescription, setVideoDescription] = useState("");
const [videoTags, setVideoTags] = useState([]);
const [visibility, setVisibility] = useState("public"); // Default visibility
const [videoThumbnail, setVideoThumbnail] = useState(null);
const [videoFile, setVideoFile] = useState(null);
const [channel, setChannel] = useState(null); // Assuming user.channel is the channel ID
useEffect(() => {
fetchChannel();
}, [])
const fetchChannel = async () => {
try {
const response = await fetch(`/api/users/${user.id}/channel`, {
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
});
if (!response.ok) {
throw new Error("Erreur lors de la récupération de la chaîne");
}
const data = await response.json();
setChannel(data.channel);
} catch (error) {
console.error("Erreur lors de la récupération de la chaîne :", error);
}
}
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();
if (!videoTitle || !videoDescription || !videoThumbnail || !videoFile) {
alert("Veuillez remplir tous les champs requis.");
return;
}
if (!channel || !channel.id) {
alert("Erreur: aucune chaîne trouvée. Veuillez actualiser la page.");
return;
}
if (videoTags.length > 10) {
alert("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()); // Ensure it's a string
formData.append("visibility", visibility);
formData.append("file", videoFile);
const request = await fetch("/api/videos", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: formData
});
if (!request.ok) {
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}`
).join('\n');
alert(`Erreurs de validation:\n${errorMessages}`);
} else {
alert(`Erreur lors de l'ajout de la vidéo : ${errorData.message || 'Erreur inconnue'}`);
}
return;
}
// 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());
const thumbnailRequest = await fetch("/api/videos/thumbnail", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: thumbnailFormData
});
if (!thumbnailRequest.ok) {
const errorData = await thumbnailRequest.json();
console.error("Backend validation errors:", errorData);
alert(`Erreur lors de l'ajout de la miniature : ${errorData.message || 'Erreur inconnue'}`);
return;
}
// if the thumbnail was successfully uploaded, we can send the tags
const tagsRequest = await fetch(`/api/videos/${videoId}/tags`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: JSON.stringify({ tags: videoTags, channel: channel.id.toString() }) // Ensure channel ID is a string
});
if (!tagsRequest.ok) {
const errorData = await tagsRequest.json();
console.error("Backend validation errors:", errorData);
alert(`Erreur lors de l'ajout des tags : ${errorData.message || 'Erreur inconnue'}`);
return;
}
// If everything is successful, redirect to the video management page
alert("Vidéo ajoutée avec succès !");
};
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} />
<main className="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>
<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 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>
</div>
);
}

208
frontend/src/pages/ManageChannel.jsx

@ -0,0 +1,208 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import {useNavigate, useParams} from "react-router-dom";
import {useAuth} from "../contexts/AuthContext.jsx";
import VideoStatListElement from "../components/VideoStatListElement.jsx";
export default function ManageChannel() {
const {id} = useParams();
const {user} = useAuth();
const navigate = useNavigate();
const [channel, setChannel] = useState();
const [channelStats, setChannelStats] = useState();
const [channelName, setChannelName] = useState(null);
const [description, setDescription] = useState(null);
const [editMode, setEditMode] = useState(false);
const token = localStorage.getItem("token");
const nonEditModeClasses = "text-2xl font-bold text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none text-center";
const editModeClasses = nonEditModeClasses + " glassmorphism";
const nonEditModeClassesTextArea = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none w-full"
const editModeClassesTextArea = nonEditModeClassesTextArea + " glassmorphism h-48";
useEffect(() => {
fetchChannelData()
fetchChannelStats()
}, []);
const fetchChannelData = async () => {
try {
const request = await fetch(`/api/channels/${id}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`
}
})
const result = await request.json();
setChannel(result);
} catch (error) {
console.error("Error fetching channel data:", error);
}
}
const fetchChannelStats = async () => {
try {
const request = await fetch(`/api/channels/${id}/stats`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`
}
})
const result = await request.json();
setChannelStats(result);
} catch (error) {
console.error("Error fetching channel stats", error);
}
}
const handleUpdateChannel = async () => {
if (!editMode) return;
try {
const response = await fetch(`/api/channels/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
name: channelName || channel.name,
description: description || channel.description,
})
});
if (response.ok) {
setEditMode(false);
fetchChannelData(); // Refresh channel data after update
} else {
console.error("Failed to update channel");
}
} catch (error) {
console.error("Error updating channel:", error);
}
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} />
<main className="pt-[118px] px-36 flex">
{/* LEFT SIDE */}
<form className="glassmorphism w-1/3 h-screen py-10 px-4">
<img src={user.picture} className="w-1/3 aspect-square object-cover rounded-full mx-auto" alt=""/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat ${editMode ? "block" : "hidden"} `}>
Nom de chaine
</label>
<input
type="text"
id="name"
value={channelName || channelName === "" ? channelName : channel ? channel.name : "Chargement"}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setChannelName(e.target.value)}
placeholder="Nom d'utilisateur"
disabled={!editMode}
/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Description
</label>
<textarea
name="description"
id=""
className={(editMode ? editModeClassesTextArea : nonEditModeClassesTextArea)}
value={description || description === "" ? description : channel ? channel.description : "Chargement"}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de votre chaine"
disabled={!editMode}
></textarea>
{
editMode ? (
<div className="mt-4">
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={handleUpdateChannel}
>
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"
onClick={() => setEditMode(!editMode)}
>
Annuler
</button>
</div>
) : (
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer mt-4"
onClick={() => setEditMode(!editMode)}
>
Modifier
</button>
)
}
</form>
{/* RIGHT SIDE */}
<div className="w-2/3 h-screen pl-10" >
{/* VIEW / SUBSCRIBERS STATS */}
<div className="flex gap-4" >
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" >
{/* TOTAL VIEWS */}
<p className="text-white text-xl font-montserrat font-semibold" >Vues totales</p>
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.views : "0"}</p>
</div>
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" >
{/* TOTAL SUBSCRIBERS */}
<p className="text-white text-xl font-montserrat font-semibold" >Abonnés</p>
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.subscribers : "0"}</p>
</div>
</div>
{/* VIDEOS */}
<div className="flex justify-between">
<h2 className="text-white text-3xl font-montserrat font-bold mt-10" >Vidéos</h2>
<button
className="bg-primary px-2 py-1 rounded-sm text-white font-montserrat text-md font-semibold cursor-pointer mt-4"
onClick={() => navigate("/add-video")}
>
Ajouter une vidéo
</button>
</div>
{ channel?.videos?.length > 0 ? (
<div className="flex flex-col gap-4 mt-5">
{channel.videos.map((video) => (
<VideoStatListElement
video={video}
onClick={() => navigate("/manage-video/" + video.id)}
key={video.id}
/>
))}
</div>
) : (
<p className="text-white text-xl font-montserrat mt-4">Aucune vidéo trouvée pour cette chaîne.</p>
)}
</div>
</main>
</div>
)
}

417
frontend/src/pages/ManageVideo.jsx

@ -0,0 +1,417 @@
import Navbar from "../components/Navbar.jsx";
import {useParams} from "react-router-dom";
import {useAuth} from "../contexts/AuthContext.jsx";
import {useEffect, useState} from "react";
import LinearGraph from "../components/LinearGraph.jsx";
import Comment from "../components/Comment.jsx";
import Tag from "../components/Tag.jsx";
export default function ManageVideo() {
const { user } = useAuth();
const token = localStorage.getItem("token");
const {id} = useParams();
const [video, setVideo] = useState(null);
const [likesPerDay, setLikesPerDay] = useState([]);
const [viewsPerDay, setViewsPerDay] = useState([]);
const [videoTitle, setVideoTitle] = useState(null);
const [description, setDescription] = useState(null);
const [visibility, setVisibility] = useState("private");
const [editMode, setEditMode] = useState(false);
const [thumbnailPreview, setThumbnailPreview] = useState(null);
const [videoFile, setVideoFile] = useState(null);
const nonEditModeClasses = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none";
const editModeClasses = nonEditModeClasses + " glassmorphism";
const nonEditModeClassesTextArea = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none w-full"
const editModeClassesTextArea = nonEditModeClassesTextArea + " glassmorphism h-48";
const fetchVideo = async () => {
const request = await fetch(`/api/videos/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!request.ok) {
throw new Error("Failed to fetch video");
}
const data = await request.json();
setVideo(data);
setVisibility(data.visibility)
setVideoTitle(data.title);
setDescription(data.description);
}
const fetchLikesPerDay = async () => {
const request = await fetch(`/api/videos/${id}/likes/day`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!request.ok) {
throw new Error("Failed to fetch likes per day");
}
const data = await request.json();
setLikesPerDay(data.likes);
setViewsPerDay(data.views);
}
useEffect(() => {
if (user) {
fetchVideo()
fetchLikesPerDay()
}
}, [user, id, token]);
const onSubmit = async (e) => {
e.preventDefault();
if (!editMode) return;
const request = await fetch(`/api/videos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: videoTitle,
description: description,
visibility: visibility,
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to update video");
return;
}
const form = new FormData();
if (videoFile) {
form.append('file', videoFile);
form.append('video', id);
form.append('channel', video.channel);
const videoRequest = await fetch(`/api/videos/${id}/video`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
},
body: form
});
if (!videoRequest.ok) {
console.error("Failed to update video file");
return;
}
}
const data = await request.json();
setVideo(data);
setEditMode(false);
}
const handleThumbnailChange = async (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setThumbnailPreview(e.target.result);
};
reader.readAsDataURL(file);
}
const formData = new FormData();
formData.append('file', file);
formData.append('video', id);
formData.append('channel', video.channel);
const request = await fetch(`/api/videos/thumbnail`, {
"method": 'POST',
"headers": {
"Authorization": `Bearer ${token}`
},
body: formData
})
if (!request.ok) {
console.error("Failed to upload thumbnail");
return;
}
const data = await request.json();
};
const onAddTag = async (e) => {
if (e.key !== 'Enter' || e.target.value.trim() === "") return;
const newTag = e.target.value.trim();
e.target.value = "";
const request = await fetch(`/api/videos/${id}/tags`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tags: [...video.tags, newTag],
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to add tag");
return;
}
const data = await request.json();
setVideo({
...video,
tags: [...video.tags, newTag]
});
}
const onSuppressTag = async (tag) => {
const request = await fetch(`/api/videos/${id}/tags`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tags: video.tags.filter(t => t !== tag),
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to suppress tag");
return;
}
const data = await request.json();
const newTags = video.tags.filter(t => t !== tag);
setVideo({
...video,
tags: newTags
});
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar/>
<main className="px-36 pb-36">
{ /* GRAPHS */ }
<div>
<div className="flex pt-[118px] gap-4" >
<LinearGraph
dataToGraph={viewsPerDay}
legend="Vues"
className="glassmorphism flex-1 h-[300px] p-4"
/>
<LinearGraph
dataToGraph={likesPerDay}
legend="Likes"
className="glassmorphism flex-1 h-[300px] p-4"
borderColor="#FF073A"
/>
</div>
<div className="flex gap-4 mt-4 ">
{ /* LEFT SIDE */ }
<div className="flex-1" >
{ /* THUMBNAIL */ }
<label htmlFor="thumbnail" className="glassmorphism flex justify-center items-center py-5 relative overflow-hidden ">
<img src={thumbnailPreview || (video ? video.thumbnail : "")} alt="" className=" rounded-sm"/>
<div className="absolute top-0 left-0 bg-[#00000080] w-full h-full flex justify-center items-center opacity-0 hover:opacity-100 transition" >
<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" name="thumbnail" id="thumbnail" className="opacity-0 w-0 h-0 hidden" onChange={handleThumbnailChange} accept="image/*"/>
{ /* VIDEO INFOS */ }
<form className="glassmorphism p-4 mt-4 flex-1" >
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Titre de la vidéo
</label>
<input
type="text"
id="name"
value={videoTitle || videoTitle === "" ? videoTitle : video ? video.title : "Chargement"}
onChange={(e) => setVideoTitle(e.target.value)}
className={(editMode ? editModeClasses : nonEditModeClasses) + " text-xl"}
placeholder="Titre de la vidéo"
disabled={!editMode}
/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Description
</label>
<textarea
name="description"
id=""
className={(editMode ? editModeClassesTextArea : nonEditModeClassesTextArea)}
value={description || description === "" ? description : video ? video.description : "Chargement"}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de votre chaine"
disabled={!editMode}
></textarea>
<label htmlFor="visibility" className="text-2xl text-white mb-1 block font-montserrat">
Visibilité
</label>
<select
className={(editMode ? editModeClasses : nonEditModeClasses)}
value={visibility}
name="visibility"
onChange={(e) => setVisibility(e.target.value)}
disabled={!editMode}
>
<option value="public">Publique</option>
<option value="private">Privée</option>
</select>
<label htmlFor="video" className={`flex gap-2 glassmorphism p-2 items-center mt-4 cursor-pointer ${editMode ? "block" : "hidden"}`}>
<video src={video ? video.file : ""} className="w-1/8 aspect-video rounded-sm" ></video>
<p className="text-2xl text-white mb-1 block font-montserrat">Fichier vidéo</p>
</label>
<input
type="file"
name="video"
id="video"
className="hidden"
accept="video/*"
onChange={(e) => setVideoFile(e.target.files[0])}
/>
{
editMode ? (
<div className="mt-4">
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={(e) => {onSubmit(e)}}
>
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"
onClick={() => setEditMode(!editMode)}
>
Annuler
</button>
</div>
) : (
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer mt-4"
onClick={() => setEditMode(!editMode)}
>
Modifier
</button>
)
}
</form>
</div>
{ /* RIGHT SIDE */ }
<div className="flex-1">
<div className="flex gap-4">
{ /* TOTAL VIEWS */ }
<div className="glassmorphism py-16 flex-1">
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.views : 0} vues</p>
</div>
{ /* TOTAL LIKES */ }
<div className="glassmorphism py-16 flex-1">
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.likes : 0} likes</p>
</div>
</div>
{ /* COMMENTS */ }
<div className="glassmorphism p-4 mt-4 h-[500px] overflow-y-auto">
<h2 className="text-2xl font-bold text-white mb-4">Commentaires</h2>
{video && video.comments && video.comments.length > 0 ? (
video.comments.map((comment) => (
<Comment comment={comment} doShowCommands={false} />
))
) : (
<p className="text-gray-500">Aucun commentaire</p>
)}
</div>
{ /* TAGS */ }
<div className="glassmorphism p-4 mt-4">
<h2 className="text-2xl font-bold text-white mb-4">Tags</h2>
<div className="flex flex-wrap gap-2">
{ video && video.tags && video.tags.length > 0 ? (
video.tags.map((tag) => (
<Tag tag={tag} key={tag} onSuppress={() => onSuppressTag(tag)} />
))
) : (
<p className="text-gray-500">Aucun tag</p>
)}
</div>
<input
type="text"
className="glassmorphism text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none mt-4"
placeholder="Ajouter un tag"
onKeyPress={(e) => onAddTag(e)}
/>
</div>
{ /* LINK */ }
<div className="glassmorphism p-4 mt-4">
<h2 className="text-2xl font-bold text-white mb-4">Lien de la vidéo</h2>
<p className="text-md font-normal text-white">
<a href={`/video/${id}`} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{window.location.origin}/video/{id}
</a>
<button
className="ml-2 bg-primary text-white p-2 rounded-sm cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/video/${id}`);
}}
>
Copier
</button>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
)
}

8
frontend/src/pages/Video.jsx

@ -4,6 +4,7 @@ import Navbar from "../components/Navbar.jsx";
import { useAuth } from "../contexts/AuthContext.jsx";
import Comment from "../components/Comment.jsx";
import VideoCard from "../components/VideoCard.jsx";
import Tag from "../components/Tag.jsx";
export default function Video() {
@ -396,12 +397,7 @@ export default function Video() {
<div className="mb-3">
<div className="flex flex-wrap gap-2">
{video.tags.map((tag, index) => (
<span
key={index}
className="bg-gray-700 text-white px-3 py-1 rounded-full text-sm font-montserrat"
>
#{tag}
</span>
<Tag tag={tag} key={index} doShowControls={false} />
))}
</div>
</div>

27
frontend/src/routes/routes.jsx

@ -4,6 +4,9 @@ import Register from '../pages/Register.jsx'
import Video from '../pages/Video.jsx'
import ProtectedRoute from '../components/ProtectedRoute.jsx'
import Account from "../pages/Account.jsx";
import ManageChannel from "../pages/ManageChannel.jsx";
import ManageVideo from "../pages/ManageVideo.jsx";
import AddVideo from "../pages/AddVideo.jsx";
const routes = [
{ path: "/", element: <Home/> },
@ -34,6 +37,30 @@ const routes = [
<Account/>
</ProtectedRoute>
)
},
{
path: "/manage-channel/:id",
element: (
<ProtectedRoute requireAuth={true}>
<ManageChannel/>
</ProtectedRoute>
)
},
{
path: "/manage-video/:id",
element: (
<ProtectedRoute requireAuth={true}>
<ManageVideo/>
</ProtectedRoute>
)
},
{
path: "/add-video",
element: (
<ProtectedRoute requireAuth={true}>
<AddVideo/>
</ProtectedRoute>
)
}
]

21
nginx/default.conf

@ -2,17 +2,36 @@ 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/ {
proxy_pass http://127.0.0.1:8000;
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_buffering off;
# Also set timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# Static assets - NO CACHING for development

23
nginx/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
nginx/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-----
Loading…
Cancel
Save