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 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-22 13:37:06 +02:00
1 parent 81802ab001
commit 66c098190d
2 files changed
+32 -3

No files matched your search

+19 -2
View File
@@ -5,18 +5,35 @@ const BUCKET = 'newsletter-assets';
// Vydá podpísanú adresu na nahratie súboru priamo z prehliadača do Supabase Storage. // 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“). // 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) { export async function POST(req: NextRequest) {
try { try {
const { name } = (await req.json()) as { name?: string }; const { name, hash } = (await req.json()) as { name?: string; hash?: string };
if (!name) { if (!name) {
return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 }); return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 });
} }
const admin = getSupabaseAdmin(); const admin = getSupabaseAdmin();
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_'); 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); 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) { if (error || !data) {
console.error('Signed upload URL error:', error); console.error('Signed upload URL error:', error);
return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 }); return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 });
+13 -1
View File
@@ -2,13 +2,25 @@
// (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku). // (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku).
import { supabase } from './supabase'; 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<string | undefined> {
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<string> { export async function uploadFile(file: Blob, name: string): Promise<string> {
const hash = await contentHash(file);
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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}` })); 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) { if (!data.success || !data.path || !data.token) {
throw new Error(data.error || 'Nahratie súboru zlyhalo'); throw new Error(data.error || 'Nahratie súboru zlyhalo');
} }