From 66c098190d1ca7034aa4c08e24c6a4f8cb82f708 Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Thu, 22 Oct 2026 13:37:06 +0200 Subject: [PATCH] feat: nahravanie obrazkov podla obsahu - rovnaky subor sa neulozi druhykrat Nazov suboru zacina odtlackom obsahu (SHA-256); ak rovnaky subor uz v ulozisku je, pouzije sa jeho adresa. Opakovane importy ZIP-u uz nevytvaraju duplikaty. Co-Authored-By: Claude Opus 5.5 --- src/app/api/upload/route.ts | 21 +++++++++++++++++++-- src/lib/upload.ts | 14 +++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 18b450b..62c128d 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -5,18 +5,35 @@ const BUCKET = 'newsletter-assets'; // Vydá podpísanú adresu na nahratie súboru priamo z prehliadača do Supabase Storage. // Samotný súbor cez server nejde – Vercel by odmietol požiadavky nad 4,5 MB („Request Entity Too Large“). +// Názov súboru začína odtlačkom obsahu (hash): ak rovnaký súbor už v úložisku je, vráti sa jeho adresa +// a nič sa nenahráva – opakované importy tak nevytvárajú duplikáty. export async function POST(req: NextRequest) { try { - const { name } = (await req.json()) as { name?: string }; + const { name, hash } = (await req.json()) as { name?: string; hash?: string }; if (!name) { return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 }); } const admin = getSupabaseAdmin(); const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_'); - const path = `uploads/${Date.now()}-${safeName}`; + const contentHash = hash && /^[a-f0-9]{16,64}$/.test(hash) ? hash : null; + if (contentHash) { + const { data: found } = await admin.storage.from(BUCKET).list('uploads', { search: contentHash, limit: 1 }); + const existing = found?.find((f) => f.name.startsWith(`${contentHash}-`)); + if (existing) { + const { data: pub } = admin.storage.from(BUCKET).getPublicUrl(`uploads/${existing.name}`); + return NextResponse.json({ success: true, exists: true, url: pub.publicUrl }); + } + } + + const path = `uploads/${contentHash || Date.now()}-${safeName}`; const { data, error } = await admin.storage.from(BUCKET).createSignedUploadUrl(path); + if (error && contentHash && /exist/i.test(error.message)) { + // ten istý súbor práve nahral niekto iný + const { data: pub } = admin.storage.from(BUCKET).getPublicUrl(path); + return NextResponse.json({ success: true, exists: true, url: pub.publicUrl }); + } if (error || !data) { console.error('Signed upload URL error:', error); return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 }); diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 2c8aee5..2c9c644 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -2,13 +2,25 @@ // (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku). import { supabase } from './supabase'; +// Odtlačok obsahu súboru (prvých 24 znakov SHA-256) – rovnaký obrázok = rovnaký názov v úložisku +async function contentHash(file: Blob): Promise { + try { + const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 24); + } catch { + return undefined; // bez odtlačku sa súbor nahrá pod časovou pečiatkou + } +} + export async function uploadFile(file: Blob, name: string): Promise { + const hash = await contentHash(file); const res = await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) + body: JSON.stringify({ name, hash }) }); const data = await res.json().catch(() => ({ error: `Server vrátil chybu ${res.status}` })); + if (data.success && data.exists && data.url) return data.url; // rovnaký súbor už v úložisku je if (!data.success || !data.path || !data.token) { throw new Error(data.error || 'Nahratie súboru zlyhalo'); }