From 18000569185843b22fad1b2500167c8ef29aa0ea Mon Sep 17 00:00:00 2001 From: Kai Ritthaler Date: Thu, 26 Jun 2025 11:10:52 +0200 Subject: [PATCH] added endpoints for profile and feed --- code/backend/package.json | 1 - code/backend/prisma/schema.prisma | 2 +- .../backend/src/controllers/feedController.ts | 31 +++ .../src/controllers/followerController.ts | 124 +++++++++ .../backend/src/controllers/postController.ts | 147 ++++++++++- .../src/controllers/profileController.ts | 235 ++++++++++++++++++ .../backend/src/controllers/userController.ts | 31 --- .../src/middleware/authenticateToken.ts | 11 - .../middleware/optionalAuthenticateToken.ts | 25 ++ .../{fileUpload.ts => uploadMultiple.ts} | 8 +- code/backend/src/middleware/uploadSingle.ts | 42 ++++ code/backend/src/routes/feedRoutes.ts | 57 +++++ code/backend/src/routes/followerRoutes.ts | 50 ++++ code/backend/src/routes/postRoutes.ts | 91 +++++-- code/backend/src/routes/profileRoutes.ts | 128 ++++++++++ code/backend/src/routes/userRoutes.ts | 30 +-- code/backend/src/schemas/feedSchemas.ts | 15 ++ code/backend/src/server.ts | 10 +- code/backend/src/types/express/index.d.ts | 10 + code/backend/tsconfig.json | 3 +- code/backend/yarn.lock | 9 +- .../frontend/src/pages/LoginAndSignUpPage.tsx | 2 +- 22 files changed, 952 insertions(+), 110 deletions(-) create mode 100644 code/backend/src/controllers/feedController.ts create mode 100644 code/backend/src/controllers/followerController.ts create mode 100644 code/backend/src/controllers/profileController.ts create mode 100644 code/backend/src/middleware/optionalAuthenticateToken.ts rename code/backend/src/middleware/{fileUpload.ts => uploadMultiple.ts} (89%) create mode 100644 code/backend/src/middleware/uploadSingle.ts create mode 100644 code/backend/src/routes/feedRoutes.ts create mode 100644 code/backend/src/routes/followerRoutes.ts create mode 100644 code/backend/src/routes/profileRoutes.ts create mode 100644 code/backend/src/schemas/feedSchemas.ts create mode 100644 code/backend/src/types/express/index.d.ts diff --git a/code/backend/package.json b/code/backend/package.json index 049d92c..c8e0433 100644 --- a/code/backend/package.json +++ b/code/backend/package.json @@ -20,7 +20,6 @@ }, "devDependencies": { "@prisma/client": "^6.9.0", - "@types/bcryptjs": "^3.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", diff --git a/code/backend/prisma/schema.prisma b/code/backend/prisma/schema.prisma index 39d8f4f..486bbc5 100644 --- a/code/backend/prisma/schema.prisma +++ b/code/backend/prisma/schema.prisma @@ -89,7 +89,7 @@ model Comment { } model Like { - id String @id @default(uuid()) + @@id([postId, userId]) createdAt DateTime @default(now()) post Post @relation(fields: [postId], references: [id]) diff --git a/code/backend/src/controllers/feedController.ts b/code/backend/src/controllers/feedController.ts new file mode 100644 index 0000000..d202f7f --- /dev/null +++ b/code/backend/src/controllers/feedController.ts @@ -0,0 +1,31 @@ +import express, { Request, Response } from "express"; +import { StatusCodes } from "http-status-codes"; +import { PrismaClient, Post } from "../../prisma/app/generated/prisma/client"; +import { feedQuerySchema } from "../schemas/feedSchemas"; +const prisma = new PrismaClient(); +export const feed = async (req: Request, res: Response) => { + try { + const query = feedQuerySchema.parse(req.query); + const take = query.limit || 10; + + const posts = await prisma.post.findMany({ + take: take + 1, + where: { + status: "PUBLIC", + ...(query.createdAt && { createdAt: { lt: query.createdAt } }), + }, + orderBy: { createdAt: "desc" }, + select: { id: true, createdAt: true, description: true }, + }); + + const hasMore = posts.length > take; + if (hasMore) posts.pop(); + + const nextCursor = hasMore ? posts[posts.length - 1].createdAt : null; + + res.status(200).json({ posts, nextCursor }); + } catch (err) { + console.error(err); + res.status(400).json({ error: "Invalid query parameters" }); + } +}; diff --git a/code/backend/src/controllers/followerController.ts b/code/backend/src/controllers/followerController.ts new file mode 100644 index 0000000..bab2056 --- /dev/null +++ b/code/backend/src/controllers/followerController.ts @@ -0,0 +1,124 @@ +import { StatusCodes } from "http-status-codes"; +import express, { Request, Response } from "express"; +import { JwtPayload } from "jsonwebtoken"; +import { PrismaClient, Follow } from "../../prisma/app/generated/prisma/client"; +const prisma = new PrismaClient(); +export const followUser = async (req: Request, res: Response) => { + const username: string = req.params.username; + const followingUser: JwtPayload = req.user!; + if (!username) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no username", + details: [{ message: "Username is required" }], + }); + return; + } + try { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "User not found", + details: [ + { message: `User with username '${username}' does not exist.` }, + ], + }); + return; + } + if (user.username == username) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "Bad Request", + details: [{ message: "You can`t follow yourself" }], + }); + return; + } + const alreadyFollowing = await prisma.follow.findUnique({ + where: { + followingUserId_followedUserId: { + followingUserId: followingUser.sub!, + followedUserId: user.id, + }, + }, + }); + if (alreadyFollowing) { + res.status(StatusCodes.CONFLICT).json({ + error: "Already following", + details: [{ message: "You are already following this User" }], + }); + return; + } + const follow = await prisma.follow.create({ + data: { + followingUserId: followingUser.sub!, + followedUserId: user.id, + }, + }); + res.status(StatusCodes.NO_CONTENT).send(); + return; + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Upload failed", + details: [{ message: "Internal server error" }], + }); + } +}; +export const unfollowUser = async (req: Request, res: Response) => { + const username: string = req.params.username; + const followingUser: JwtPayload = req.user!; + if (!username) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no username", + details: [{ message: "Username is required" }], + }); + return; + } + try { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "User not found", + details: [ + { message: `User with username '${username}' does not exist.` }, + ], + }); + return; + } + const alreadyFollowing = await prisma.follow.findUnique({ + where: { + followingUserId_followedUserId: { + followingUserId: followingUser.sub!, + followedUserId: user.id, + }, + }, + }); + if (!alreadyFollowing) { + res.status(StatusCodes.CONFLICT).json({ + error: "Not following", + details: [{ message: "You are not following this User" }], + }); + return; + } + const follow = await prisma.follow.delete({ + where: { + followingUserId_followedUserId: { + followingUserId: followingUser.sub!, + followedUserId: user.id, + }, + }, + }); + res.status(StatusCodes.NO_CONTENT).send(); + return; + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Upload failed", + details: [{ message: "Internal server error" }], + }); + } +}; diff --git a/code/backend/src/controllers/postController.ts b/code/backend/src/controllers/postController.ts index 3fd2c5e..6bbb15f 100644 --- a/code/backend/src/controllers/postController.ts +++ b/code/backend/src/controllers/postController.ts @@ -17,12 +17,12 @@ export const uploadPost = async (req: Request, res: Response) => { try { const uploads = await Promise.all( files.map(async (file) => { - const objectName = `${user.sub}/${Date.now()}-${file.originalname}`; + const objectName = `${user.sub}/posts/${Date.now()}-${file.originalname}`; await minioClient.putObject(BUCKET, objectName, file.buffer); const url = await minioClient.presignedGetObject( BUCKET, objectName, - 60 * 5 // 5 Minuten Gültigkeit + 60 * 10 ); return { @@ -76,10 +76,19 @@ export const getPost = async (req: Request, res: Response) => { try { // get the postId from the request const postId: string = req.query.postId as string; - const postObject = await prisma.post.findUnique({ + const user: JwtPayload | undefined = req.user; + const postObject = await prisma.post.findFirst({ // find the post by id where: { id: postId, + ...(user + ? { + OR: [ + { status: "PRIVATE", userId: user.id }, + { status: "PUBLIC" }, + ], + } + : { status: "PUBLIC" }), }, include: { user: true, @@ -107,6 +116,20 @@ export const getPost = async (req: Request, res: Response) => { }); return; } + let isFollowing = false; + if (user) { + const following = await prisma.follow.findUnique({ + where: { + followingUserId_followedUserId: { + followingUserId: user.sub!, + followedUserId: postObject.userId, + }, + }, + }); + if (following) { + isFollowing = true; + } + } const images = await Promise.all( // generate the presigned url for each image postObject?.media.map(async (image) => { @@ -139,7 +162,9 @@ export const getPost = async (req: Request, res: Response) => { createdAt: postObject.createdAt, updatedAt: postObject.updatedAt, images: images.filter((image) => image !== null), // filter out the null images + following: isFollowing, }); + return; } catch (err: any) { if (err.code === "NotFound") { // Handle the case where the object does not exist @@ -151,22 +176,26 @@ export const getPost = async (req: Request, res: Response) => { }, ], }); + return; } console.error(err); res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: "Failed to retrieve post", details: [{ message: "Server error" }], }); + return; } }; // get all posts from a user export const getUserPosts = async (req: Request, res: Response) => { try { - const userId: string = req.query.userId as string; // Get the userId from the request + const username: string = req.params.username; const posts = await prisma.post.findMany({ where: { - userId: userId, + user: { + username: username, // hier greifst du auf die relationierte User-Tabelle zu + }, }, }); if (!posts || posts.length === 0) { @@ -192,3 +221,111 @@ export const getUserPosts = async (req: Request, res: Response) => { return; } }; +export const like = async (req: Request, res: Response) => { + const postId: string = req.params.postId; + const user: JwtPayload = req.user!; + if (!postId) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no username", + details: [{ message: "Username is required" }], + }); + return; + } + try { + const post = await prisma.post.findUnique({ + where: { id: postId }, + }); + + if (!post) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "Post not found", + details: [{ message: `Post with PostId '${postId}' does not exist.` }], + }); + return; + } + const alreadyLiked = await prisma.like.findUnique({ + where: { + postId_userId: { + postId: postId, + userId: user.sub!, + }, + }, + }); + if (alreadyLiked) { + res.status(StatusCodes.CONFLICT).json({ + error: "Already following", + details: [{ message: "You are already following this User" }], + }); + return; + } + const follow = await prisma.like.create({ + data: { + postId: postId, + userId: user.sub!, + }, + }); + res.status(StatusCodes.NO_CONTENT).send(); + return; + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Upload failed", + details: [{ message: "Internal server error" }], + }); + } +}; +export const removeLike = async (req: Request, res: Response) => { + const postId: string = req.params.postId; + const user: JwtPayload = req.user!; + if (!postId) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no postId", + details: [{ message: "postId is required" }], + }); + return; + } + try { + const post = await prisma.post.findUnique({ + where: { id: postId }, + }); + + if (!post) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "Post not found", + details: [{ message: `Post with PostId '${postId}' does not exist.` }], + }); + return; + } + const alreadyLiked = await prisma.like.findUnique({ + where: { + postId_userId: { + postId: postId!, + userId: user.sub!, + }, + }, + }); + if (!alreadyLiked) { + res.status(StatusCodes.CONFLICT).json({ + error: "Already following", + details: [{ message: "You are already following this User" }], + }); + return; + } + const follow = await prisma.like.delete({ + where: { + postId_userId: { + postId: postId, + userId: user.sub!, + }, + }, + }); + res.status(StatusCodes.NO_CONTENT).send(); + return; + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Upload failed", + details: [{ message: "Internal server error" }], + }); + } +}; diff --git a/code/backend/src/controllers/profileController.ts b/code/backend/src/controllers/profileController.ts new file mode 100644 index 0000000..ba88950 --- /dev/null +++ b/code/backend/src/controllers/profileController.ts @@ -0,0 +1,235 @@ +import express, { Request, Response } from "express"; +import { JwtPayload } from "jsonwebtoken"; +import { PrismaClient } from "../../prisma/app/generated/prisma/client"; +import dotenv from "dotenv"; +import { StatusCodes } from "http-status-codes"; +import multer from "multer"; +import { minioClient } from "../server"; + +const app = express(); +app.use(express.json()); +const prisma = new PrismaClient(); +// load environment variables from .env file +dotenv.config(); + +type PublicUser = { + id: string; + username: string; + bio: string | null; + profilePictureUrl: string | null; + followers: number; + following: number; +}; +const getUser: (userId: string) => Promise = async ( + userId: string +) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { profilePicture: true }, + }); + if (user) { + let profilePictureUrl: string | null = null; + if (user.profilePicture) { + profilePictureUrl = await minioClient.presignedGetObject( + user.profilePicture.bucket, + user.profilePicture.objectName, + 60 * 10 + ); + } + const followerCount = await prisma.follow.count({ + where: { + followedUserId: userId, + }, + }); + + const followingCount = await prisma.follow.count({ + where: { + followingUserId: userId, + }, + }); + const publicUser: PublicUser = { + id: user.id, + username: user.username, + bio: user.bio, + profilePictureUrl, + followers: followerCount, + following: followingCount, + }; + return publicUser; + } + return undefined; +}; + +export const uploadProfilePicture = async (req: Request, res: Response) => { + const user: JwtPayload = req.user!; + const file = req.file as Express.Multer.File; + const BUCKET = "images"; // Name of the bucket where the images are stored + try { + const objectName = `${user.sub}/profile.${file.mimetype.split("/")[1]}`; + await minioClient.putObject(BUCKET, objectName, file.buffer); + const url = await minioClient.presignedGetObject( + BUCKET, + objectName, + 60 * 10 + ); + const oldImage = await prisma.user.findUnique({ + where: { id: user.sub }, + select: { profilePictureId: true }, + }); + + const media = await prisma.media.create({ + data: { + originalFilename: file.originalname, + objectName: objectName, + mimeType: file.mimetype, + size: file.size, + bucket: BUCKET, + uploader: { connect: { id: user.sub } }, + }, + }); + + await prisma.user.update({ + where: { id: user.sub }, + data: { + profilePicture: { + connect: { id: media.id }, + }, + }, + }); + + if (oldImage?.profilePictureId && oldImage.profilePictureId !== media.id) { + await prisma.media.delete({ + where: { id: oldImage.profilePictureId }, + }); + } + + res.status(StatusCodes.CREATED).json({ + originalName: file.originalname, + objectName: objectName, + size: file.size, + mimetype: file.mimetype, + url: url, + }); + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Upload failed", + details: [{ message: "Internal server error" }], + }); + } +}; + +export const getProfilePicture = async (req: Request, res: Response) => { + const username: string = req.params.username; + if (!username) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no username", + details: [{ message: "Username is required" }], + }); + return; + } + try { + const user = await prisma.user.findUnique({ + where: { username }, + include: { profilePicture: true }, + }); + + if (!user) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "User not found", + details: [ + { message: `User with username '${username}' does not exist.` }, + ], + }); + return; + } + + if (!user.profilePicture) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "No profile picture", + details: [{ message: "This user does not have a profile picture." }], + }); + return; + } + + const profilePictureUrl = await minioClient.presignedGetObject( + user.profilePicture.bucket, + user.profilePicture.objectName, + 60 * 10 // 10 minutes expiration + ); + + res.status(StatusCodes.OK).json({ url: profilePictureUrl }); + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Retrieving image failed", + details: ["Internal server error"], + }); + return; + } +}; + +export const updateBio = async (req: Request, res: Response) => { + const user: JwtPayload = req.user!; + const bio: string = req.body.bio; + try { + const updatedUser = await prisma.user.update({ + where: { id: user.sub }, + data: { + bio: bio, + }, + }); + const publicUser: PublicUser | undefined = await getUser(user.sub!); + res.status(StatusCodes.OK).json({ + message: "User found", + data: publicUser, + }); + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Updating bio failed", + details: [{ message: "Internal Server error" }], + }); + } +}; +// Endpoint to get user data +export const getProfile = async (req: Request, res: Response) => { + const username: string = req.params.username as string; + if (!username) { + res.status(StatusCodes.BAD_REQUEST).json({ + error: "no username", + details: [{ message: "Username is required" }], + }); + return; + } + try { + const user = await prisma.user.findUnique({ + where: { username }, + include: { profilePicture: true }, + }); + + if (!user) { + res.status(StatusCodes.NOT_FOUND).json({ + error: "User not found", + details: [ + { message: `User with username '${username}' does not exist.` }, + ], + }); + return; + } + + const publicUser: PublicUser | undefined = await getUser(user.id); + + res.status(StatusCodes.OK).json({ + message: "User found", + data: publicUser, + }); + } catch (err) { + console.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Retrieving image failed", + details: ["Error while retrieving the image"], + }); + return; + } +}; diff --git a/code/backend/src/controllers/userController.ts b/code/backend/src/controllers/userController.ts index 25fb4c0..52d4dc8 100644 --- a/code/backend/src/controllers/userController.ts +++ b/code/backend/src/controllers/userController.ts @@ -171,37 +171,6 @@ export const loginUser = async (req: Request, res: Response) => { res.status(StatusCodes.OK).json({ message: "User logged in successfully" }); }; -// Endpoint to get user data -export const getUser = async (req: Request, res: Response) => { - const username: string = req.params.username as string; - if (!username) { - res.status(StatusCodes.BAD_REQUEST).json({ - error: "no username", - details: [{ message: "Username is required" }], - }); - return; - } - const user: User | null = await prisma.user.findUnique({ - where: { - username: username, - }, - }); - if (!user) { - res.status(StatusCodes.NOT_FOUND).json({ - error: "user not found", - details: [{ message: `User "${username}" not found` }], - }); - return; - } - res.json({ - message: "User found", - data: { - username: user.username, - userId: user.id, - bio: user.bio, - }, - }); -}; export const refreshToken = async (req: Request, res: Response) => { const refreshToken: string | undefined = req.headers[ "refresh-token" diff --git a/code/backend/src/middleware/authenticateToken.ts b/code/backend/src/middleware/authenticateToken.ts index 37a4be5..55984d6 100644 --- a/code/backend/src/middleware/authenticateToken.ts +++ b/code/backend/src/middleware/authenticateToken.ts @@ -9,17 +9,6 @@ dotenv.config(); const JWT_SECRET: string = process.env.TOKEN_SECRET!; if (!JWT_SECRET) console.error("No JWT secret provided"); - - -// Extend the Express Request interface to include the user property -declare global { - namespace Express { - interface Request { - user?: JwtPayload; - } - } -} - // Middleware function to authenticate the JWT token export function authenticateToken() { return (req: Request, res: Response, next: NextFunction) => { diff --git a/code/backend/src/middleware/optionalAuthenticateToken.ts b/code/backend/src/middleware/optionalAuthenticateToken.ts new file mode 100644 index 0000000..8c3777c --- /dev/null +++ b/code/backend/src/middleware/optionalAuthenticateToken.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { JwtPayload } from "../types/tokens"; +const JWT_SECRET: string = process.env.TOKEN_SECRET!; + +export const optionalAuthenticateToken = ( + req: Request, + res: Response, + next: NextFunction +) => { + const authHeader = req.headers["authorization"]; // Get the authorization header + const token = authHeader && authHeader.split(" ")[1]; // Extract the token from the "Bearer " format + + if (!token) { + return next(); + } + + jwt.verify(token!, JWT_SECRET, (err, user) => { + if (err) { + return next(); + } + req.user = user as JwtPayload; + next(); + }); +}; diff --git a/code/backend/src/middleware/fileUpload.ts b/code/backend/src/middleware/uploadMultiple.ts similarity index 89% rename from code/backend/src/middleware/fileUpload.ts rename to code/backend/src/middleware/uploadMultiple.ts index 1e5cabd..9f77e67 100644 --- a/code/backend/src/middleware/fileUpload.ts +++ b/code/backend/src/middleware/uploadMultiple.ts @@ -1,5 +1,5 @@ import { StatusCodes } from "http-status-codes"; -import multer, { MulterError } from "multer"; +import multer from "multer"; import { Request, Response, NextFunction } from "express"; // Configure multer to store files in memory @@ -35,11 +35,7 @@ export const upload = (req: Request, res: Response, next: NextFunction) => { } for (const file of files) { //check for correct filetypes - if ( - file.mimetype !== "image/jpeg" && - file.mimetype !== "image/png" && - file.mimetype !== "image/webp" - ) { + if (["jpeg", "png", "webp"].includes(file.mimetype)) { return res.status(StatusCodes.BAD_REQUEST).json({ error: "Invalid file type", details: [ diff --git a/code/backend/src/middleware/uploadSingle.ts b/code/backend/src/middleware/uploadSingle.ts new file mode 100644 index 0000000..d652239 --- /dev/null +++ b/code/backend/src/middleware/uploadSingle.ts @@ -0,0 +1,42 @@ +import { Request, Response, NextFunction } from "express"; +import multer from "multer"; +import { StatusCodes } from "http-status-codes"; + +const multerInstance = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB +}); + +export const upload = (req: Request, res: Response, next: NextFunction) => { + multerInstance.single("image")(req, res, (err: any) => { + if (err) { + console.error(err); + return res + .status(StatusCodes.BAD_REQUEST) + .json({ error: err.name, details: [err.message] }); + } + + if (!req.file) { + return res.status(StatusCodes.BAD_REQUEST).json({ + error: "No file uploaded", + details: [{ message: "Please upload a file" }], + }); + } + + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(req.file.mimetype)) { + return res.status(StatusCodes.BAD_REQUEST).json({ + error: "Invalid file type", + details: [ + { + message: + "Only .jpeg, .png, or .webp files are allowed. Invalid: " + + req.file.originalname, + }, + ], + }); + } + + next(); + }); +}; diff --git a/code/backend/src/routes/feedRoutes.ts b/code/backend/src/routes/feedRoutes.ts new file mode 100644 index 0000000..c835498 --- /dev/null +++ b/code/backend/src/routes/feedRoutes.ts @@ -0,0 +1,57 @@ +import express from "express"; +import { feed } from "../controllers/feedController"; +const feedRouter = express.Router(); +/** + * @swagger + * /api/feed: + * get: + * summary: Get a paginated feed of public posts + * description: Returns public posts sorted by creation date descending with cursor pagination. + * tags: + * - Feed + * parameters: + * - in: query + * name: createdAt + * schema: + * type: string + * format: date-time + * required: false + * description: Cursor for pagination, ISO timestamp of last post from previous page (only fetch posts created before this date) + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * required: false + * description: Number of posts to fetch + * responses: + * 200: + * description: List of posts with pagination cursor + * content: + * application/json: + * schema: + * type: object + * properties: + * posts: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * format: uuid + * createdAt: + * type: string + * format: date-time + * description: + * type: string + * nextCursor: + * type: string + * format: uuid + * nullable: true + * description: Cursor for the next page or null if no more posts + * 500: + * description: Server error + */ +feedRouter.get("/", feed); +export default feedRouter; diff --git a/code/backend/src/routes/followerRoutes.ts b/code/backend/src/routes/followerRoutes.ts new file mode 100644 index 0000000..0d6fe2e --- /dev/null +++ b/code/backend/src/routes/followerRoutes.ts @@ -0,0 +1,50 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authenticateToken"; +import { followUser, unfollowUser } from "../controllers/followerController"; +const followerRouter = express.Router(); +/** + * @swagger + * /api/follower/follow/{username}: + * post: + * summary: follow a User + * tags: [User] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Login erfolgreich + * 401: + * description: Ungültige Anmeldedaten + */ +followerRouter.post("/follow/:username", authenticateToken(), followUser); + +/** + * @swagger + * /api/follower/unfollow/{username}: + * delete: + * summary: follow a User + * tags: [User] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Login erfolgreich + * 401: + * description: Ungültige Anmeldedaten + */ +followerRouter.delete("/unfollow/:username", authenticateToken(), unfollowUser); +export default followerRouter; diff --git a/code/backend/src/routes/postRoutes.ts b/code/backend/src/routes/postRoutes.ts index 3609b70..e9556bb 100644 --- a/code/backend/src/routes/postRoutes.ts +++ b/code/backend/src/routes/postRoutes.ts @@ -2,12 +2,16 @@ import express from "express"; import { getPost, getUserPosts, + like, + removeLike, uploadPost as uploadPost, } from "../controllers/postController"; -import { upload } from "../middleware/fileUpload"; +import { upload } from "../middleware/uploadMultiple"; import { validateData } from "../middleware/validationMiddleware"; import { uploadPostSchema } from "../schemas/postSchemas"; +import { authenticateToken } from "../middleware/authenticateToken"; +import { optionalAuthenticateToken } from "../middleware/optionalAuthenticateToken"; const router = express.Router(); /** @@ -16,7 +20,7 @@ const router = express.Router(); * post: * summary: Upload multiple images with metadata * tags: - * - posts + * - Posts * requestBody: * required: true * content: @@ -57,13 +61,19 @@ const router = express.Router(); * 200: * description: Images uploaded successfully */ -router.post("/upload", upload, validateData(uploadPostSchema), uploadPost); +router.post( + "/upload", + authenticateToken(), + upload, + validateData(uploadPostSchema), + uploadPost +); /** * @swagger * /api/posts/getPost/{postId}: * get: * summary: Get Post - * tags: [posts] + * tags: [Posts] * security: * - bearerAuth: [] * parameters: @@ -76,23 +86,74 @@ router.post("/upload", upload, validateData(uploadPostSchema), uploadPost); * responses: * 200: * description: Ok - * 401: - * description: not authenticated + * 404: + * description: not found */ -router.get("/getPost/:postId", getPost); +router.get("/getPost/:postId", optionalAuthenticateToken, getPost); /** * @swagger - * /api/posts/getUserPosts/: + * /api/posts/getUserPosts/{username}: * get: - * summary: Get Post - * tags: [posts] - * security: - * - bearerAuth: [] + * summary: Get Posts from user + * tags: [Posts] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll * responses: * 200: * description: Ok - * 401: - * description: not authenticated + * 404: + * description: not found */ -router.get("/getuserposts/", getUserPosts); +router.get("/getuserposts/:username", optionalAuthenticateToken, getUserPosts); +/** + * @swagger + * /api/posts/like/{postId}: + * post: + * summary: follow a User + * tags: [Posts] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: postId + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Login erfolgreich + * 401: + * description: Ungültige Anmeldedaten + */ +router.post("/like/:postId", authenticateToken(), like); + +/** + * @swagger + * /api/posts/removeLike/{postId}: + * delete: + * summary: follow a User + * tags: [Posts] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: postId + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Login erfolgreich + * 401: + * description: Ungültige Anmeldedaten + */ +router.delete("/removeLike/:postId", authenticateToken(), removeLike); + export default router; diff --git a/code/backend/src/routes/profileRoutes.ts b/code/backend/src/routes/profileRoutes.ts new file mode 100644 index 0000000..ffc8fc2 --- /dev/null +++ b/code/backend/src/routes/profileRoutes.ts @@ -0,0 +1,128 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authenticateToken"; +import { + getProfile, + getProfilePicture, + updateBio, + uploadProfilePicture, +} from "../controllers/profileController"; +const profileRouter = express.Router(); +import { upload } from "../middleware/uploadSingle"; +import { updateBioSchema } from "../schemas/feedSchemas"; +import { validateData } from "../middleware/validationMiddleware"; +/** + * @swagger + * /api/profile/uploadProfilePicture: + * post: + * summary: Upload profile picture + * description: Uploads a profile picture for the current user + * tags: + * - Profile + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: string + * format: binary + * description: File to upload + * responses: + * 201: + * description: Image uploaded successfully + * 400: + * description: Invalid file or request + * 401: + * description: Unauthorized + */ +profileRouter.post( + "/uploadProfilePicture", + authenticateToken(), + upload, + uploadProfilePicture +); +/** + * @swagger + * /api/profile/getProfilePicture/{username}: + * get: + * summary: Get Profilepicture + * tags: + * - Profile + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Ok + * 401: + * description: not authenticated + */ +profileRouter.get("/getProfilePicture/:username", getProfilePicture); +/** + * @swagger + * /api/profile/updateBio: + * post: + * summary: Update user bio + * description: Updates the bio (short description) of the currently authenticated user. + * tags: + * - Profile + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * bio: + * type: string + * description: New bio text for the user + * example: "When you program open source, you're programming COMMUNISM☭" + * responses: + * 200: + * description: Bio updated successfully + * 400: + * description: Invalid request + * 401: + * description: Unauthorized + */ +profileRouter.post( + "/updateBio", + authenticateToken(), + validateData(updateBioSchema), + updateBio +); +/** + * @swagger + * /api/profile/get/{username}: + * get: + * summary: Get user data + * tags: [Profile] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: Der Benutzername, nach dem gesucht werden soll + * responses: + * 200: + * description: Login erfolgreich + * 401: + * description: Ungültige Anmeldedaten + */ +profileRouter.get("/get/:username", getProfile); +export default profileRouter; diff --git a/code/backend/src/routes/userRoutes.ts b/code/backend/src/routes/userRoutes.ts index b8ced4c..29b6489 100644 --- a/code/backend/src/routes/userRoutes.ts +++ b/code/backend/src/routes/userRoutes.ts @@ -9,11 +9,7 @@ import { authenticateToken } from "../middleware/authenticateToken"; import { logout, refreshToken } from "../controllers/userController"; const userRouter = express.Router(); -import { - registerUser, - loginUser, - getUser, -} from "../controllers/userController"; +import { registerUser, loginUser } from "../controllers/userController"; /** * @swagger * components: @@ -87,28 +83,7 @@ userRouter.post( * description: Ungültige Anmeldedaten */ userRouter.post("/login", validateData(userLoginSchema), loginUser); -/** - * @swagger - * /api/user/getUser/{username}: - * get: - * summary: Get user data - * tags: [User] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: username - * required: true - * schema: - * type: string - * description: Der Benutzername, nach dem gesucht werden soll - * responses: - * 200: - * description: Login erfolgreich - * 401: - * description: Ungültige Anmeldedaten - */ -userRouter.get("/getUser/:username", authenticateToken(), getUser); + /** * @swagger * /api/user/refreshToken: @@ -162,4 +137,5 @@ userRouter.get("/refreshToken", refreshToken); * description: not authenticated */ userRouter.delete("/logout", authenticateToken(), logout); + export default userRouter; diff --git a/code/backend/src/schemas/feedSchemas.ts b/code/backend/src/schemas/feedSchemas.ts new file mode 100644 index 0000000..4c21275 --- /dev/null +++ b/code/backend/src/schemas/feedSchemas.ts @@ -0,0 +1,15 @@ +import { string, z } from "zod"; + +export const feedQuerySchema = z.object({ + createdAt: z.string().datetime().optional(), + limit: z + .string() + .transform((val) => parseInt(val, 10)) + .refine((num) => num > 0 && num <= 30, { + message: "Limit must be between 1 and 30", + }) + .optional(), +}); +export const updateBioSchema = z.object({ + bio: z.string(), +}); diff --git a/code/backend/src/server.ts b/code/backend/src/server.ts index f73c516..6c33d86 100644 --- a/code/backend/src/server.ts +++ b/code/backend/src/server.ts @@ -3,7 +3,8 @@ import { Client } from "minio"; import dotenv from "dotenv"; import userRouter from "./routes/userRoutes"; import postRouter from "./routes/postRoutes"; -import { authenticateToken } from "./middleware/authenticateToken"; +import profileRouter from "./routes/profileRoutes"; +import followerRouter from "./routes/followerRoutes"; import bodyParser from "body-parser"; import cors from "cors"; dotenv.config(); @@ -30,6 +31,7 @@ export const minioClient = new Client({ import swaggerJSDoc from "swagger-jsdoc"; import swaggerUi from "swagger-ui-express"; import { deleteExpiredTokens } from "./tasks/deleteTokens"; +import feedRouter from "./routes/feedRoutes"; const options = { definition: { @@ -75,8 +77,10 @@ setInterval( ); app.use(bodyParser.json()); app.use("/api/user", userRouter); -app.use("/api/posts", authenticateToken(), postRouter); - +app.use("/api/posts", postRouter); +app.use("/api/profile", profileRouter); +app.use("/api/feed", feedRouter); +app.use("/api/follower/", followerRouter); app.listen(port, () => { console.log(`Server läuft auf http://localhost:${port}`); }); diff --git a/code/backend/src/types/express/index.d.ts b/code/backend/src/types/express/index.d.ts new file mode 100644 index 0000000..5974984 --- /dev/null +++ b/code/backend/src/types/express/index.d.ts @@ -0,0 +1,10 @@ +import { JwtPayload } from "../tokens"; + +export {}; +declare global { + namespace Express { + interface Request { + user?: JwtPayload; + } + } +} diff --git a/code/backend/tsconfig.json b/code/backend/tsconfig.json index 4671c02..644fef9 100644 --- a/code/backend/tsconfig.json +++ b/code/backend/tsconfig.json @@ -7,7 +7,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", - "resolveJsonModule": true // Allow importing JSON files + "typeRoots": ["./src/types", "./node_modules/@types"], + "resolveJsonModule": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] diff --git a/code/backend/yarn.lock b/code/backend/yarn.lock index 7f117bc..d30a12a 100644 --- a/code/backend/yarn.lock +++ b/code/backend/yarn.lock @@ -137,13 +137,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/bcryptjs@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-3.0.0.tgz#d7be11653aa82cf17ffee4f3925f1f80cfc1add2" - integrity sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg== - dependencies: - bcryptjs "*" - "@types/body-parser@*": version "1.19.5" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" @@ -347,7 +340,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -bcryptjs@*, bcryptjs@^3.0.2: +bcryptjs@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-3.0.2.tgz#caadcca1afefe372ed6e20f86db8e8546361c1ca" integrity sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog== diff --git a/code/frontend/src/pages/LoginAndSignUpPage.tsx b/code/frontend/src/pages/LoginAndSignUpPage.tsx index e0512c0..29dcbf0 100644 --- a/code/frontend/src/pages/LoginAndSignUpPage.tsx +++ b/code/frontend/src/pages/LoginAndSignUpPage.tsx @@ -31,7 +31,7 @@ function LoginAndSignUpPage({ signupProp }: { signupProp: boolean }) { const toggleLogin = (event: React.MouseEvent) => { event.preventDefault(); setErrorMessages(undefined); - setSignup(!signup); + signup ? navigate("/login") : navigate("/register"); }; const [formData, setFormData] = useState({ username: "",