From c00e18f5ce173a72f88344a956a399cf87b9cc10 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Tue, 17 Feb 2026 13:32:42 +0100 Subject: [PATCH] Implement CDN functionality for image uploads and management in account routes --- .env.example | 10 ++ backend/package.json | 1 + backend/src/lib/CDN.ts | 19 +++ backend/src/routes/account/cdn.ts | 180 +++++++++++++++++++++++++++ backend/src/routes/account/delete.ts | 17 +++ frontend/src/modals/ExamModal.tsx | 160 ++++++++++++++++++++++-- frontend/src/pages/AccountPage.tsx | 173 +++++++++++++++++++++++-- frontend/src/pages/Home.tsx | 48 ++++++- 8 files changed, 585 insertions(+), 23 deletions(-) create mode 100644 .env.example create mode 100644 backend/src/lib/CDN.ts create mode 100644 backend/src/routes/account/cdn.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d820bd --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +COOKIE_DOMAIN=localhost +DB_CONN_STRING=mongodb://localhost:27017/exams +PORT=8000 + +CDN_KEY= +CDN_SECRET= +CDN_BUCKET= +CDN_HOST= +CDN_PORT= +CDN_USE_SSL= diff --git a/backend/package.json b/backend/package.json index 1491899..b49560c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "@types/node": "^25.0.3", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "minio": "^8.0.2", "mongodb": "^7.0.0", "rimraf": "^6.1.2", "rjweb-server": "^9.8.6", diff --git a/backend/src/lib/CDN.ts b/backend/src/lib/CDN.ts new file mode 100644 index 0000000..da275ac --- /dev/null +++ b/backend/src/lib/CDN.ts @@ -0,0 +1,19 @@ +import * as Minio from 'minio'; +import { env } from 'node:process'; + +export const minioClient = new Minio.Client({ + endPoint: env.CDN_HOST!, + port: parseInt(env.CDN_PORT || '443'), + useSSL: env.CDN_USE_SSL === 'true', + accessKey: env.CDN_KEY!, + secretKey: env.CDN_SECRET! +}); + +export const CDN_BUCKET = env.CDN_BUCKET!; + +export function getPublicUrl(filename: string) { + const protocol = env.CDN_USE_SSL === 'true' ? 'https' : 'http'; + const port = (env.CDN_PORT && env.CDN_PORT !== '80' && env.CDN_PORT !== '443') ? `:${env.CDN_PORT}` : ''; + + return `${protocol}://${env.CDN_HOST}${port}/${CDN_BUCKET}/${filename}`; +} diff --git a/backend/src/routes/account/cdn.ts b/backend/src/routes/account/cdn.ts new file mode 100644 index 0000000..ea64d36 --- /dev/null +++ b/backend/src/routes/account/cdn.ts @@ -0,0 +1,180 @@ +import { COOKIENAME, fileRouter } from "../../"; +import { authCheck } from "../../lib/Auth"; +import { minioClient, CDN_BUCKET, getPublicUrl } from "../../lib/CDN"; +import crypto from "crypto"; + +export = new fileRouter.Path("/").http( + "POST", + "/api/account/cdn/upload", + (http) => + http.onRequest(async (ctr) => { + const cookie = ctr.cookies.get(COOKIENAME) || null; + const apiHeader = (ctr.headers && ctr.headers.get) + ? (ctr.headers.get("api-authentication") as string) || null + : null; + const auth = await authCheck(cookie, apiHeader); + + if (!auth.state) { + return ctr + .status(ctr.$status.UNAUTHORIZED) + .print({ error: auth.message }); + } + + const [data, error] = await ctr.bindBody((z) => + z.object({ + images: z.array(z.object({ + image: z.string().min(1), // base64 + filename: z.string().min(1).max(200), + contentType: z.string().min(1).max(100), + })).min(1), + }) + ); + + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() }); + + try { + const urls: string[] = []; + const ALLOWED_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf"]); + + for (const item of data.images) { + const buffer = Buffer.from(item.image, 'base64'); + const rawExtension = item.filename.split('.').pop() || ""; + const extension = rawExtension.toLowerCase(); + + if (!ALLOWED_EXTENSIONS.has(extension)) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ error: "Unsupported file extension" }); + } + const objectName = `${auth.userId}/${crypto.randomUUID()}.${extension}`; + + await minioClient.putObject(CDN_BUCKET, objectName, buffer, buffer.length, { + 'Content-Type': item.contentType, + }); + + urls.push(getPublicUrl(objectName)); + } + + return ctr.print({ + success: true, + urls: urls + }); + } catch (error: unknown) { + console.error(error); + + const details = + error instanceof Error ? error.message : "Unknown error during image upload"; + + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + error: "Failed to upload images to CDN", + errorCode: "CDN_UPLOAD_FAILED", + details + }); + } + }) +).http( + "GET", + "/api/account/cdn/list", + (http) => + http.onRequest(async (ctr) => { + const cookie = ctr.cookies.get(COOKIENAME) || null; + const apiHeader = (ctr.headers && ctr.headers.get) + ? (ctr.headers.get("api-authentication") as string) || null + : null; + const auth = await authCheck(cookie, apiHeader); + + if (!auth.state) { + return ctr + .status(ctr.$status.UNAUTHORIZED) + .print({ error: auth.message }); + } + + try { + const objects: any[] = []; + const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true); + + for await (const obj of stream) { + objects.push({ + name: obj.name, + url: getPublicUrl(obj.name), + size: obj.size, + lastModified: obj.lastModified + }); + } + + return ctr.print({ + success: true, + images: objects + }); + } catch (e) { + console.error(e); + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + error: "Failed to list images from CDN" + }); + } + }) + ).http( + "DELETE", + "/api/account/cdn/delete", + (http) => + http.onRequest(async (ctr) => { + const cookie = ctr.cookies.get(COOKIENAME) || null; + const apiHeader = (ctr.headers && ctr.headers.get) + ? (ctr.headers.get("api-authentication") as string) || null + : null; + const auth = await authCheck(cookie, apiHeader); + + if (!auth.state) { + return ctr + .status(ctr.$status.UNAUTHORIZED) + .print({ error: auth.message }); + } + + const [data, error] = await ctr.bindBody((z) => + z.object({ + urls: z.array(z.string().url()).min(1), + }) + ); + + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() }); + + try { + const objectsToDelete: string[] = []; + + for (const url of data.urls) { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(Boolean); + + // Expected: [bucket, userId, filename] + if (pathParts.length < 3) continue; + + const bucket = pathParts[0]; + const userId = pathParts[1]; + const filename = pathParts.slice(2).join('/'); + const objectName = `${userId}/${filename}`; + + if (bucket === CDN_BUCKET && userId === auth.userId) { + objectsToDelete.push(objectName); + } + } + + if (objectsToDelete.length === 0) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "No valid objects to delete" }); + } + + await minioClient.removeObjects(CDN_BUCKET, objectsToDelete); + + return ctr.print({ + success: true, + message: `${objectsToDelete.length} image(s) deleted successfully` + }); + } catch (e) { + console.error(e); + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + error: "Failed to delete images" + }); + } + }) + ); diff --git a/backend/src/routes/account/delete.ts b/backend/src/routes/account/delete.ts index 9bd132a..3677534 100644 --- a/backend/src/routes/account/delete.ts +++ b/backend/src/routes/account/delete.ts @@ -1,5 +1,6 @@ import { COOKIENAME, db, fileRouter } from "../../"; import { authCheck } from "../../lib/Auth"; +import { minioClient, CDN_BUCKET } from "../../lib/CDN"; import bcrypt from "bcryptjs"; export = new fileRouter.Path("/").http( @@ -15,6 +16,22 @@ export = new fileRouter.Path("/").http( return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message }); } + // Delete all CDN objects for the user + try { + const objectsToDelete: string[] = []; + const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true); + + for await (const obj of stream) { + if (obj.name) objectsToDelete.push(obj.name); + } + + if (objectsToDelete.length > 0) { + await minioClient.removeObjects(CDN_BUCKET, objectsToDelete); + } + } catch (e) { + console.error("Failed to cleanup CDN during account deletion:", e); + } + // Delete all exams for the user await db.collection("exams").deleteMany({ userId: auth.userId }); diff --git a/frontend/src/modals/ExamModal.tsx b/frontend/src/modals/ExamModal.tsx index dc3faa9..0121c3c 100644 --- a/frontend/src/modals/ExamModal.tsx +++ b/frontend/src/modals/ExamModal.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react'; +import { X, Plus, Trash2, Link as LinkIcon, Image as ImageIcon, Loader2, FileText } from 'lucide-react'; import { Exam } from '../types'; +import axios from 'axios'; +import { toast } from 'react-toastify'; interface ExamModalProps { show: boolean; @@ -14,6 +16,7 @@ interface ExamModalProps { result: string; duration: string; studyMaterials: string[]; + imageUrls: string[]; }; setFormData: React.Dispatch>; editingExam: Exam | null; } @@ -36,6 +40,91 @@ const ExamModal: React.FC = ({ editingExam }) => { const [newMaterial, setNewMaterial] = React.useState(''); + const [uploading, setUploading] = React.useState(false); + const [isDragging, setIsDragging] = React.useState(false); + const fileInputRef = React.useRef(null); + + const processFiles = async (files: File[]) => { + if (files.length === 0) return; + + const allowedTypes = ['image/', 'application/pdf']; + const validFiles = files.filter(file => allowedTypes.some(type => file.type.startsWith(type))); + const invalidFiles = files.filter(file => !allowedTypes.some(type => file.type.startsWith(type))); + + if (invalidFiles.length > 0) { + const invalidNames = invalidFiles.map(file => file.name).join(', '); + toast.error(`The following file(s) are not supported (images/PDFs only): ${invalidNames}`); + } + + if (validFiles.length === 0) { + return; + } + + setUploading(true); + try { + const uploadPromises = validFiles.map(file => { + return new Promise<{image: string, filename: string, contentType: string}>((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve({ + image: (reader.result as string).split(',')[1], + filename: file.name, + contentType: file.type + }); + }; + reader.readAsDataURL(file); + }); + }); + + const items = await Promise.all(uploadPromises); + const res = await axios.post('/api/account/cdn/upload', { + images: items + }); + + if (res.data.success) { + setFormData(prev => ({ + ...prev, + imageUrls: [...prev.imageUrls, ...res.data.urls] + })); + toast.success(`${items.length} image(s) uploaded successfully`); + } + } catch (err) { + console.error(err); + toast.error('Failed to upload images'); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + await processFiles(files); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + await processFiles(files); + }; + + const removeImage = (index: number) => { + setFormData({ + ...formData, + imageUrls: formData.imageUrls.filter((_, i) => i !== index) + }); + }; const addMaterial = () => { const trimmedMaterial = newMaterial.trim(); @@ -72,8 +161,8 @@ const ExamModal: React.FC = ({ if (!show) return null; return (
-
-
+
+

{editingExam ? 'Exam Details' : 'New Schedule'}

-
+
= ({ required />
-
+
= ({ />
-
+
= ({