From 7ac5713193342fb81af82e6a1d184fbd105cd782 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 29 Sep 2026 21:47:44 +0200 Subject: [PATCH] added efficenciy --- app/api/documents/[id]/thumbnail/route.ts | 49 ++++++++++++++++ .../20260930160000_document_thumbnails.sql | 7 +++ modules/loginPage/actions/auth.ts | 4 ++ modules/shared/actions/documents.ts | 57 +++++++++++++++---- modules/shared/queries/documents.ts | 24 ++------ modules/shared/schemas/documents.ts | 2 + .../ui/components/documents-browser.tsx | 56 +++++++++++++++--- next.config.ts | 7 +++ 8 files changed, 168 insertions(+), 38 deletions(-) create mode 100644 app/api/documents/[id]/thumbnail/route.ts create mode 100644 lib/supabase/migrations/20260930160000_document_thumbnails.sql diff --git a/app/api/documents/[id]/thumbnail/route.ts b/app/api/documents/[id]/thumbnail/route.ts new file mode 100644 index 0000000..fcfee11 --- /dev/null +++ b/app/api/documents/[id]/thumbnail/route.ts @@ -0,0 +1,49 @@ +import * as z from "zod" +import { getSession } from "@/lib/supabase/dal" +import { createClient } from "@/lib/supabase/server" +import { DOCUMENTS_BUCKET, MAX_THUMBNAIL_BYTES, previewKind } from "@/modules/shared/schemas/documents" + +const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } }) + +// Feste URL pro Dokument statt ablaufender Signed URL: der Browser cached das Vorschaubild dauerhaft. +// Zugriff prüft RLS (Tabelle + Storage) bei jedem nicht gecachten Aufruf. +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await getSession() + if (!user) return new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }) + + const { id } = await params + if (!z.string().uuid().safeParse(id).success) return notFound() + + const supabase = await createClient() + const { data: doc } = await supabase + .from("documents") + .select("storage_path, thumbnail_path, mime_type") + .eq("id", id) + .maybeSingle() + if (!doc) return notFound() + + if (doc.thumbnail_path) { + const { data: blob, error } = await supabase.storage.from(DOCUMENTS_BUCKET).download(doc.thumbnail_path) + if (error || !blob || blob.size > MAX_THUMBNAIL_BYTES) return notFound() + + return new Response(blob, { + headers: { + // Inhalt stammt vom Nutzer und wird von unserer Domain ausgeliefert: Typ fest setzen, Sniffing und Skripte aus. + "Content-Type": "image/jpeg", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Cache-Control": "private, max-age=31536000, immutable", + }, + }) + } + + // Ältere Bilder ohne Vorschaubild: kurz gültige Weiterleitung aufs Original, bewusst nicht gecached. + if (previewKind(doc.mime_type) === "image") { + const { data } = await supabase.storage.from(DOCUMENTS_BUCKET).createSignedUrl(doc.storage_path, 5 * 60) + if (data?.signedUrl) { + return new Response(null, { status: 302, headers: { Location: data.signedUrl, "Cache-Control": "no-store" } }) + } + } + + return notFound() +} diff --git a/lib/supabase/migrations/20260930160000_document_thumbnails.sql b/lib/supabase/migrations/20260930160000_document_thumbnails.sql new file mode 100644 index 0000000..2a152e6 --- /dev/null +++ b/lib/supabase/migrations/20260930160000_document_thumbnails.sql @@ -0,0 +1,7 @@ +-- Kleine Vorschaubilder (JPEG, im Browser beim Upload erzeugt) neben dem Original +alter table public.documents + add column if not exists thumbnail_path text; + +alter table public.documents + add constraint documents_thumbnail_path_matches_client + check (thumbnail_path is null or split_part(thumbnail_path, '/', 1) = client_id::text); diff --git a/modules/loginPage/actions/auth.ts b/modules/loginPage/actions/auth.ts index 2229bf0..c846b41 100644 --- a/modules/loginPage/actions/auth.ts +++ b/modules/loginPage/actions/auth.ts @@ -1,6 +1,7 @@ "use server"; import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; import * as z from "zod"; import { createClient } from "@/lib/supabase/server"; @@ -49,11 +50,14 @@ export async function login( return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." } } + // Zwischengespeicherte Seiten eines vorherigen Nutzers im selben Tab verwerfen. + revalidatePath("/", "layout") redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard") } export async function logout() { const supabase = await createClient(); await supabase.auth.signOut(); + revalidatePath("/", "layout"); redirect("/"); } \ No newline at end of file diff --git a/modules/shared/actions/documents.ts b/modules/shared/actions/documents.ts index a57ba61..c57c5ae 100644 --- a/modules/shared/actions/documents.ts +++ b/modules/shared/actions/documents.ts @@ -9,6 +9,7 @@ import { DOCUMENTS_BUCKET, FolderNameSchema, MAX_FILES_PER_UPLOAD, + MAX_THUMBNAIL_BYTES, UploadFileSchema, } from "@/modules/shared/schemas/documents"; @@ -95,7 +96,7 @@ async function assertFolder(clientId: string, folderId: string) { return Boolean(data); } -export type PreparedUpload = { documentId: string; path: string }; +export type PreparedUpload = { documentId: string; path: string; thumbnailPath: string }; // Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request). export async function prepareUpload( @@ -118,25 +119,45 @@ export async function prepareUpload( return { uploads: files.map((file) => { const documentId = crypto.randomUUID(); - return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` }; + return { + documentId, + path: `${access.clientId}/${documentId}/${safeFileName(file.name)}`, + thumbnailPath: thumbnailPathFor(access.clientId, documentId), + }; }), }; } +// Eigener Unterordner: kann nie mit einem hochgeladenen Dateinamen kollidieren (safeFileName enthält kein "/"). +function thumbnailPathFor(clientId: string, documentId: string) { + return `${clientId}/${documentId}/thumbnail/preview.jpg`; +} + const FinalizeSchema = z - .array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) })) + .array( + UploadFileSchema.extend({ + documentId: z.string().uuid(), + path: z.string().min(1).max(400), + hasThumbnail: z.boolean(), + }) + ) .min(1) .max(MAX_FILES_PER_UPLOAD); // Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer // bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen. -async function removeOrphanedObjects(paths: string[]) { +async function removeOrphanedObjects(clientId: string, uploads: { documentId: string; path: string }[]) { const supabase = await createClient(); - const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths); + const { data: existing, error } = await supabase + .from("documents") + .select("storage_path") + .in("storage_path", uploads.map((upload) => upload.path)); if (error) return; const referenced = new Set((existing ?? []).map((row) => row.storage_path as string)); - const orphaned = paths.filter((path) => !referenced.has(path)); + const orphaned = uploads + .filter((upload) => !referenced.has(upload.path)) + .flatMap((upload) => [upload.path, thumbnailPathFor(clientId, upload.documentId)]); if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned); } @@ -144,7 +165,7 @@ async function removeOrphanedObjects(paths: string[]) { export async function finalizeUpload( profileId: string, folderId: string, - uploads: { documentId: string; path: string; name: string; size: number; type: string }[] + uploads: { documentId: string; path: string; name: string; size: number; type: string; hasThumbnail: boolean }[] ): Promise { const access = await resolveAccess(profileId); if ("error" in access) return access; @@ -165,7 +186,19 @@ export async function finalizeUpload( const stored = await Promise.all( parsed.data.map(async (upload) => { const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path); - return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null }; + if (error || !data) return null; + + // Vorschaubild ist optional: fehlt es, wird einfach keins gespeichert. + const thumbnailPath = thumbnailPathFor(access.clientId, upload.documentId); + const thumbnail = upload.hasThumbnail ? await supabase.storage.from(DOCUMENTS_BUCKET).info(thumbnailPath) : null; + const hasThumbnail = Boolean(thumbnail?.data && (thumbnail.data.size ?? 0) <= MAX_THUMBNAIL_BYTES); + + return { + upload, + size: data.size ?? null, + contentType: data.contentType ?? null, + thumbnailPath: hasThumbnail ? thumbnailPath : null, + }; }) ); const verified = stored.filter((entry): entry is NonNullable => entry !== null); @@ -180,12 +213,13 @@ export async function finalizeUpload( storage_path: entry.upload.path, mime_type: entry.contentType, size_bytes: entry.size, + thumbnail_path: entry.thumbnailPath, uploaded_by: session.userId, })) ); if (error) { - await removeOrphanedObjects(parsed.data.map((upload) => upload.path)); + await removeOrphanedObjects(access.clientId, parsed.data); return { error: "Dateien konnten nicht gespeichert werden." }; } @@ -237,11 +271,12 @@ export async function deleteDocument(profileId: string, documentId: string): Pro .delete() .eq("id", documentId) .eq("client_id", access.clientId) - .select("storage_path"); + .select("storage_path, thumbnail_path"); if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." }; - const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]); + const paths = [data[0].storage_path, data[0].thumbnail_path].filter((path): path is string => Boolean(path)); + const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove(paths); revalidateDocuments(profileId); if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." }; diff --git a/modules/shared/queries/documents.ts b/modules/shared/queries/documents.ts index 910a627..2ecba2f 100644 --- a/modules/shared/queries/documents.ts +++ b/modules/shared/queries/documents.ts @@ -1,8 +1,6 @@ import "server-only" import { createClient } from "@/lib/supabase/server" -import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" - -const THUMBNAIL_TTL_SECONDS = 60 * 60 +import { previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" export async function getFolders(clientId: string): Promise { const supabase = await createClient() @@ -39,33 +37,21 @@ export async function getDocuments(clientId: string, folderId: string): Promise< const supabase = await createClient() const { data, error } = await supabase .from("documents") - .select("id, name, mime_type, size_bytes, created_at, storage_path") + .select("id, name, mime_type, size_bytes, created_at, thumbnail_path") .eq("client_id", clientId) .eq("folder_id", folderId) .order("created_at", { ascending: false }) if (error) throw new Error("Dokumente konnten nicht geladen werden.") - const imagePaths = data - .filter((row) => previewKind(row.mime_type) === "image") - .map((row) => row.storage_path as string) - - const thumbnails = new Map() - if (imagePaths.length > 0) { - const { data: signed } = await supabase.storage - .from(DOCUMENTS_BUCKET) - .createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS) - for (const entry of signed ?? []) { - if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl) - } - } - return data.map((row) => ({ id: row.id, name: row.name, mimeType: row.mime_type, sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes), createdAt: row.created_at, - thumbnailUrl: thumbnails.get(row.storage_path) ?? null, + // Feste URL (nicht ablaufend), damit der Browser das Vorschaubild dauerhaft cachen kann. + thumbnailUrl: + row.thumbnail_path || previewKind(row.mime_type) === "image" ? `/api/documents/${row.id}/thumbnail` : null, })) } diff --git a/modules/shared/schemas/documents.ts b/modules/shared/schemas/documents.ts index 63f4746..49934e9 100644 --- a/modules/shared/schemas/documents.ts +++ b/modules/shared/schemas/documents.ts @@ -3,6 +3,8 @@ import * as z from "zod"; export const MAX_FILE_BYTES = 50 * 1024 * 1024; export const MAX_FILES_PER_UPLOAD = 20; export const DOCUMENTS_BUCKET = "client-documents"; +export const MAX_THUMBNAIL_BYTES = 512 * 1024; +export const THUMBNAIL_MAX_EDGE = 480; export type Folder = { id: string; diff --git a/modules/shared/ui/components/documents-browser.tsx b/modules/shared/ui/components/documents-browser.tsx index dba1b8c..645eaff 100644 --- a/modules/shared/ui/components/documents-browser.tsx +++ b/modules/shared/ui/components/documents-browser.tsx @@ -12,6 +12,8 @@ import { DOCUMENTS_BUCKET, MAX_FILES_PER_UPLOAD, MAX_FILE_BYTES, + MAX_THUMBNAIL_BYTES, + THUMBNAIL_MAX_EDGE, fileKind, previewKind, formatFileSize, @@ -45,6 +47,32 @@ function uniqueName(name: string, used: Set) { return next; } +const THUMBNAIL_SOURCE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); + +// Kleines JPEG (max. 480 px) statt des Originals als Vorschaubild. JPEG, weil Safari WebP per Canvas nicht zuverlässig erzeugt. +async function createThumbnail(file: File): Promise { + if (!THUMBNAIL_SOURCE_TYPES.has(file.type)) return null; + try { + const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" }); + const scale = Math.min(1, THUMBNAIL_MAX_EDGE / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + + const context = canvas.getContext("2d"); + if (!context) return null; + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8)); + return blob && blob.size <= MAX_THUMBNAIL_BYTES ? blob : null; + } catch { + return null; + } +} + async function runLimited(items: T[], limit: number, worker: (item: T) => Promise) { let index = 0; await Promise.all( @@ -249,16 +277,27 @@ export function DocumentsBrowser({ const supabase = createClient(); const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] })); - const succeeded: typeof jobs = []; + const succeeded: (typeof jobs[number] & { hasThumbnail: boolean })[] = []; const failed: string[] = []; setUpload({ done: 0, total: jobs.length }); await runLimited(jobs, PARALLEL_UPLOADS, async (job) => { - const { error } = await supabase.storage - .from(DOCUMENTS_BUCKET) - .upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false }); - if (error) failed.push(job.file.name); - else succeeded.push(job); + const bucket = supabase.storage.from(DOCUMENTS_BUCKET); + const { error } = await bucket.upload(job.target.path, job.file, { + contentType: job.file.type || undefined, + upsert: false, + }); + + if (error) { + failed.push(job.file.name); + } else { + // Vorschaubild ist nur Komfort: schlägt es fehl, bleibt das Original trotzdem hochgeladen. + const thumbnail = await createThumbnail(job.file); + const thumbnailUploaded = thumbnail + ? !(await bucket.upload(job.target.thumbnailPath, thumbnail, { contentType: "image/jpeg", upsert: false })).error + : false; + succeeded.push({ ...job, hasThumbnail: thumbnailUploaded }); + } setUpload((current) => (current ? { ...current, done: current.done + 1 } : current)); }); @@ -266,12 +305,13 @@ export function DocumentsBrowser({ const result = await finalizeUpload( profileId, folderId, - succeeded.map(({ target, file }) => ({ + succeeded.map(({ target, file, hasThumbnail }) => ({ documentId: target.documentId, path: target.path, name: file.name, size: file.size, type: file.type, + hasThumbnail, })) ); if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name)); @@ -422,7 +462,7 @@ export function DocumentsBrowser({ className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" title={doc.name} > -
+
{doc.thumbnailUrl ? ( // eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL diff --git a/next.config.ts b/next.config.ts index f06a92f..6f87797 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,13 @@ const nextConfig: NextConfig = { outputFileTracingIncludes: { "/api/invoices/*": ["./modules/shared/pdf/assets/**/*"], }, + experimental: { + // Bereits besuchte Seiten 5 Min. im Browser halten. Eigene Änderungen leeren den Cache sofort + // (revalidatePath in den Actions), ebenso Login/Logout (Cookie-Änderung). + staleTimes: { + dynamic: 300, + }, + }, }; export default nextConfig;