From 9b25fe7150d8249006b8bddd292d1a5d9eaf535d Mon Sep 17 00:00:00 2001 From: Benjamin Lefebvre Date: Fri, 30 May 2025 11:10:38 -0400 Subject: [PATCH 1/7] S3 Storage --- .gitignore | 5 +- backend/package.json | 4 +- backend/src/app.ts | 86 ++++-- .../content/controllers/content.controller.ts | 37 ++- .../storage/services/storage.service.ts | 275 ++++++++++++----- .../user/controllers/user.controller.ts | 6 +- frontend/src/App.tsx | 3 +- frontend/src/pages/content/ContentEditor.tsx | 283 ++++++------------ frontend/src/pages/content/ContentView.tsx | 89 +++--- 9 files changed, 444 insertions(+), 344 deletions(-) diff --git a/.gitignore b/.gitignore index b08783d..3e40ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -185,4 +185,7 @@ backend/node_modules **/node_modules -package-lock.json \ No newline at end of file +package-lock.json + + +local_uploads \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 890d5e7..a8db91e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.18", - "@types/express": "^4.17.1", + "@types/express": "^4.17.22", "@types/formidable": "^3.4.5", "@types/jest": "^29.5.14", "@types/ms": "^2.1.0", @@ -32,6 +32,8 @@ }, "dependencies": { "@algolia/client-search": "^5.20.4", + "@aws-sdk/client-s3": "^3.816.0", + "@aws-sdk/s3-request-presigner": "^3.816.0", "@types/jsonwebtoken": "^9.0.7", "algoliasearch": "^4.24.0", "axios": "^1.7.7", diff --git a/backend/src/app.ts b/backend/src/app.ts index 2ea2af8..0d29d57 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,23 +1,23 @@ -import express from 'express'; -import rateLimit from 'express-rate-limit'; -import { appConfig } from './app.config'; -import { errorHandler } from './shared/middleware/error.middleware'; -import { stripeWebhookMiddleware } from './shared/middleware/stripe.middleware'; -import { logger } from './shared/utils/logger'; -import { createErrorResponse } from './shared/utils/response'; +import express from "express"; +import rateLimit from "express-rate-limit"; +import { appConfig } from "./app.config"; +import { errorHandler } from "./shared/middleware/error.middleware"; +import { stripeWebhookMiddleware } from "./shared/middleware/stripe.middleware"; +import { logger } from "./shared/utils/logger"; +import { createErrorResponse } from "./shared/utils/response"; +import path from "path"; // Import routes -import userRoutes from './modules/user/routes/user.routes'; -import commentRoutes from './modules/comment/routes/comment.routes'; -import contentRoutes from './modules/content/routes/content.routes'; -import subscriptionRoutes from './modules/subscription/routes/subscription.routes'; -import notificationRoutes from './modules/notification/routes/notification.routes'; -import oauthRoutes from './modules/user/routes/oauth.routes'; -import webhookRoutes from './modules/subscription/routes/webhook.routes'; -import searchRoutes from './modules/search/routes/search.routes'; +import userRoutes from "./modules/user/routes/user.routes"; +import commentRoutes from "./modules/comment/routes/comment.routes"; +import contentRoutes from "./modules/content/routes/content.routes"; +import subscriptionRoutes from "./modules/subscription/routes/subscription.routes"; +import notificationRoutes from "./modules/notification/routes/notification.routes"; +import oauthRoutes from "./modules/user/routes/oauth.routes"; +import webhookRoutes from "./modules/subscription/routes/webhook.routes"; +import searchRoutes from "./modules/search/routes/search.routes"; // import summarizationRoutes from './modules/ai/routes/summarization.routes'; - const app = express(); // Apply basic middleware @@ -39,37 +39,59 @@ app.use((req, res, next) => { const limiter = rateLimit({ ...appConfig.rateLimiting, handler: (req, res) => { - res.status(429).json( - createErrorResponse('Too many requests, please try again later') - ); + res + .status(429) + .json(createErrorResponse("Too many requests, please try again later")); }, }); app.use(limiter); // Health check endpoint -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); +app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); }); // API routes -app.use('/user', userRoutes); -app.use('/comment', commentRoutes); -app.use('/content', contentRoutes); -app.use('/subscription', subscriptionRoutes); -app.use('/notification', notificationRoutes); -app.use('/oauth', oauthRoutes); -app.use('/webhook', webhookRoutes); -app.use('/search', searchRoutes); +app.use("/user", userRoutes); +app.use("/comment", commentRoutes); +app.use("/content", contentRoutes); +app.use("/subscription", subscriptionRoutes); +app.use("/notification", notificationRoutes); +app.use("/oauth", oauthRoutes); +app.use("/webhook", webhookRoutes); +app.use("/search", searchRoutes); // app.use('/ai', summarizationRoutes); -app.get('/', (_, res) => { - res.send('Server is Listening!'); +const allowedOrigins = ["http://localhost:5173", "http://localhost:3001"]; + +app.use( + "/local_uploads", + (req, res, next) => { + const origin = req.headers.origin; + if (origin && allowedOrigins.includes(origin)) { + res.header("Access-Control-Allow-Origin", origin); + res.header("Vary", "Origin"); + res.header("Access-Control-Allow-Credentials", "true"); + res.header("Access-Control-Allow-Methods", "GET,OPTIONS"); + res.header("Access-Control-Allow-Headers", "Content-Type,Authorization"); + if (req.method === "OPTIONS") { + return res.sendStatus(200); + } + } + // If no Origin header, do NOT set Access-Control-Allow-Origin at all + next(); + }, + express.static(path.join(process.cwd(), "local_uploads")) +); + +app.get("/", (_, res) => { + res.send("Server is Listening!"); }); // 404 handler app.use((req, res) => { res.status(404).json({ - error: `Cannot ${req.method} ${req.path}` + error: `Cannot ${req.method} ${req.path}`, }); }); diff --git a/backend/src/modules/content/controllers/content.controller.ts b/backend/src/modules/content/controllers/content.controller.ts index df0f36b..7915431 100644 --- a/backend/src/modules/content/controllers/content.controller.ts +++ b/backend/src/modules/content/controllers/content.controller.ts @@ -20,14 +20,12 @@ export class ContentController { console.log(error); res .status(500) - .json({ error: error as string || "Failed to create content" }); + .json({ error: (error as string) || "Failed to create content" }); } } static async uploadThumbnail(req: Request, res: Response) { console.log("Uploading Thumbnail..."); - console.log(req.body); - console.log(req.params); const form = new IncomingForm(); form.parse(req, async (err, fields, files: any) => { if (err) { @@ -35,25 +33,38 @@ export class ContentController { return res.status(500).json({ error: "Failed to upload thumbnail." }); } + // Check if files.thumbnail exists and is an array + if ( + !files.thumbnail || + !Array.isArray(files.thumbnail) || + !files.thumbnail[0] + ) { + return res.status(400).json({ error: "No file uploaded." }); + } + const file = files.thumbnail[0]; const fileName = file.newFilename; const fileType = file.mimetype; + const filePath = file.filepath; // Use 'filepath' for formidable v2+ - // Upload thumbnail to storage try { - // Upload thumbnail + if (!filePath) { + return res.status(400).json({ error: "File path is missing." }); + } + + // Pass the file object with the correct path to StorageService const response = await StorageService.uploadFile( - file, + { ...file, path: filePath }, // Ensure 'path' is set if your StorageService expects it "thumbnails", fileName, - fileType + fileType, ); res.status(201).json(response); } catch (error: any) { console.log(error); res .status(500) - .json({ error: error as string || "Failed to upload thumbnail" }); + .json({ error: error.message || "Failed to upload thumbnail" }); } }); } @@ -82,7 +93,7 @@ export class ContentController { // const confirmation = await axios.get(`${apiURL}/content/${contentId}`) const confirmation = await ContentService.getContent(contentId); const owner_id = confirmation?.creatorUID; - + if (userId == owner_id) { //check whether they are allowed to edit the content const response = await ContentService.editContent(contentId, data); @@ -137,11 +148,15 @@ export class ContentController { const fileType = file.mimetype; try { + if (!file) { + return res.status(400).json({ error: "No file uploaded." }); + } + const response = await StorageService.uploadFile( file, "thumbnails", fileName, - fileType + fileType, ); const updateData = JSON.parse(fields.data); @@ -433,7 +448,7 @@ export class ContentController { static async getRelatedContent(req: Request, res: Response) { console.log("Fetching Related Content..."); const { contentId } = req.params; - const userId = req.query.userId as string || undefined; + const userId = (req.query.userId as string) || undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : 5; try { diff --git a/backend/src/modules/storage/services/storage.service.ts b/backend/src/modules/storage/services/storage.service.ts index e40cab7..6dee084 100644 --- a/backend/src/modules/storage/services/storage.service.ts +++ b/backend/src/modules/storage/services/storage.service.ts @@ -1,109 +1,250 @@ import { - getDownloadURL, - ref, - uploadBytes, - deleteObject, -} from "firebase/storage"; -import { storage } from "../../../shared/config/firebase.config"; + S3Client, + PutObjectCommand, + DeleteObjectCommand, + GetObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { logger } from "../../../shared/utils/logger"; import fs from "fs/promises"; +import path from "path"; +// Validate required environment variables at startup +const requiredEnv = [ + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_S3_BUCKET", +]; + +const isDev = process.env.NODE_ENV === "development"; +const LOCAL_UPLOAD_DIR = path.resolve(process.cwd(), "local_uploads"); + +const s3Config = { + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}; + +const s3 = new S3Client(s3Config); +const BUCKET = process.env.AWS_S3_BUCKET!; + +for (const envVar of requiredEnv) { + if (!process.env[envVar] && !isDev) { + throw new Error(`Missing required environment variable: ${envVar}`); + } +} + +/** + * StorageService handles file uploads and deletions. + * It supports both local development and production S3 storage. + * + * @description In development mode, files are managed locally in the "local_uploads" directory. + * In production, files are managed in an S3 bucket. + * + * @module StorageService + */ export class StorageService { /** - * @description Uploads a file to Firebase Storage + * Uploads a file to the appropriate storage based on the environment. * - * @param file - * @param filePath - * @param fileName - * @param fileType - * - * @returns - Object containing the download URL of the uploaded file - * @throws - Error if file upload fails + * @param file - The file to upload + * @param filePath - The path where the file should be stored + * @param fileName - The name of the file + * @param fileType - The MIME type of the file (required for S3) + * @returns An object containing the URL of the uploaded file */ static async uploadFile( file: File, filePath: string, fileName: string, fileType: string - ) { - try { - console.log("Uploading File..."); + ): Promise<{ url: string }> { + if (isDev) { + return await StorageService.uploadFileDevelopment( + file, + filePath, + fileName, + fileType + ); + } else { + return await StorageService.uploadFileProduction( + file, + filePath, + fileName, + fileType + ); + } + } - const storageRef = ref(storage, `${filePath}/${fileName}`); - const metadata = { - contentType: fileType, - }; - const fileBuffer = await fs.readFile(file.webkitRelativePath); + /** + * Uploads a file to local storage in development mode. + * + * @param file - The file to upload + * @param filePath - The path where the file should be stored + * @param fileName - The name of the file + * @returns An object containing the URL of the uploaded file + */ + private static async uploadFileDevelopment( + file: any, + filePath: string, + fileName: string, + fileType: string + ): Promise<{ url: string }> { + try { + const dir = path.join(LOCAL_UPLOAD_DIR, filePath); + await fs.mkdir(dir, { recursive: true }); - const snapshot = await uploadBytes(storageRef, fileBuffer, metadata); - logger.info(` - File: ${file} - FileBuffer: ${fileBuffer} - Path: ${filePath} - FileName: ${fileName} - StorageRef: ${storageRef} - `); - logger.info("File uploaded successfully."); - - // Get the download URL - const downloadURL = await getDownloadURL(snapshot.ref); - logger.info("Download URL:", downloadURL); - - return { url: downloadURL }; - } catch (error: any) { - let errorMessage = error.message; + let fileBuffer: Buffer; - // Remove "Firebase: " prefix from the error message - if (errorMessage.startsWith("Firebase: ")) { - errorMessage = errorMessage.replace("Firebase: ", ""); + if (file.filepath) { + fileBuffer = await fs.readFile(file.filepath); + } else if (file.buffer) { + fileBuffer = file.buffer; + } else { + throw new Error("No valid file data found (no filepath or buffer)."); } - throw new Error(errorMessage); + fileName = `${fileName}${fileType ? `.${fileType.split("/")[1]}` : ""}`; + + const fullPath = path.join(dir, fileName); + await fs.writeFile(fullPath, fileBuffer); + logger.info(`File saved locally at ${fullPath}`); + + // Return the backend-accessible URL + const url = `http://localhost:3000/local_uploads/${filePath}/${fileName}`; + return { url }; + } catch (error: any) { + logger.error("Local upload error:", error); + throw new Error(`Local upload failed: ${error.message}`); } } /** + * Uploads a file to S3 in production mode. * - * @description - Deletes a file from Firebase Storage + * @param file - The file to upload + * @param filePath - The path where the file should be stored in S3 + * @param fileName - The name of the file + * @param fileType - The MIME type of the file + * @returns An object containing the URL of the uploaded file + */ + private static async uploadFileProduction( + file: File, + filePath: string, + fileName: string, + fileType: string + ): Promise<{ url: string }> { + try { + const fileBuffer = await fs.readFile(file.webkitRelativePath); + const key = path.posix.join(filePath, fileName); + + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: fileBuffer, + ContentType: fileType, + }) + ); + + // Generate a signed GET URL for the uploaded file + const url = await getSignedUrl( + s3, + new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }), + { expiresIn: 3600 } + ); + + return { url }; + } catch (error: any) { + logger.error("S3 upload error:", error); + throw new Error(`S3 upload failed: ${error.message}`); + } + } + + /** + * Deletes a file from the appropriate storage based on the environment. * - * @param filePath - Path to the file in Firebase Storage (can be a URL) - * @returns - void - * @throws - Error if file path is not provided + * @param filePath - The path of the file to delete + * @throws Error if the file path is not provided or if deletion fails */ static async deleteFile(filePath: string) { - if (!filePath) { - throw new Error("File path is required."); - } + if (!filePath) throw new Error("File path is required."); - if (filePath.includes("https://firebasestorage.googleapis.com")) { - filePath = await StorageService.extractFilePathFromUrl(filePath); + if (isDev) { + await StorageService.deleteFileDevelopment(filePath); + } else { + await StorageService.deleteFileProduction(filePath); } + } - const fileRef = ref(storage, `${filePath}`); - + /** + * Deletes a file from local storage in development mode. + * + * @param filePath - The path of the file to delete + * @throws Error if deletion fails + */ + private static async deleteFileDevelopment(filePath: string) { try { - await deleteObject(fileRef); - logger.info(`File ${filePath} deleted.`); + let localPath = filePath; + if (localPath.startsWith("/local_uploads/")) { + localPath = localPath.replace("/local_uploads/", ""); + } + const fullPath = path.join(LOCAL_UPLOAD_DIR, localPath); + await fs.unlink(fullPath); + logger.info(`Local file ${fullPath} deleted.`); } catch (error: any) { - logger.error(`Error deleting file ${filePath}: `, error); - + logger.error(`Error deleting local file: `, error); + throw new Error(`Local delete failed: ${error.message}`); } } /** - * Extracts the file path from a Firebase Storage URL. + * Deletes a file from S3 in production mode. * - * @param url - The full URL of the file in Firebase Storage. - * @returns The file path to be used with Firebase Storage methods. + * @param filePath - The path of the file to delete + * @throws Error if deletion fails */ - static async extractFilePathFromUrl(url: string): Promise { - const decodedUrl = decodeURIComponent(url); - const match = decodedUrl.match(/\/o\/(.*?)\?/); - if (match && match[1]) { - return match[1]; + private static async deleteFileProduction(filePath: string) { + let key = filePath; + if (filePath.startsWith("http")) { + key = StorageService.extractFilePathFromUrl(filePath); + } + try { + await s3.send( + new DeleteObjectCommand({ + Bucket: BUCKET, + Key: key, + }) + ); + logger.info(`File ${key} deleted from S3.`); + } catch (error: any) { + logger.error(`Error deleting file ${key}: `, error); + throw new Error(`S3 delete failed: ${error.message}`); } - throw new Error("Invalid Firebase Storage URL"); + } + /** + * Extracts the file path from a given S3 URL. + * + * @param url - The S3 URL to extract the file path from + * @returns The extracted file path + * @throws Error if the URL is invalid + */ + static extractFilePathFromUrl(url: string): string { + try { + // Try to parse using URL API + const u = new URL(url); + // Remove leading slash from pathname + return u.pathname.replace(/^\/+/, ""); + } catch { + throw new Error("Invalid S3 URL"); + } } } diff --git a/backend/src/modules/user/controllers/user.controller.ts b/backend/src/modules/user/controllers/user.controller.ts index 2b5e087..1d5341f 100644 --- a/backend/src/modules/user/controllers/user.controller.ts +++ b/backend/src/modules/user/controllers/user.controller.ts @@ -138,12 +138,16 @@ export async function uploadProfileImageController( // Upload profile image to storage try { + if (!file) { + return res.status(400).json({ error: "No file uploaded." }); + } + // Upload profile image const response = await StorageService.uploadFile( file, "profileImage", fileName, - fileType + fileType, ); res.status(201).json(response); } catch (error: any) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b75a82..5cdfb21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,7 +94,8 @@ export default function App() { } /> {/* CONTENT */} - } /> + } /> + } /> } /> {/* PRO */} diff --git a/frontend/src/pages/content/ContentEditor.tsx b/frontend/src/pages/content/ContentEditor.tsx index 6aae906..9653340 100644 --- a/frontend/src/pages/content/ContentEditor.tsx +++ b/frontend/src/pages/content/ContentEditor.tsx @@ -17,23 +17,14 @@ import { apiURL } from "../../scripts/api"; import axios from "axios"; import Toolbar from "../../components/content/toolbar"; -// TODO: Implement Edit/Create Content Logic to adapt this page based on the selected mode. - -export default function ContentEditor() { - // --------------------------------------- - // -------------- Variables -------------- - // --------------------------------------- - // State for Editro and Content +export default function ContentEditor({ isEditMode }: { isEditMode: boolean }) { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [thumbnail, setThumbnail] = useState(null); const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [error, setError] = useState(""); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [isSummarizing, setIsSummarizing] = useState(false); const auth = useAuth(); - const { user } = useAuth(); - const [error, setError] = useState(""); const navigate = useNavigate(); // Initialize Editor @@ -60,9 +51,6 @@ export default function ContentEditor() { // ----------- Event Handlers ------------ // --------------------------------------- useEffect(() => { - // Only proceed if we're in the browser environment - if (typeof window === "undefined") return; - const savedTitle = localStorage.getItem("title"); const savedContent = Cookies.get("content"); @@ -76,6 +64,40 @@ export default function ContentEditor() { } }, [editor]); + // Fetch existing content data if in edit mode + useEffect(() => { + if (isEditMode) { + // Fetch existing content data if in edit mode + const fetchContent = async () => { + try { + const content = await axios.get( + `${apiURL}/content/${window.location.pathname.split("/").pop()}`, + { + withCredentials: true, + } + ); + + if (content.data) { + setTitle(content.data.title); + setContent(content.data.content); + if (editor) { + editor.commands.setContent(content.data.content); + } + if (content.data.thumbnailUrl) { + setThumbnailPreview(content.data.thumbnailUrl); + } + } else { + setError("Failed to load content. Please try again."); + } + } catch { + setError("Failed to load content. Please try again."); + } + }; + + fetchContent(); + } + }, [isEditMode, editor]); + // --------------------------------------- // -------------- Functions -------------- // --------------------------------------- @@ -92,6 +114,7 @@ export default function ContentEditor() { */ const handleThumbnailChange = (e: React.ChangeEvent) => { const file = e.target.files ? e.target.files[0] : null; + if (file && file.type.startsWith("image/")) { setThumbnail(file); setThumbnailPreview(URL.createObjectURL(file)); @@ -103,194 +126,74 @@ export default function ContentEditor() { }; /** - * handleSummarize() -> void - * - * @description - * Handles the summarization of the content using the AI service backend, - * this will set the isSummarizing state to true and then call the backend - * to summarize the content using the API. If the content is not provided, - * it will set an error message and return. - * - * @returns {Promise} + * Handles the submission of the content, including thumbnail upload and notifications. + * Uses POST for create and PUT for update mode. */ - const handleSummarize = async () => { - if (!content) { + const handleSubmit = async () => { + setError(""); + + if (!title || !content) { setError( - "Please add some content before summarizing using our AI service." + "Title and content are required, and were not provided. Please try again." ); return; } - setIsSummarizing(true); try { - const response = await fetch(`${apiURL}/api/v1/summarize`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ input: content }), - }); - - const data = await response.json(); - if (!response.ok) { - setError( - data.error || - "Failed to summarize provided content. Please Try again." + // Prepare content payload + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newContent: Record = { + creatorUID: auth.user!.uid, + title, + content, + }; + + // Handle thumbnail upload if present + if (thumbnail) { + const formData = new FormData(); + formData.append("thumbnail", thumbnail); + const thumbRes = await axios.post( + `${apiURL}/content/uploadThumbnail`, + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + } ); + newContent.thumbnailUrl = thumbRes.data.url; } - if (!editor) { - return; - } - - const formattedSummary = ` -
-

Summary

-
-

${data.summary.output.replace(/\n/g, "

")}

-
-
- `; - - editor.commands.setContent(formattedSummary); - setContent(formattedSummary); - Cookies.set("content", formattedSummary); - } catch (error) { - setError( - `Failed to summarize provided content: ${ - (error as Error).message - }, something went wrong. Please Try again.` - ); - } - - setIsSummarizing(false); - }; - - /** - * handleSubmit() -> void - * - * @description - * Handles the submission of the content, setting { title, content, thumbnail } - * respectively amnd redirecting to the Content page. If the title and content - * are not provided, it will throw an error and set the error state to the current - * error message based on the error thrown. - * - * @returns - */ - function handleSubmit() { - setError(""); - - if (title === "" || content === "") { - setError( - "Title and content are required, and were not provided. Please Try again." - ); - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newContent: Record = { - creatorUID: user!.uid, - title, - content, - }; - - if (thumbnail) { - const formData = new FormData(); - formData.append("thumbnail", thumbnail); - - fetch(`${apiURL}/content/uploadThumbnail`, { - method: "POST", - body: formData, - }) - .then(async (response) => { - const res = await response.json(); - const thumbnailUrl = res.url; - newContent["thumbnailUrl"] = thumbnailUrl; - - return fetch(`${apiURL}/content`, { - method: "POST", + // Determine request method and URL + let response; + if (isEditMode) { + const contentId = window.location.pathname.split("/").pop(); + response = await axios.put( + `${apiURL}/content/${contentId}`, + newContent, + { headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newContent), - }); - }) - .then(async (response) => { - if (response.status === 200 || response.status === 201) { - const followers = user?.followers || []; - for (let i = 0; i < followers.length; i++) { - try { - await axios.post(`${apiURL}/notifications/create`, { - userId: followers[i], - notification: { - userId: user?.uid, - username: user?.username, - type: "followedPost", - textPreview: `"${ - title && title?.length > 30 - ? title.substring(0, 30) + "..." - : title - }"`, - timestamp: Date.now(), - read: false, - }, - }); - } catch (error) { - console.error(`Error sending notifications: ${error}`); - } - } - - Cookies.remove("content"); - localStorage.removeItem("title"); - navigate("/"); - } else { - setError("Failed to create content. Please Try again."); + withCredentials: true, } - }) - .catch((error) => { - console.log(error); - setError("Failed to create content. Please Try again."); + ); + } else { + response = await axios.post(`${apiURL}/content`, newContent, { + headers: { "Content-Type": "application/json" }, + withCredentials: true, }); - } else { - fetch(`${apiURL}/content`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newContent), - }) - .then((response) => response.json()) - .then(async () => { - const followers = user?.followers || []; - - for (let i = 0; i < followers.length; i++) { - try { - await axios.post(`${apiURL}/notifications/create`, { - userId: followers[i], - notification: { - userId: user?.uid, - username: user?.username, - type: "followedPost", - textPreview: `"${ - title && title?.length > 30 - ? title.substring(0, 30) + "..." - : title - }"`, - timestamp: Date.now(), - read: false, - }, - }); - } catch (error) { - console.error(`Error sending notifications: ${error}`); - } - } + } - Cookies.remove("content"); - localStorage.removeItem("title"); - navigate("/"); - }) - .catch((error) => { - console.log(error); - setError("Failed to create content. Please Try again."); - }); + // Notify followers if content creation/update was successful + if (response.status === 200 || response.status === 201) { + Cookies.remove("content"); + localStorage.removeItem("title"); + navigate("/"); + } else { + setError("Failed to save content. Please try again."); + } + } catch (err) { + console.error(err); + setError("Failed to save content. Please try again."); } - } + }; // User must be authenticated to create content if (!auth.isAuthenticated) { @@ -303,7 +206,7 @@ export default function ContentEditor() { return ( <>
-

Create Content

+

{isEditMode ? "Edit" : "Create"} Content

- - diff --git a/frontend/src/pages/content/ContentView.tsx b/frontend/src/pages/content/ContentView.tsx index 3849b20..d1e521a 100644 --- a/frontend/src/pages/content/ContentView.tsx +++ b/frontend/src/pages/content/ContentView.tsx @@ -38,7 +38,7 @@ export default function ContentView() { const { id } = useParams(); // useAuth Hook for Authentication - const { user } = useAuth(); + const auth = useAuth(); // --------------------------------------- // -------------- Variables -------------- @@ -158,30 +158,36 @@ export default function ContentView() { setFormatedContent(sanitized); setLikes(typeof content.likes === "number" ? content.likes : 0); setViews(content.views || 0); - if (user?.uid) { - setIsLiked(content.peopleWhoLiked?.includes(user?.uid) || false); - setIsBookmarked(content.bookmarkedBy?.includes(user?.uid) || false); + if (auth.user?.uid) { + setIsLiked(content.peopleWhoLiked?.includes(auth.user?.uid) || false); + setIsBookmarked( + content.bookmarkedBy?.includes(auth.user?.uid) || false + ); } } - }, [content, user?.uid]); + }, [content, auth.user?.uid]); // Update status content stats useEffect(() => { - if (content?.uid && user?.uid) { - setIsBookmarked(user?.bookmarkedContent?.includes(content.uid) || false); + if (content?.uid && auth.user?.uid) { + setIsBookmarked( + auth.user?.bookmarkedContent?.includes(content.uid) || false + ); setBookmarks(content?.bookmarkedBy?.length || 0); - setIsShared(user?.sharedContent?.includes(content.uid) || false); + setIsShared(auth.user?.sharedContent?.includes(content.uid) || false); setShareCount(content?.shares || 0); - setIsLiked(user?.likedContent?.includes(content.uid) || false); + setIsLiked(auth.user?.likedContent?.includes(content.uid) || false); setLikes(content?.peopleWhoLiked?.length || 0); } if (content?.creatorUID) { - setIsFollowing(user?.following?.includes(content.creatorUID) || false); + setIsFollowing( + auth.user?.following?.includes(content.creatorUID) || false + ); } - }, [content, user]); + }, [content, auth.user]); // --------------------------------------- // -------------- Handlers --------------- @@ -225,8 +231,8 @@ export default function ContentView() { ); } - if (user?.uid) { - navigate(`/profile/${user.uid}`); + if (auth.user?.uid) { + navigate(`/profile/${auth.user.uid}`); } else { navigate("/"); } @@ -243,23 +249,23 @@ export default function ContentView() { */ const handleLike = async () => { try { - if (!user?.uid) { + if (!auth.user?.uid) { console.error("No user ID available"); return; } const action = isLiked ? "unlike" : "like"; - const url = `${apiURL}/content/${id}/${action}/${user.uid}`; + const url = `${apiURL}/content/${id}/${action}/${auth.user.uid}`; const response = await axios.post(url); if (response.status == 200) { - if (!isLiked && user.uid != content?.creatorUID) { + if (!isLiked && auth.user.uid != content?.creatorUID) { try { await axios.post(`${apiURL}/notifications/create`, { userId: content?.creatorUID, notification: { - userId: user.uid, - username: user.username, + userId: auth.user.uid, + username: auth.user.username, type: "like", textPreview: `"${ content?.title && content?.title?.length > 30 @@ -296,13 +302,13 @@ export default function ContentView() { */ const handleBookmark = async () => { try { - if (!user?.uid) { + if (!auth.user?.uid) { console.error("No user ID available"); return; } const action = isBookmarked ? "unbookmark" : "bookmark"; - const url = `${apiURL}/content/${user.uid}/${action}/${id}`; + const url = `${apiURL}/content/${auth.user.uid}/${action}/${id}`; const response = await axios.post(url); if (response.status === 200) { @@ -330,25 +336,25 @@ export default function ContentView() { const handleShare = async () => { try { // Check if the user is logged in via AuthProvider - if (!user?.uid) { + if (!auth.user?.uid) { alert("Please log in to share this article."); return; } const action = isShared ? "unshare" : "share"; - const userId = user.uid; + const userId = auth.user.uid; const shareResponse = await axios.post( `${apiURL}/content/${id}/user/${userId}/${action}` ); if (shareResponse.status == 200) { - if (!isShared && user?.uid != content?.creatorUID) { + if (!isShared && auth.user?.uid != content?.creatorUID) { try { await axios.post(`${apiURL}/notifications/create`, { userId: content?.creatorUID, notification: { - userId: user.uid, - username: user.username, + userId: auth.user.uid, + username: auth.user.username, type: "share", textPreview: `"${ content?.title && content?.title?.length > 30 @@ -366,15 +372,15 @@ export default function ContentView() { } if (!isShared) { - const followers = user.followers || []; + const followers = auth.user.followers || []; for (let i = 0; i < followers.length; i++) { try { await axios.post(`${apiURL}/notifications/create`, { userId: followers[i], notification: { - userId: user.uid, - username: user.username, + userId: auth.user.uid, + username: auth.user.username, type: "followedShare", textPreview: `"${ content?.title && content.title?.length > 30 @@ -412,16 +418,22 @@ export default function ContentView() { */ const handleFollow = async () => { // Check if the user is logged in - if (!user?.uid || !content?.creatorUID) { + if (!auth.user?.uid || !content?.creatorUID) { return; } // Update following status let response: { message: string } | Error; if (isFollowing) { - response = await FollowService.unfollowUser(user.uid, content.creatorUID); + response = await FollowService.unfollowUser( + auth.user.uid, + content.creatorUID + ); } else { - response = await FollowService.followUser(user.uid, content.creatorUID); + response = await FollowService.followUser( + auth.user.uid, + content.creatorUID + ); } // Check if the response is an error @@ -455,15 +467,16 @@ export default function ContentView() { * Redirects user to the edit page for the current content. */ const editContent = () => { - if (content?.creatorUID === user?.uid) navigate(`edit/${content?.uid}`); + if (content?.creatorUID === auth.user?.uid) + navigate(`/content/edit/${content?.uid}`); else throw Error("You cannot edit this content"); }; const isCreator = () => { - if (typeof window !== "undefined") { - return localStorage.getItem("userUID") === content?.creatorUID; - } - return false; + // Check if the user is logged in and if the content has a creator UID + if (!auth.user?.uid || !content?.creatorUID) return false; + // Compare the logged-in user's UID with the content creator's UID + return auth.user.uid === content.creatorUID; }; // -------------------------------------- @@ -595,7 +608,7 @@ export default function ContentView() { )}
- {user && user.uid !== creator?.uid && ( + {auth.user && auth.user.uid !== creator?.uid && (