Web razvoj
Next.jsS3Cloudflare R2Presigned URLsSigurnostUčitavanje datotekaApp Router

Next.js učitavanje datoteka kako treba: izravno na S3 i Cloudflare R2 s presigned URL-ovima, validacijom i sigurnošću

AO
Adrijan Omićević
·14 min čitanja

# Što ćete izgraditi#

Ovaj vodič prikazuje production-ready obrazac za izravno učitavanje iz preglednika na AWS S3 ili Cloudflare R2 pomoću presigned URL-ova u Next.js App Routeru. Implementirat ćete serversko potpisivanje, slojevitu validaciju, opcionalno skeniranje na malware te robusne ponovne pokušaje (retry) bez usmjeravanja bajtova datoteke kroz vaš Next.js server.

Ako trebate kratko objašnjenje zašto je to važno: uploadi preko servera često padaju pod opterećenjem zbog memorijskih ograničenja i timeouta, dok izravni upload u object storage prebacuje “teški dio” na infrastrukturu dizajniranu baš za to.

Ciljana ključna riječ: Next.js file upload presigned URL S3 Cloudflare R2

# Pregled arhitekture: izravni upload, pa provjera#

Siguran upload flow ima tri faze:

  1. 1
    Zahtjev: preglednik traži od vašeg servera dozvolu za upload.
  2. 2
    Upload: preglednik učitava izravno na S3 ili R2 koristeći presigned URL.
  3. 3
    Finalizacija: preglednik javlja serveru da je upload gotov, a server to verificira i evidentira.

Time uklanjate velike request bodyje iz Next.js runtimea, što je ključno na serverless platformama gdje su request bodyji često ograničeni, a dugi uploadi udaraju u timeoute.

Flow izravnog uploada (visoka razina)#

KorakTkoŠto se događaZašto je bitno
1KlijentŠalje filename, size, type vašem API-juMinimalan payload, brz zahtjev
2ServerAuth provjere, validira metapodatke, generira object key, vraća presigned URLCentralna sigurnosna kontrolna točka
3KlijentPUT-a datoteku na S3 ili R2Koristi propusnost storage mreže
4KlijentZove finalize endpoint s key i ETagIzbjegava vjerovanje klijentovim tvrdnjama
5ServerHEAD-a objekt, validira size i content-type, označava statusSprječava spoofing i djelomične uploadove
6OpcionalnoScannerSkenira objekt, pa premješta u safe bucket ili označava clean

🎯 Ključna poruka: Aplikacija bi trebala tretirati datoteku kao nepouzdanu dok se ne verificira na serveru i, za rizične use-caseove, ne skenira.

# Preduvjeti#

ZahtjevVerzijaNapomene
Next.js14 ili 15App Router route handlers korišteni u primjerima
Node.js runtime za potpisivanje18+Presigning koristi crypto i AWS SDK
AWS S3 ili Cloudflare R2R2 koristi S3-kompatibilan endpoint
Auth sustavBilo kojiKoristite session cookies ili JWT; vežite upload uz korisnika

Za obrasce autentikacije pogledajte Next.js opcije autentikacije. Upload endpointi moraju biti zaštićeni, inače presigned URL-ovi postaju javni “relay” za upload.

# Postavke storagea: osnove S3 vs R2#

S3 i R2 podržavaju isti S3-kompatibilni API, ali operativni detalji se razlikuju.

Preporučeni layout bucketa#

Koristite barem dva logička područja:

  • staging: gdje završavaju izravni uploadi
  • public ili processed: gdje premještate datoteke nakon verifikacije i skeniranja

To smanjuje rizik posluživanja nesigurnog sadržaja i olakšava lifecycle politike.

Brza usporedba za ovaj use case#

MogućnostAWS S3Cloudflare R2
Presigned PUT URL-oviDaDa
Multi-part uploadDaDa
Event triggeriJake native opcijeRadi, ali ovisi o vašem stacku
Egress naknadeTipičan cloud pricingČesto niže, posebno preko Cloudflare-a
S3 API kompatibilnostNativeS3-kompatibilan endpoint

# Korak 1: serversko potpisivanje u App Routeru#

Napravit ćete route handler koji validira namjeru uploada i vraća presigned PUT URL plus object key.

Environment varijable#

Credentialse držite isključivo na serveru.

VarijablaPrimjerNapomene
S3_ACCESS_KEY_IDAKIA...Koristite least-privilege IAM
S3_SECRET_ACCESS_KEY...Nikad ne izlažite klijentu
S3_BUCKETmyapp-uploadsOdvojeni bucketi po env-u
S3_REGIONeu-central-1Za R2 često auto
S3_ENDPOINThttps://<account>.r2.cloudflarestorage.comPotrebno za R2

Postavljanje S3 ili R2 klijenta#

Napravite mali helper. Ovo ostaje na serveru.

TypeScript
// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
 
export function getS3Client() {
  return new S3Client({
    region: process.env.S3_REGION!,
    endpoint: process.env.S3_ENDPOINT || undefined,
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID!,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
    },
    forcePathStyle: !!process.env.S3_ENDPOINT,
  });
}

forcePathStyle je često potreban za S3-kompatibilne providere i lokalno testiranje.

Route handler: kreiranje presigned URL-a#

Ovaj endpoint treba:

  • auth provjeru
  • validirati size i type prema vašoj politici
  • generirati siguran object key vezan uz korisnika
  • kreirati kratkotrajni presigned URL
TypeScript
// app/api/uploads/sign/route.ts
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getS3Client } from "@/lib/s3";
 
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"];
 
export async function POST(req: Request) {
  const sessionUserId = req.headers.get("x-user-id"); // replace with real auth
  if (!sessionUserId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
 
  const body = await req.json();
  const fileName = String(body.fileName || "");
  const fileType = String(body.fileType || "");
  const fileSize = Number(body.fileSize || 0);
 
  if (!ALLOWED_TYPES.includes(fileType)) {
    return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
  }
  if (!Number.isFinite(fileSize) || fileSize <= 0 || fileSize > MAX_BYTES) {
    return NextResponse.json({ error: "File too large" }, { status: 400 });
  }
 
  const safeExt = fileType === "application/pdf" ? "pdf" : fileType.split("/")[1];
  const objectKey = `staging/${sessionUserId}/${randomUUID()}.${safeExt}`;
 
  const s3 = getS3Client();
  const bucket = process.env.S3_BUCKET!;
 
  const cmd = new PutObjectCommand({
    Bucket: bucket,
    Key: objectKey,
    ContentType: fileType,
    // Optionally: metadata to help downstream processing
    Metadata: {
      "original-name": fileName.slice(0, 120),
      "uploader-id": sessionUserId,
    },
  });
 
  const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 });
 
  return NextResponse.json({
    uploadUrl,
    key: objectKey,
    maxBytes: MAX_BYTES,
    requiredContentType: fileType,
  });
}

Zašto je kratko trajanje bitno: ako presigned URL procuri, napadač ima mali prozor. Česta production vrijednost je 30 do 120 sekundi.

⚠️ Upozorenje: Ne gradite object key iz filenamea koji šalje korisnik. Path trikovi i kolizije su realni. Uvijek generirajte vlastiti key, a originalno ime spremite kao metadata.

# Korak 2: upload u pregledniku s retryjima i progresom#

Klijent uploada izravno na presigned URL koristeći fetch s PUT. Dodajte retryje jer mobilne mreže pucaju, korporativni proxyji znaju resetirati konekcije, a korisnici zatvaraju laptop usred uploada.

Minimalna funkcija za upload na klijentu#

TypeScript
// lib/uploadDirect.ts
export async function uploadViaPresignedUrl(params: {
  file: File;
  uploadUrl: string;
  contentType: string;
  retries?: number;
}) {
  const { file, uploadUrl, contentType } = params;
  const retries = params.retries ?? 2;
 
  let lastError: unknown;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(uploadUrl, {
        method: "PUT",
        headers: { "Content-Type": contentType },
        body: file,
      });
      if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
      return { etag: res.headers.get("etag") };
    } catch (err) {
      lastError = err;
      await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
    }
  }
  throw lastError;
}

Ako trebate progress, fetch još uvijek nema stabilne evente za upload progress u mnogim preglednicima. Za velike datoteke koristite XMLHttpRequest za progress ili implementirajte multipart upload s client-side bibliotekom.

Orkestracija na klijentu: sign, upload, finalize#

Zadržite finalize korak čak i ako već imate key. To je prilika serveru da verificira i veže upload uz vašu bazu.

TypeScript
// example usage in a client component action
export async function uploadFile(file: File) {
  const signRes = await fetch("/api/uploads/sign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fileName: file.name, fileType: file.type, fileSize: file.size }),
  });
  if (!signRes.ok) throw new Error("Sign failed");
  const { uploadUrl, key, requiredContentType } = await signRes.json();
 
  const { etag } = await uploadViaPresignedUrl({
    file,
    uploadUrl,
    contentType: requiredContentType,
    retries: 2,
  });
 
  const finRes = await fetch("/api/uploads/finalize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key, etag }),
  });
  if (!finRes.ok) throw new Error("Finalize failed");
  return finRes.json();
}

# Korak 3: finalize endpoint sa serverskom verifikacijom#

Finalizacija mora potvrditi da objekt postoji i da odgovara vašim očekivanjima.

Minimalno:

  • osigurati da key pripada korisniku koji traži
  • HEAD-ati objekt i validirati:
    • ContentLength manji ili jednak vašem max
    • ContentType odgovara allowlisti
  • spremiti DB zapis sa statusom uploaded ili pending_scan

Route handler: finalize#

TypeScript
// app/api/uploads/finalize/route.ts
import { NextResponse } from "next/server";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
import { getS3Client } from "@/lib/s3";
 
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"];
 
export async function POST(req: Request) {
  const sessionUserId = req.headers.get("x-user-id"); // replace with real auth
  if (!sessionUserId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
 
  const { key } = await req.json();
  const objectKey = String(key || "");
 
  if (!objectKey.startsWith(`staging/${sessionUserId}/`)) {
    return NextResponse.json({ error: "Invalid key" }, { status: 403 });
  }
 
  const s3 = getS3Client();
  const bucket = process.env.S3_BUCKET!;
 
  const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey }));
 
  const size = Number(head.ContentLength || 0);
  const type = String(head.ContentType || "");
 
  if (size <= 0 || size > MAX_BYTES) {
    return NextResponse.json({ error: "Invalid size" }, { status: 400 });
  }
  if (!ALLOWED_TYPES.includes(type)) {
    return NextResponse.json({ error: "Invalid type" }, { status: 400 });
  }
 
  // Persist to DB here: userId, key, size, type, status
  // For risky content: set status = "pending_scan"
 
  return NextResponse.json({
    ok: true,
    key: objectKey,
    size,
    type,
    status: "uploaded",
  });
}

Zašto je to bitno: metapodatke koje šalje klijent je lako lažirati. Verifikacija preko HEAD-a je jeftina i zaustavlja velik broj zloupotreba.

💡 Savjet: Ako imate CDN ispred, nikad ne servirajte direktno iz staging. Servirajte samo iz zasebnog public prefixa ili bucketa nakon verifikacije i skeniranja.

# Strategija validacije: veličina, tip i sadržaj#

Validacija treba slojeve. Svaki sloj hvata različite probleme.

Validacija na klijentu (UX)#

Provjere na klijentu smanjuju uzaludno vrijeme, ali nisu sigurnost.

  • blokirajte očito pogrešne tipove datoteka
  • prikažite max veličinu prije početka uploada
  • prikažite progress i očekivano preostalo vrijeme

Validacija na serveru (sigurnosna kontrolna točka)#

Server mora provoditi politiku u oba endpointa:

  • u /sign: validirati traženi fileType i fileSize
  • u /finalize: validirati stvarne metapodatke pohranjenog objekta

Ne vjerujte samo MIME tipu#

Preglednici mogu slati application/octet-stream ili pogrešne tipove, a napadači mogu lažirati image/png.

Ako je rizik visok, dodajte barem jedno od:

  • content sniffing nakon uploada
  • detekciju magic-bytes u workeru
  • antivirusno skeniranje prije nego što datoteka postane dostupna

Za širi set sigurnosnih kontrola koristite ovaj checklist: Sigurnosni checklist za web aplikacije.

# Dodatno učvršćivanje sigurnosti: keyjevi, dozvole i zaštita od zloupotrebe#

Endpointi za izravni upload su česta meta za zloupotrebu bandwidtha i pohranu ilegalnog sadržaja. Ove kontrole koristite po defaultu.

IAM ili R2 API token: least privilege#

Credentialsi vašeg servera trebaju moći:

  • PutObject u staging/
  • HeadObject u staging/
  • opcionalno CopyObject u public/ nakon skeniranja
  • opcionalno DeleteObject za čišćenje

Izbjegavajte wildcard pristup bucketu.

Object keyjevi: predvidljivost i kontrola pristupa#

Koristite nepogodive (unguessable) keyjeve i vežite ih uz korisnički namespace.

Dobar obrazac:

  • staging/userId/uuid.ext

Izbjegavajte:

  • uploads/myphoto.png
  • uploads/2026/05/myphoto.png

Kratki expiration i one-time intent#

Expiry presigned URL-a treba biti kratak, ali trebate i intent na razini aplikacije:

  • kreirajte upload_intent zapis s userId, key, maxBytes, type, expiresAt
  • dopustite finalize samo ako postoji odgovarajući intent
  • expirajte i garbage-collectajte intentove

To blokira zloupotrebu “potpiši jednom, uploadaj zauvijek” kad URL procuri.

CORS#

Za izravne browser PUT-ove konfigurirajte bucket CORS da dopušta origin vaše stranice, PUT i potrebne headere.

Česti problemi:

  • nedostaje ETag u exposed headers
  • preširok AllowedOrigin s wildcardom kod autentificiranih aplikacija

Rate limiting#

Ograničite /sign po korisniku i IP-u. Čak i uz kratki expiry, bot može generirati tisuće signed URL-ova u minuti.

# Antivirus i opcije skeniranja malwarea#

Ako korisnici mogu uploadati PDF-ove, Office datoteke, arhive ili bilo što što se može dalje redistribuirati, skeniranje je obično nužno.

Praktičan pristup je tretirati upload kao karantenu dok se ne skenira.

Matrica opcija#

OpcijaGdje se izvodiPrednostiNedostaci
ClamAV u container workeruVaša infrastrukturaNiska cijena, zrelo rješenjeTreba ops i ažuriranja
Managed skeniranje malwareaVendorManje ops poslaDodatni trošak, vendor lock-in
Custom rule-based skeniranjeWorkerPravila po mjeriNije zamjena za AV

Tipičan scanning pipeline#

  1. 1
    Upload ide u staging/ sa statusom pending_scan.
  2. 2
    Background job preuzima objekt u worker i skenira.
  3. 3
    Ako je clean, kopira u public/ i označi clean.
  4. 4
    Ako je zaraženo, obriše i označi rejected.

Skeniranje držite izvan request patha. Čak i male datoteke mogu uzrokovati vremena skeniranja koja prelaze serverless limite.

ℹ️ Napomena: Ako morate procesirati slike, radite to u workeru i ponovno ih enkodirajte. Ponovno enkodiranje uklanja mnoge tehnike zlonamjernih payloadova u metapodacima i smanjuje veličinu za CDN isporuku.

# Rukovanje retryjima, idempotencijom i djelomičnim uploadovima#

Retryji nisu opcionalni u stvarnim uvjetima. Trik je učiniti retryje sigurnima.

Retry pravila koja rade#

Tip greškeŠto napravitiZašto
Network error tijekom PUT-aRetry isti presigned URL ako je još važećiNajčešća prolazna greška
403 iz storageaZatražiti novi presigned URLURL istekao ili signature mismatch
Upload je uspio, ali finalize je paoRetry finalize s istim keyjemUčinite finalize idempotentnim
Korisnik reload-a stranicu usred uploadaNastaviti koristeći spremljeni key i intentIzbjegava orphaned objekte

Učinite finalize idempotentnim#

Finalize mora biti siguran za višestruke pozive. U DB terminima:

  • spremite unique constraint na key
  • ako zapis postoji i pripada korisniku, vratite success

To sprječava duple zapise kad klijent retrya.

Cleanup job za orphaned uploadove#

Očekujte orphaned objekte zbog napuštenih uploadova. Dnevni cleanup job treba brisati:

  • objekte u staging/ starije od 24 do 72 sata
  • intentove kojima je istekao rok

To smanjuje trošak storagea i ograničava izloženost.

# Česte zamke u Next.js implementacijama uploada#

Ovi problemi uzrokuju većinu production incidenata s uploadima.

Zamka 1: upload kroz route handlere#

Uploadanje bajtova datoteke kroz Next.js endpoint je primamljivo, ali krhko:

  • serverless platforme često imaju limite veličine bodyja
  • veliki zahtjevi mogu premašiti execution timeoute
  • skokovi Node memorije mogu srušiti procese kad se više uploadova događa paralelno

Direct-to-storage gotovo u potpunosti uklanja ove failure modeove.

Zamka 2: dugotrajni presigned URL-ovi#

Duga expiracija pretvara jednokratnu autorizaciju u dugotrajnu “capability”. Ako URL procuri kroz logove, browser ekstenzije ili podijeljene screenshotove, postaje vektor zloupotrebe.

Koristite kratki TTL i intent na razini aplikacije.

Zamka 3: posluživanje neskeniranog sadržaja#

Ako servirate direktno iz staging/, praktički objavljujete nepouzdan korisnički sadržaj. To može dovesti do distribucije malwarea, phishinga i štete za brend.

Karantena i skeniranje, pa promocija u public.

Zamka 4: nedostatak observabilityja#

Uploadovi padaju na načine koje korisnici ne mogu precizno opisati. Instrumentirajte flow:

  • logirajte sign i finalize odgovore s correlation ID-jevima
  • pratite rate neuspjelih uploadova po pregledniku i tipu mreže
  • alertirajte na skokove u broju signed URL-ova po korisniku

Koristite strukturirani pristup iz Observability za web aplikacije: logovi, metrike, tracing.

# Production checklist: što isporučiti#

Koristite ovaj checklist prije puštanja u produkciju.

PodručjeMust-havePreporučeno
AuthZaštićeni sign i finalizePer-user kvote
ValidacijaVeličina i allowlist tipovaPost-upload sniffing
SigurnostRandom keyjevi, kratki TTLStaging i promotion bucketi
PouzdanostRetryji na klijentuMultipart za velike datoteke
OpsCleanup za orphansAlerti na abuse patternse
ComplianceAudit logoviMalware skeniranje za rizičan sadržaj

# Ključne poruke#

  • Koristite trokorak: potpis na serveru, izravan upload na S3 ili R2 iz preglednika, zatim finalize sa serverskom verifikacijom.
  • Validaciju provodite dvaput: pri potpisivanju na temelju “claimed” metapodataka i pri finalizaciji pomoću HEAD-a za provjeru stvarne veličine i tipa objekta.
  • Tretirajte staging kao nepouzdan: stavite u karantenu, opcionalno skenirajte na malware, zatim promovirajte na sigurnu lokaciju prije posluživanja.
  • Presigned URL-ove držite kratkotrajnima i vežite ih uz upload intent kako biste smanjili zloupotrebu ako URL procuri.
  • Gradite za greške: dodajte retryje za PUT i napravite finalize idempotentnim, plus pokrenite cleanup job za napuštene uploadove.

# Zaključak#

Izravni upload u object storage je najpouzdaniji način za rad s datotekama u Next.js-u jer izbjegava pritisak na server memoriju i serverless timeoute, uz bolju skalabilnost. Implementirajte obrazac sign-upload-finalize, agresivno validirajte, stavite u karantenu i skenirajte kad treba te učinite retryje sigurnima pomoću idempotentne finalize logike.

Ako želite da Samioda implementira sigurni upload pipeline za S3 ili Cloudflare R2, uključujući skeniranje, observability i hardening usklađen s vašom auth postavkom, kontaktirajte nas i pomoći ćemo vam da to isporučite brzo i sigurno.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.