From d2d3d1bbb1f3b6fd91a9b439198ed4e07bc8270c Mon Sep 17 00:00:00 2001 From: Potenz Date: Tue, 30 Sep 2025 02:50:10 +0530 Subject: [PATCH] feat(cloudpage) - wip - view and download feat added, upload ui size info added --- apps/Backend/src/routes/cloud-storage.ts | 109 +++++++-- .../src/storage/cloudStorage-storage.ts | 4 +- .../cloud-storage/file-preview-modal.tsx | 216 ++++++++++++++++++ .../cloud-storage/files-section.tsx | 114 ++++++++- .../file-upload/file-upload-zone.tsx | 130 ++++++++--- .../file-upload/multiple-file-upload-zone.tsx | 120 +++++++++- 6 files changed, 633 insertions(+), 60 deletions(-) create mode 100644 apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx diff --git a/apps/Backend/src/routes/cloud-storage.ts b/apps/Backend/src/routes/cloud-storage.ts index 78786b9..e616dc3 100644 --- a/apps/Backend/src/routes/cloud-storage.ts +++ b/apps/Backend/src/routes/cloud-storage.ts @@ -1,7 +1,5 @@ -// src/routes/cloudStorage.routes.ts import express, { Request, Response } from "express"; -// import storage from "../storage"; -import { cloudStorage as storage } from "../storage/cloudStorage-storage"; +import storage from "../storage"; import { serializeFile } from "../utils/prismaFileUtils"; import { CloudFolder } from "@repo/db/types"; @@ -242,6 +240,9 @@ router.get( PUT /files/:id { name?, mimeType?, folderId? } DELETE /files/:id */ +const MAX_FILE_MB = 20; +const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024; + router.post( "/folders/:id/files", async (req: Request, res: Response): Promise => { @@ -260,11 +261,25 @@ router.post( let expectedSize: bigint | null = null; if (req.body.expectedSize != null) { try { + // coerce to BigInt safely + const asNum = Number(req.body.expectedSize); + if (!Number.isFinite(asNum) || asNum < 0) { + return sendError(res, 400, "Invalid expectedSize"); + } + if (asNum > MAX_FILE_BYTES) { + // Payload Too Large + return sendError( + res, + 413, + `File too large. Max allowed is ${MAX_FILE_MB} MB` + ); + } expectedSize = BigInt(String(req.body.expectedSize)); } catch { return sendError(res, 400, "Invalid expectedSize"); } } + let totalChunks: number | null = null; if (req.body.totalChunks != null) { const tc = Number(req.body.totalChunks); @@ -308,23 +323,20 @@ router.post( return sendError(res, 400, "Invalid seq"); const body = req.body as Buffer; - console.log( - `[chunk upload] fileId=${id} seq=${seq} contentType=${String(req.headers["content-type"])} bodyIsBuffer=${Buffer.isBuffer(body)} bodyLength=${body?.length ?? 0}` - ); if (!body || !(body instanceof Buffer)) { return sendError(res, 400, "Expected raw binary body (Buffer)"); } + // strict size guard: any single chunk must not exceed MAX_FILE_BYTES + if (body.length > MAX_FILE_BYTES) { + return sendError(res, 413, `Chunk size exceeds ${MAX_FILE_MB} MB limit`); + } + try { await storage.appendFileChunk(id, seq, body); return res.json({ error: false, data: { fileId: id, seq } }); } catch (err: any) { - console.error( - "[chunk upload] appendFileChunk failed:", - err && (err.stack || err.message || err) - ); - return sendError(res, 500, "Failed to add chunk"); } } @@ -339,6 +351,28 @@ router.post( return sendError(res, 400, "Invalid file id"); try { + // Ask storage for the file (includes chunks in your implementation) + const file = await storage.getFile(id); + if (!file) return sendError(res, 404, "File not found"); + + // Sum chunks' sizes (storage.getFile returns chunks ordered by seq in your impl) + const chunks = (file as any).chunks ?? []; + if (!chunks.length) return sendError(res, 400, "No chunks uploaded"); + + let total = 0; + for (const c of chunks) { + // c.data is Bytes / Buffer-like + total += c.data.length; + // early bailout + if (total > MAX_FILE_BYTES) { + return sendError( + res, + 413, + `Assembled file is too large (${Math.round(total / 1024 / 1024)} MB). Max allowed is ${MAX_FILE_MB} MB.` + ); + } + } + const result = await storage.finalizeFileUpload(id); return res.json({ error: false, data: result }); } catch (err: any) { @@ -384,9 +418,56 @@ router.delete( } ); -/* ---------- Download (stream) ---------- - GET /files/:id/download -*/ +/* GET /files/:id -> return serialized metadata (used by preview modal) */ +router.get("/files/:id", async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + try { + const file = await storage.getFile(id); + if (!file) return sendError(res, 404, "File not found"); + return res.json({ error: false, data: serializeFile(file as any) }); + } catch (err) { + return sendError(res, 500, "Failed to load file"); + } +}); + +/* GET /files/:id/content -> stream file with inline disposition for preview */ +router.get( + "/files/:id/content", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + try { + const file = await storage.getFile(id); + if (!file) return sendError(res, 404, "File not found"); + + const filename = (file.name ?? `file-${(file as any).id}`).replace( + /["\\]/g, + "" + ); + + if ((file as any).mimeType) + res.setHeader("Content-Type", (file as any).mimeType); + // NOTE: inline instead of attachment so browser can render (images, pdfs) + res.setHeader( + "Content-Disposition", + `inline; filename="${encodeURIComponent(filename)}"` + ); + + await storage.streamFileTo(res, id); + + if (!res.writableEnded) res.end(); + } catch (err) { + if (res.headersSent) return res.end(); + return sendError(res, 500, "Failed to stream file"); + } + } +); +/* GET /files/:id/download */ router.get( "/files/:id/download", async (req: Request, res: Response): Promise => { diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index 87daa2b..914db83 100644 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -109,7 +109,7 @@ export interface IStorage { } /* ------------------------------- Implementation ------------------------------- */ -export const cloudStorage: IStorage = { +export const cloudStorageStorage: IStorage = { // --- FOLDERS --- async getFolder(id: number) { const folder = await db.cloudFolder.findUnique({ @@ -471,4 +471,4 @@ export const cloudStorage: IStorage = { }, }; -export default cloudStorage; +export default cloudStorageStorage; diff --git a/apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx b/apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx new file mode 100644 index 0000000..797abca --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { apiRequest } from "@/lib/queryClient"; +import { toast } from "@/hooks/use-toast"; +import { Download, Maximize2, Minimize2, X } from "lucide-react"; + +type Props = { + fileId: number | null; + isOpen: boolean; + onClose: () => void; +}; + +export default function FilePreviewModal({ fileId, isOpen, onClose }: Props) { + const [loading, setLoading] = useState(false); + const [meta, setMeta] = useState(null); + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + if (!isOpen || !fileId) return; + + let cancelled = false; + let createdUrl: string | null = null; + + async function load() { + setLoading(true); + setError(null); + setMeta(null); + setBlobUrl(null); + + try { + const metaRes = await apiRequest( + "GET", + `/api/cloud-storage/files/${fileId}` + ); + const metaJson = await metaRes.json(); + if (!metaRes.ok) { + throw new Error(metaJson?.message || "Failed to load file metadata"); + } + if (cancelled) return; + setMeta(metaJson.data); + + const contentRes = await apiRequest( + "GET", + `/api/cloud-storage/files/${fileId}/content` + ); + if (!contentRes.ok) { + let msg = `Preview request failed (${contentRes.status})`; + try { + const j = await contentRes.json(); + msg = j?.message ?? msg; + } catch (e) {} + throw new Error(msg); + } + const blob = await contentRes.blob(); + if (cancelled) return; + createdUrl = URL.createObjectURL(blob); + setBlobUrl(createdUrl); + } catch (err: any) { + if (!cancelled) setError(err?.message ?? String(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + + return () => { + cancelled = true; + if (createdUrl) { + URL.revokeObjectURL(createdUrl); + } + }; + }, [isOpen, fileId]); + + if (!isOpen) return null; + + const mime = meta?.mimeType ?? ""; + + async function handleDownload() { + if (!fileId) return; + try { + const res = await apiRequest( + "GET", + `/api/cloud-storage/files/${fileId}/download` + ); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j?.message || `Download failed (${res.status})`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = meta?.name ?? `file-${fileId}`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + } catch (err: any) { + toast({ + title: "Download failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + // container sizing classes + const containerBase = + "bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl"; + const sizeClass = isFullscreen + ? "w-[95vw] h-[95vh]" + : "w-[min(1200px,95vw)] h-[85vh]"; + + return ( +
+
+ {/* header */} + +
+
+

+ {meta?.name ?? "Preview"} +

+
+ {meta?.mimeType ?? ""} +
+
+ +
+ + + +
+
+ + {/* body */} +
+ {/* loading / error */} + {loading && ( +
+ Loading preview… +
+ )} + {error &&
{error}
} + + {/* image */} + {!loading && !error && blobUrl && mime.startsWith("image/") && ( +
+ {meta?.name} +
+ )} + + {/* pdf */} + {!loading && + !error && + blobUrl && + (mime === "application/pdf" || mime.endsWith("/pdf")) && ( +
+