From e961f60b01ddcd0ee9b4f4271ff61188b27a0b99 Mon Sep 17 00:00:00 2001 From: stephen Date: Fri, 9 Jan 2026 17:58:44 +1100 Subject: [PATCH] Create Folder in FileNode needs more work --- next.config.ts | 8 +- prisma/schema.prisma | 2 +- src/app/upload/_actions.ts | 121 ++++++++++---------- src/app/upload/upload-view.tsx | 198 +++++++++++++++++++++++---------- src/proxy.ts | 29 ++++- 5 files changed, 225 insertions(+), 133 deletions(-) diff --git a/next.config.ts b/next.config.ts index e9ffa30..ab26a57 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + experimental: { + serverActions: { + // Set this higher than your MAX_FILE_SIZE in upload-view.tsx + bodySizeLimit: '150mb', + }, + }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15495e7..b55b756 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,7 +50,7 @@ model Session { } model FileNode { - id String @id @default(uuid()) + id String @id // Removed @default(uuid()) to allow manual assignment name String size BigInt? isFolder Boolean @default(false) diff --git a/src/app/upload/_actions.ts b/src/app/upload/_actions.ts index 1aeb0b3..9210685 100644 --- a/src/app/upload/_actions.ts +++ b/src/app/upload/_actions.ts @@ -5,99 +5,94 @@ import { getFreshAccessToken } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; +/** + * Creates a virtual folder in the database. + * No physical folder is created on OneDrive to keep storage flat and fast. + */ +export async function createFolderAction(name: string, parentId: string | null = null) { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauthorized"); + + const internalId = crypto.randomUUID(); + + await prisma.fileNode.create({ + data: { + id: internalId, + name: name, + isFolder: true, + path: `/virtual/${name}`, + ownerId: session.user.id, + parentId: parentId || null, + } + }); + + revalidatePath("/upload"); + revalidatePath("/dashboard"); + return { success: true }; +} + +/** + * Uploads a file to a unique UUID folder on OneDrive + * and links it to a virtual parent in the DB. + */ export async function uploadFileAction(formData: FormData) { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); const file = formData.get("file") as File; + const description = formData.get("description") as string || ""; + const parentId = formData.get("parentId") as string | null; + if (!file) throw new Error("No file selected"); const accessToken = await getFreshAccessToken(session.user.id); - const folderName = "WebCalibre"; + const rootFolder = "WebCalibre"; + const internalId = crypto.randomUUID(); - // --- 1. CHECK/CREATE THE WEBCALIBRE FOLDER --- - // We check if the folder exists at the root of the user's OneDrive - const folderCheckUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`; - const folderCheck = await fetch(folderCheckUrl, { - headers: { Authorization: `Bearer ${accessToken}` } + // 1. Ensure WebCalibre exists (Simplified for brevity) + // ... (Keep the root folder check logic from your previous version) + + // 2. Create the unique UUID folder on OneDrive + const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" }) }); - if (folderCheck.status === 404) { - console.log(`📂 Folder '${folderName}' not found. Creating it...`); - const createFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - name: folderName, - folder: {}, // Empty object tells Graph to create a folder - "@microsoft.graph.conflictBehavior": "fail" - }) - }); - - if (!createFolderRes.ok) { - const errorData = await createFolderRes.json(); - console.error("❌ Folder Creation Error:", errorData); - throw new Error("Could not create WebCalibre folder on OneDrive."); - } - } + if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed"); + const subFolderData = await createSubFolderRes.json(); - // --- 2. CREATE UPLOAD SESSION --- - // encodeURIComponent is vital for filenames with spaces or special characters - const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${encodeURIComponent(file.name)}:/createUploadSession`; - + // 3. Create Upload Session & Upload + const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`; const sessionRes = await fetch(sessionUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - item: { - "@microsoft.graph.conflictBehavior": "rename", // If file exists, name it "Book 1.pdf" - name: file.name - } - }) + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } }) }); - const sessionData = await sessionRes.json(); - if (!sessionRes.ok) { - console.error("❌ Session Error:", sessionData); - throw new Error(sessionData.error?.message || "OneDrive session failed"); - } - - const { uploadUrl } = sessionData; - - // --- 3. UPLOAD THE DATA BYTES --- + const { uploadUrl } = await sessionRes.json(); const buffer = Buffer.from(await file.arrayBuffer()); const uploadRes = await fetch(uploadUrl, { method: "PUT", - headers: { - "Content-Length": `${file.size}`, - "Content-Range": `bytes 0-${file.size - 1}/${file.size}` - }, + headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` }, body: buffer }); - if (!uploadRes.ok) { - const uploadError = await uploadRes.json(); - console.error("❌ Upload Error:", uploadError); - throw new Error("Chunk upload failed"); - } - + if (!uploadRes.ok) throw new Error("OneDrive stream failed"); const driveItem = await uploadRes.json(); - // --- 4. RECORD IN POSTGRESQL (PRISMA) --- + // 4. Record in DB await prisma.fileNode.create({ data: { + id: internalId, oneDriveId: driveItem.id, name: file.name, + description: description, size: BigInt(file.size), isFolder: false, - path: `/${folderName}/${file.name}`, + path: `/${rootFolder}/${internalId}/${file.name}`, ownerId: session.user.id, + parentId: parentId || null, // VIRTUAL HIERARCHY metadata: { type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN", mimeType: file.type @@ -105,8 +100,6 @@ export async function uploadFileAction(formData: FormData) { } }); - // Revalidate ensures the dashboard list updates immediately revalidatePath("/dashboard"); - return { success: true }; } \ No newline at end of file diff --git a/src/app/upload/upload-view.tsx b/src/app/upload/upload-view.tsx index 013328c..3c1dbc2 100644 --- a/src/app/upload/upload-view.tsx +++ b/src/app/upload/upload-view.tsx @@ -1,119 +1,195 @@ 'use client'; import { useState } from "react"; -import { Box, Button, Typography, Paper, LinearProgress, Stack } from "@mui/material"; +import { + Box, Button, Typography, Paper, LinearProgress, Stack, + TextField, MenuItem, IconButton, Tooltip, Divider +} from "@mui/material"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; -import { uploadFileAction } from "./_actions"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import { uploadFileAction, createFolderAction } from "./_actions"; /** - * Helper function to convert raw bytes into a human-readable string. - * This helps the user understand exactly how large their e-book is. + * Format bytes to human readable string (MiB/KiB) */ const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - // Returns something like "1.45 MB" or "850 KB" return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -export default function UploadView({ user }: { user: any }) { +export default function UploadView({ user, folders = [] }: { user: any, folders?: any[] }) { const [file, setFile] = useState(null); + const [description, setDescription] = useState(""); + const [parentId, setParentId] = useState(""); const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle'); - const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Limit + // Folder Creation State + const [showFolderInput, setShowFolderInput] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + + // --- RESTORED FILE SIZE LIMITS --- + const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MiB Limit + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) return; + try { + await createFolderAction(newFolderName); + setNewFolderName(""); + setShowFolderInput(false); + } catch (err) { + alert("Error creating folder"); + } + }; const handleUpload = async () => { if (!file) return; + + // RESTORED: Client-side size validation if (file.size > MAX_FILE_SIZE) { - alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`); - return; + alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`); + return; } + setStatus('uploading'); const formData = new FormData(); formData.append("file", file); + formData.append("description", description); + formData.append("parentId", parentId); try { await uploadFileAction(formData); setStatus('success'); setFile(null); + setDescription(""); } catch (err) { - // If the server action fails, it usually prints details in the terminal - alert("Upload failed. Ensure the 'WebCalibre' folder can be created and OneDrive has space."); + console.error(err); + alert("Upload failed. Ensure file size is within limits and check server logs."); setStatus('idle'); } }; return ( - - - Upload to WebCalibre - - - Adding books as {user.name} + + + Add to Library - - {/* Dropzone/Selection Area */} - - { - setFile(e.target.files?.[0] || null); - setStatus('idle'); - }} - /> - + + + {/* Section 1: Folder Selection */} + + + 1. Select Target Project / Folder + + + setParentId(e.target.value)} + disabled={status === 'uploading'} + > + None (Root) + {folders.map((f) => ( + {f.name} + ))} + + + setShowFolderInput(!showFolderInput)} + sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }} + > + + + + - {/* New: Enhanced File Info Display */} - {file && ( - - - Selected: {file.name} - - - File Size: {formatFileSize(file.size)} - - + {showFolderInput && ( + + setNewFolderName(e.target.value)} + autoFocus + /> + + )} - {/* Progress Indicator */} + + + {/* Section 2: File Selection */} + + + 2. Upload File + + + { + setFile(e.target.files?.[0] || null); + setStatus('idle'); + }} + /> + + {file && ( + + + Selected: {file.name} + + MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}> + Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"} + + + )} + + + + {/* Section 3: Description */} + setDescription(e.target.value)} + disabled={status === 'uploading'} + /> + + {/* Status & Action */} {status === 'uploading' && ( - - - Connecting to OneDrive & Uploading... + + + + Streaming to OneDrive storage... - )} - {/* Action Button */} - {/* Success Feedback */} {status === 'success' && ( - - ✅ Successfully uploaded to your library! + + ✅ Successfully Added! )} diff --git a/src/proxy.ts b/src/proxy.ts index 863b06c..d3dd34b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,23 +1,35 @@ import { NextResponse } from "next/server"; -import {auth} from "@/auth"; +import { auth } from "@/auth"; const protectedRoutes = ["/dashboard", "/profile"]; const apiAuthPrefix = "/api/auth"; export const proxy = auth((req) => { const { nextUrl } = req; - const isLoggedIn = !!req.auth; - const path = nextUrl.pathname; + + /** + * 1. IMMEDIATE BYPASS FOR UPLOADS + * We check this first. If the user is hitting the upload route, + * we let the request pass through directly to the page/action. + * This prevents the middleware from trying to parse the 100MB body. + */ + if (path.startsWith('/upload')) { + return NextResponse.next(); + } + + const isLoggedIn = !!req.auth; const isApiAuthRoute = path.startsWith(apiAuthPrefix); + + // Check if the current path is in our protected list const isProtectedRoute = protectedRoutes.includes(path); - // 1. Allow API Auth calls (Login/Logout/Callback) + // 2. Allow API Auth calls (Login/Logout/Callback) if (isApiAuthRoute) { return NextResponse.next(); } - // 2. CHANGED: Redirect to HOME (/) instead of /login if logged out + // 3. Redirect to HOME (/) if trying to access a protected route while logged out if (isProtectedRoute && !isLoggedIn) { return NextResponse.redirect(new URL("/", nextUrl)); } @@ -25,6 +37,11 @@ export const proxy = auth((req) => { return NextResponse.next(); }); +/** + * The Matcher tells Next.js which routes this proxy should run on. + * By adding '|upload' to the negative lookahead (?!...), we tell + * Next.js to ignore the /upload route entirely at the engine level. + */ export const config = { - matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|upload).*)"], }; \ No newline at end of file