diff --git a/.gitignore b/.gitignore index fa32295..7374b52 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,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 0f9a1f2..3e2815c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,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", @@ -34,6 +34,8 @@ }, "dependencies": { "@algolia/client-search": "^5.20.4", + "@aws-sdk/client-s3": "^3.816.0", + "@aws-sdk/s3-request-presigner": "^3.816.0", "@google/generative-ai": "^0.24.1", "@langchain/core": "^0.3.57", "@types/jsonwebtoken": "^9.0.7", diff --git a/backend/src/app.config.ts b/backend/src/app.config.ts index 5378fa6..b1fac7f 100644 --- a/backend/src/app.config.ts +++ b/backend/src/app.config.ts @@ -16,7 +16,15 @@ export const appConfig = { // Lower default body-parser limits to mitigate DoS risk; adjust per-route as needed json: json({ limit: "2mb" }), urlencoded: urlencoded({ extended: true, limit: "2mb" }), - helmet: helmet(), + helmet: helmet({ + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "img-src": ["'self'", "http://localhost:3000", "data:"], + }, + }, + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), compression: compression(), cookieParser: cookieParser(), }, diff --git a/backend/src/app.ts b/backend/src/app.ts index 97f1dfe..efee999 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,10 +1,11 @@ -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'; @@ -17,7 +18,6 @@ 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,41 @@ 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.use( + "/local_uploads", + express.static(path.join(process.cwd(), "local_uploads")) +); -app.get('/', (_, res) => { - res.send('Server is Listening!'); +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..4971209 100644 --- a/backend/src/modules/storage/services/storage.service.ts +++ b/backend/src/modules/storage/services/storage.service.ts @@ -1,109 +1,249 @@ 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 absolute file system path + return { url: fullPath }; + } 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/subscription/services/stripe.service.ts b/backend/src/modules/subscription/services/stripe.service.ts index edd6221..15e6337 100644 --- a/backend/src/modules/subscription/services/stripe.service.ts +++ b/backend/src/modules/subscription/services/stripe.service.ts @@ -3,7 +3,7 @@ import dotenv from 'dotenv'; dotenv.config(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { - apiVersion: '2025-04-30.basil', + apiVersion: '2025-05-28.basil', }); /** 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 dc2faa3..c47e93f 100644 --- a/frontend/src/pages/content/ContentEditor.tsx +++ b/frontend/src/pages/content/ContentEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useAuth } from "../../hooks/useAuth"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import Cookies from "js-cookie"; // TipTap (Import) @@ -15,25 +15,18 @@ import BulletList from "@tiptap/extension-bullet-list"; import OrderedList from "@tiptap/extension-ordered-list"; import { apiURL } from "../../scripts/api"; import Toolbar from "../../components/content/toolbar"; +import axios from "axios"; -// 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(); + const { id } = useParams<{ id: string }>(); // Initialize Editor const editor = useEditor({ @@ -59,9 +52,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"); @@ -75,6 +65,37 @@ export default function ContentEditor() { } }, [editor]); + // Fetch existing content data if in edit mode + useEffect(() => { + if (isEditMode) { + const fetchContent = async () => { + try { + const content = await axios.get(`${apiURL}/content/${id}`, { + withCredentials: true, + }); + + if (content.data) { + setTitle(content.data.title); + setContent(content.data.content); + if (editor) { + editor.commands.setContent(content.data.content); + } + if (content.data.thumbnail) { + setThumbnailPreview(content.data.thumbnail); + } + } else { + setError("Failed to load content. Please try again."); + } + } catch { + setError("Failed to load content. Please try again."); + } + }; + + fetchContent(); + } + }, [isEditMode, editor]); + // … + // --------------------------------------- // -------------- Functions -------------- // --------------------------------------- @@ -91,6 +112,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)); @@ -102,152 +124,91 @@ 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} - */ - const handleSummarize = async () => { - if (!content) { - setError( - "Please add some content before summarizing using our AI service." - ); - 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." - ); - } - - 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 + * Handles the submission of the content, including thumbnail upload and notifications. + * Uses POST for create and PUT for update mode. */ - function handleSubmit() { + const handleSubmit = async () => { setError(""); - if (title === "" || content === "") { + if (!title || !content) { setError( - "Title and content are required, and were not provided. Please Try again." + "Title and content are required, and were not provided. Please try again." ); return; } + // Prepare content payload // eslint-disable-next-line @typescript-eslint/no-explicit-any const newContent: Record = { - creatorUID: user!.uid, + creatorUID: auth.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; + try { + // 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; + } - return fetch(`${apiURL}/content`, { - method: "POST", + // Determine request method and URL + if (isEditMode) { + await axios.put( + `${apiURL}/content/${id}`, + newContent, + { headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newContent), - }); - }) - .then(async (response) => { - if (response.status === 200 || response.status === 201) { - 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 { - fetch(`${apiURL}/content`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newContent), - }) - .then((response) => response.json()) - .then(async () => { - Cookies.remove("content"); - localStorage.removeItem("title"); - navigate("/"); - }) - .catch((error) => { - console.log(error); - setError("Failed to create content. Please Try again."); - }); + ); + } else { + await axios.post( + `${apiURL}/content`, + { + ...newContent, + }, + { + headers: { "Content-Type": "application/json" }, + withCredentials: true, + } + ); + } + } catch (error) { + console.error("Error submitting content:", error); + setError( + "An error occurred while submitting the content. Please try again." + ); + } + + // If submission was successful, redirect to the content page + if (!error) { + // Reset local storage and cookies + localStorage.removeItem("title"); + Cookies.remove("content"); + setTitle(""); + setContent(""); + + if (id) { + navigate(`/content/${id}`); + } else { + setError("Content ID not found after submission. Please try again."); + } } - } - // User must be authenticated to create content - if (!auth.isAuthenticated) { - navigate("/authentication/login"); - } + // User must be authenticated to create content + if (!auth.isAuthenticated) { + navigate("/authentication/login"); + } + }; // -------------------------------------- // -------------- Render ---------------- @@ -255,7 +216,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 305f446..22a3b36 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,13 +249,13 @@ 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) { @@ -274,13 +280,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) { @@ -308,13 +314,13 @@ 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}` ); @@ -340,16 +346,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 @@ -383,15 +395,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; }; // -------------------------------------- @@ -523,7 +536,7 @@ export default function ContentView() { )}
- {user && user.uid !== creator?.uid && ( + {auth.user && auth.user.uid !== creator?.uid && (