Mobilni razvoj
FlutterSupabasePohranaSigurnostMobilnoVodič

Flutter + Supabase Upload Datoteka: Sigurna pohrana, potpisani URL-ovi, promjena veličine slika i kontrola pristupa

AO
Adrijan Omićević
·14 min čitanja

# Što ćete izgraditi#

Ovaj vodič pokazuje produkcijski spremnu postavku za Flutter Supabase upload datoteka za privatni korisnički sadržaj: profilne fotografije, račune i slike u chatu. Implementirat ćete upload iz kamere i galerije, promjenu veličine slika na uređaju, otporne pozadinske retry pokušaje te sigurna preuzimanja pomoću kratkotrajnih potpisanih URL-ova.

Također ćete postaviti Supabase Storage politike za write i read pristup, povezati datoteke s redovima u bazi uz RLS i obraditi realne kvarove poput nestabilnih mreža i djelomičnih uploadova.

ℹ️ Napomena: Kontrola pristupa za Supabase Storage provodi se putem Storage politika u Postgresu. RLS na vašim tablicama je odvojena stvar, ali tipično kombinirate oboje kako biste spriječili “puknute” lance autorizacije.

# Preduvjeti#

ZahtjevVerzijaNapomene
Flutter3.22+Radi na iOS-u i Androidu
Dart3.4+Dolazi s Flutterom
Supabase projektNajnovijeStorage omogućen
supabase_flutter2.xAuth + Storage klijent
image_picker1.xKamera i galerija
flutter_image_compress2.xPromjena veličine i kompresija
path1.xOperacije nad putanjama
sqflite ili hiveNajnovijePersistiranje upload queuea
connectivity_plusNajnovijeStanje mreže (opcionalno)

Ako implementirate i auth, realtime i offline sync obrasce, uparite ovaj post s Flutter + Supabase Auth, Realtime i Offline Sync. Za ojačane mobilne sigurnosne pretpostavke pročitajte Ojačavanje sigurnosti Flutter aplikacije.

# Model podataka i strategija bucketa#

Sigurna arhitektura počinje jasnim razdvajanjem odgovornosti:

  1. 1
    Storage bucket drži “sirove” bajtove i provodi pravila na razini objekta.
  2. 2
    Postgres tablica prati metapodatke i provodi tko smije referencirati koji objekt.
  3. 3
    Potpisani URL-ovi daju vremenski ograničen read pristup bez javnog bucketa.

Praktična početna osnova:

  • Naziv bucketa: user_uploads (privatno)
  • Putanja objekta: users/<user_id>/<uuid>.<ext>
  • Tablica: media s owner_id, bucket, path, mime_type, bytes, width, height, created_at

Primjer tablice:

StupacTipSvrha
iduuidPrimarni ključ
owner_iduuidauth.uid()
buckettextU pravilu user_uploads
pathtextStorage object key
mime_typetextZa renderiranje i validaciju
bytesbigintKvote i prikaz u UI-ju
widthintOptimizacije renderiranja slika
heightintOptimizacije renderiranja slika
created_attimestamptzAuditiranje

# Supabase postavljanje: Bucketi, politike i RLS#

1) Kreirajte privatni bucket#

U Supabase Dashboardu:

  • Storage → Buckets → New bucket → user_uploads
  • Postavite bucket na private
  • Opcionalno: uključite restrikcije MIME tipova ako imate poznat skup

Zašto je private bitan: javni bucketi pretvaraju autorizaciju u problem koji rješava samo aplikacija. Potpisani URL-ovi zadržavaju kontrolu pristupa na serveru i vremenski je ograničavaju.

2) Storage politike za upload i čitanje#

Supabase Storage koristi Postgres politike na storage.objects. Tipično dopuštate:

  • Insert: samo autentificirani korisnici, i to samo u vlastitu mapu
  • Select: samo autentificirani korisnici, i to samo svoju mapu
  • Update i delete: isto pravilo kao insert, ako vam treba

Čest obrazac politike provjerava da prvi segment putanje nakon users/ odgovara autentificiranom user id-u.

SQL
-- Allow authenticated users to upload only into users/<uid>/...
create policy "User can upload to own folder"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'user_uploads'
  and (storage.foldername(name))[1] = 'users'
  and (storage.foldername(name))[2] = auth.uid()::text
);
 
-- Allow authenticated users to read only their own files
create policy "User can read own files"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'user_uploads'
  and (storage.foldername(name))[1] = 'users'
  and (storage.foldername(name))[2] = auth.uid()::text
);

⚠️ Upozorenje: Ako koristite format putanje users/<uid>/..., pobrinite se da svaki upload s klijenta točno slijedi taj format. Jedno jedino odstupanje izgledat će kao “nasumičan” 403 u produkciji.

3) RLS za tablicu metapodataka#

Uključite RLS na media i dodajte politike temeljene na vlasniku.

SQL
alter table public.media enable row level security;
 
create policy "Media is readable by owner"
on public.media
for select
to authenticated
using (owner_id = auth.uid());
 
create policy "Media is insertable by owner"
on public.media
for insert
to authenticated
with check (owner_id = auth.uid());
 
create policy "Media is deletable by owner"
on public.media
for delete
to authenticated
using (owner_id = auth.uid());

Ovo osigurava da korisnik može kreirati i dohvaćati metapodatke samo za svoje datoteke, čime se sprječava enumeracija kroz vaš API čak i ako pogode ID-eve.

Zašto trebate i Storage politike i RLS#

Ako zaključate samo Storage, a ne i media, korisnici i dalje mogu dohvatiti metapodatke i saznati putanje, MIME tipove i vremenske oznake. Ako zaključate samo media, a ne Storage, korisnici možda mogu povući “sirove” datoteke direktno. Trebate oba sloja.

# Flutter klijent: Ovisnosti i inicijalizacija#

Dodajte pakete:

Bash
flutter pub add supabase_flutter image_picker flutter_image_compress path uuid sqflite connectivity_plus

Inicijalizirajte Supabase:

Dart
// main.dart
import 'package:supabase_flutter/supabase_flutter.dart';
 
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
 
  await Supabase.initialize(
    url: 'https://YOUR_PROJECT.supabase.co',
    anonKey: 'YOUR_ANON_KEY',
  );
 
  runApp(const MyApp());
}

# Upload tok: Kamera i galerija#

Čist UX nudi i snimanje kamerom i odabir iz galerije, a zatim pokreće isti processing pipeline:

  1. 1
    Odaberi ili snimi sliku
  2. 2
    Validiraj veličinu i tip
  3. 3
    Promijeni veličinu i komprimiraj
  4. 4
    Upload u Storage
  5. 5
    Ubaci (insert) red metapodataka u Postgres
  6. 6
    Generiraj potpisani URL za prikaz

Odabir slike#

Dart
import 'package:image_picker/image_picker.dart';
 
final _picker = ImagePicker();
 
Future<XFile?> pickFromGallery() {
  return _picker.pickImage(
    source: ImageSource.gallery,
    imageQuality: 100,
  );
}
 
Future<XFile?> captureFromCamera() {
  return _picker.pickImage(
    source: ImageSource.camera,
    preferredCameraDevice: CameraDevice.rear,
    imageQuality: 100,
  );
}

💡 Savjet: Ovdje koristite imageQuality: 100 i sami kontrolirajte kompresiju. Oslanjanje na kompresiju iz picker-a daje neujednačen rezultat između uređaja.

# Promjena veličine i kompresija slike (na uređaju)#

Upload fotografija u punoj rezoluciji jedan je od najbržih načina da napuhnete troškove pohrane i bandwidtha. Tipična moderna fotografija s mobitela ima 3 MB do 8 MB i 12 MP do 48 MP. Ako smanjite dulji rub na 1600 px i komprimirate u WebP ili JPEG, često možete smanjiti veličinu za 70% do 90% uz zanemariv utjecaj u UI-ju za avatere i feedove.

Primjer promjene veličine#

Dart
import 'dart:io';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path/path.dart' as p;
 
Future<File> compressForUpload(File input) async {
  final dir = input.parent;
  final outPath = p.join(
    dir.path,
    '${p.basenameWithoutExtension(input.path)}_upload.webp',
  );
 
  final result = await FlutterImageCompress.compressAndGetFile(
    input.path,
    outPath,
    format: CompressFormat.webp,
    quality: 82,
    minWidth: 1600,
    minHeight: 1600,
  );
 
  if (result == null) {
    throw Exception('Image compression failed');
  }
 
  return File(result.path);
}

Produkcijske odluke koje trebate eksplicitno donijeti:

PostavkaPreporučena početna vrijednostZašto je bitno
Dulji rub1280 do 2048 pxPokriva većinu feedova bez rasipanja
Kvaliteta75 do 85Najbolji kompromis za mobitel
FormatWebP na Androidu, JPEG fallback na iOS-uKompatibilnost i veličina
Maks. upload bajtova5 MB do 10 MBSprječava zloupotrebu i timeoutove

# Upload u Supabase Storage#

Kreirajte stabilnu putanju objekta#

Neka putanje budu determinističke i ograničene na user id. Koristite UUID ime datoteke kako biste izbjegli kolizije i curenje informacija.

Dart
import 'package:uuid/uuid.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
 
final supabase = Supabase.instance.client;
final uuid = const Uuid();
 
String buildObjectPath({
  required String userId,
  required String extension,
}) {
  final id = uuid.v4();
  return 'users/$userId/$id.$extension';
}

Upload bajtova uz content type#

Dart
import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart';
 
Future<String> uploadFile({
  required File file,
  required String mimeType,
}) async {
  final user = supabase.auth.currentUser;
  if (user == null) throw Exception('Not authenticated');
 
  final ext = file.path.split('.').last.toLowerCase();
  final path = buildObjectPath(userId: user.id, extension: ext);
 
  await supabase.storage
      .from('user_uploads')
      .upload(
        path,
        file,
        fileOptions: FileOptions(
          contentType: mimeType,
          upsert: false,
        ),
      );
 
  return path;
}

Ubacite red metapodataka nakon uploada#

Storage upload tretirajte kao izvor istine da bajtovi postoje. Metapodatke upisujte tek kad upload uspije.

Dart
Future<String> createMediaRow({
  required String path,
  required String mimeType,
  required int bytes,
}) async {
  final user = supabase.auth.currentUser;
  if (user == null) throw Exception('Not authenticated');
 
  final res = await supabase
      .from('media')
      .insert({
        'owner_id': user.id,
        'bucket': 'user_uploads',
        'path': path,
        'mime_type': mimeType,
        'bytes': bytes,
      })
      .select('id')
      .single();
 
  return res['id'] as String;
}

🎯 Ključna poruka: Prvo upload, zatim insert metapodataka. Ako prvo upišete metapodatke pa upload padne, stvarate “viseće” zapise koje je teško čistiti i koji mogu curiti u UI.

# Pozadinski retry: Izgradnja upload queuea#

Mobilni uploadovi padaju iz razloga koji se ne vide u lokalnom testiranju: liftovi, tuneli, promjene baznih stanica, captive portali i ubijanje aplikacije zbog nedostatka memorije. Minimalni sloj pouzdanosti je upload queue spremljen lokalno, s retryjem uz eksponencijalni backoff.

Dizajn queuea#

Spremite dovoljno stanja da možete ponoviti upload bez UI konteksta:

PoljeTipPrimjerZašto
idtextUUIDJedinstveni job
local_pathtext/data/user/.../tmp.webpLokacija datoteke
mime_typetextimage/webpPotrebno za upload
statustextqueued ili uploading ili failedUI i logika
attemptsint0..NBackoff
next_retry_atintepoch msRaspoređivanje
last_errortextSocketExceptionDebugiranje

Funkcija eksponencijalnog backoffa#

Dart
int computeBackoffSeconds(int attempts) {
  final base = 2;
  final maxSeconds = 300;
  final seconds = base * (1 << (attempts.clamp(0, 8)));
  return seconds > maxSeconds ? maxSeconds : seconds;
}

Skica worker petlje#

Ovo je namjerno jednostavno i radi čak i bez “pravog” background izvršavanja. Možete je pokretati pri startu aplikacije, pri resumeu i nakon promjene konekcije.

Dart
Future<void> processQueueOnce() async {
  final jobs = await db.fetchDueJobs(limit: 3);
  for (final job in jobs) {
    try {
      await db.markUploading(job.id);
 
      final file = File(job.localPath);
      final path = await uploadFile(file: file, mimeType: job.mimeType);
 
      await createMediaRow(
        path: path,
        mimeType: job.mimeType,
        bytes: await file.length(),
      );
 
      await db.markDone(job.id);
    } catch (e) {
      final attempts = job.attempts + 1;
      final backoff = computeBackoffSeconds(attempts);
      await db.markFailed(
        job.id,
        attempts: attempts,
        nextRetryAt: DateTime.now().add(Duration(seconds: backoff)),
        lastError: e.toString(),
      );
    }
  }
}

Reality check za background izvršavanje#

iOS snažno ograničava always-on pozadinske zadatke. Android je fleksibilniji, ali i dalje podliježe OEM battery politikama. Praktičan pristup:

  1. 1
    Persistirajte queue
  2. 2
    Obrađujte ga na otvaranju aplikacije, na resume i nakon povratka konekcije
  3. 3
    Za Android-heavy aplikacije razmotrite WorkManager
  4. 4
    Držite jobove malima tako da prvo komprimirate

Ako trebate server-managed upload pipeline, pogledajte opći presigned URL obrazac u Next.js Upload Datoteka s Presigned URL-ovima i prilagodite arhitekturu za vaš mobilni backend.

# Sigurna preuzimanja s potpisanim URL-ovima#

Privatni bucketi zahtijevaju potpisane URL-ove za read pristup. Aplikacija zatraži URL koji brzo istječe, pa ga koristi u image widgetu ili HTTP klijentu.

Kreiranje potpisanog URL-a#

Dart
Future<String> createSignedUrl({
  required String path,
  int expiresInSeconds = 60,
}) async {
  final res = await supabase.storage
      .from('user_uploads')
      .createSignedUrl(path, expiresInSeconds);
 
  return res;
}

Prikaz slike s kratkotrajnim URL-om#

U praksi biste trebali cacheirati potpisani URL u memoriji za vrijeme trajanja (TTL) kako ne biste ponovno potpisivali pri svakom rebuildanju. Ako koristite biblioteku za cacheiranje slika, provjerite poštuje li query stringove i isteke.

Čest obrazac:

  • Dohvatite potpisani URL kada widget postane vidljiv
  • Osvježite ako padne s 401 ili 403
  • Izbjegavajte duge TTL-ove za osjetljiv medij

⚠️ Upozorenje: Nemojte postavljati istek potpisanog URL-a na sate za privatne korisničke podatke. Ako URL procuri, vrijedi do isteka, a mobilni logovi, proxyji ili crash reportovi ga mogu otkriti.

# Obrasci kontrole pristupa koji izdrže produkciju#

Obrazac A: Datoteke samo za korisnika#

Koristite putanju users/<uid>/... i politike prikazane ranije. Ovo pokriva profilne slike, osobne dokumente i privatne eksportove.

Obrazac B: Dijeljene datoteke, poput chatova ili timova#

Trebate join tablicu koja definira članstvo, a Storage politike je moraju referencirati. Supabase politike mogu upitima dohvaćati iz drugih tablica, pa možete nametnuti da korisnik smije čitati datoteku samo ako je dio chata ili tima koji je posjeduje.

Neka putanja objekta odražava vlasništvo, npr. teams/<team_id>/..., i implementirajte:

  • team_members tablicu s RLS-om
  • Storage select politiku koja provjerava članstvo
  • media red u bazi koji se veže na team id i ima RLS temeljen na članstvu

Ovdje kompleksnost autorizacije brzo raste. Ako vaša aplikacija ima stroge zahtjeve privatnosti, uskladite se s praksama iz Ojačavanje sigurnosti Flutter aplikacije i pretpostavite da se klijenti mogu kompromitirati.

# Praktičan error handling: Što hvatati i što prikazati#

Većina upload grešaka spada u mali skup kategorija. Obradite ih eksplicitno kako bi UI ostao predvidljiv.

KvarSimptomŠto napraviti
Nije autentificiran401 ili null userForsirajte ponovnu prijavu, pauzirajte queue
Politika odbila403Logirajte putanju i user id, provjerite politiku i shemu imenovanja
Mreža ne radiSocketExceptionRetry uz backoff
Timeoutspor uploadSmanjite sliku, retry
Datoteka nedostajelokalno čišćenjeOznačite kao trajno neuspješno i tražite korisnika da ponovno odabere
Duplikat putanjerijetko s UUIDRegenerirajte i retry

Mapiranje grešaka u Dart-u#

Dart
String userMessageFromError(Object e) {
  final msg = e.toString().toLowerCase();
 
  if (msg.contains('not authenticated') || msg.contains('jwt')) {
    return 'Please sign in again to upload files.';
  }
  if (msg.contains('403') || msg.contains('permission')) {
    return 'Upload blocked by permissions. Please contact support.';
  }
  if (msg.contains('socketexception') || msg.contains('network')) {
    return 'No internet connection. Upload will retry automatically.';
  }
  return 'Upload failed. We will retry in the background.';
}

# Varijante slika i strategija promjene veličine#

Imate dvije glavne opcije:

Opcija 1: Promjena veličine na uređaju i upload jedne varijante#

Najbolje za jednostavnost i trošak. Odlično radi za avatere i tipične feedove.

  • Upload: 1 datoteka
  • CDN: manje bajtova
  • Server: bez obrade

Opcija 2: Upload originala i generiranje varijanti#

Najbolje kada trebate više veličina ili želite “future-proof” pristup. Varijante možete implementirati kroz automatizacijski workflow koji kreira thumbnailove i sprema ih uz original.

Jednostavna shema imenovanja:

VarijantaPrimjer putanjeUse case
originalusers/<uid>/<id>.jpgarhiva
thumbusers/<uid>/<id>_thumb.webpliste
mediumusers/<uid>/<id>_md.webpfeed

Ako generirate varijante na serveru, bucket neka ostane private i primijenite ista pravila politika, ili generirajte u zaseban privatni bucket.

💡 Savjet: Ako implementirate server-side resizing, izbjegnite da to radi mobilni klijent. Uploadajte jedan kanonski fajl i neka automatizacija stvara derivate radi konzistentnosti.

# Čišćenje i životni ciklus: Sigurno brisanje datoteka#

Kada korisnici obrišu objavu ili zamijene avatar, uklonite oboje:

  1. 1
    media red
  2. 2
    Storage objekt

Redoslijed je važan zbog mogućnosti oporavka. U mnogim aplikacijama prvo brišete DB zapis, pa pokušate obrisati iz Storagea. Ako Storage delete padne, zakažite cleanup job.

Primjer brisanja:

Dart
Future<void> deleteMedia({
  required String mediaId,
  required String path,
}) async {
  await supabase.from('media').delete().eq('id', mediaId);
  await supabase.storage.from('user_uploads').remove([path]);
}

# Checklist za testiranje#

Testirajte pipeline pod stvarnim ograničenjima:

  1. 1
    Upload iz kamere i galerije na obje platforme
  2. 2
    Uključite airplane mode usred uploada i potvrdite ponašanje retry queuea
  3. 3
    Forsirano zatvorite app tijekom uploada i potvrdite da se job nastavlja kasnije
  4. 4
    Provjerite da korisnik ne može čitati tuđe datoteke mijenjanjem putanje
  5. 5
    Potvrdite da potpisani URL-ovi istječu i da logika osvježavanja radi
  6. 6
    Uploadajte najveću realnu fotografiju koju vaši korisnici stvaraju i izmjerite vrijeme

Za širu offline-first arhitekturu pogledajte Flutter + Supabase Auth, Realtime i Offline Sync.

# Ključne poruke#

  • Bucket držite private, provodite Storage politike po prefiksu putanje i koristite kratkotrajne potpisane URL-ove za preuzimanje.
  • Kombinirajte Storage politike s RLS-om na tablici media kako korisnici ne bi mogli pristupiti ni bajtovima ni metapodacima koje ne posjeduju.
  • Komprimirajte i smanjite slike na uređaju kako biste smanjili upload bajtove za 70% do 90% za tipične mobilne fotografije.
  • Implementirajte upload queue s perzistiranim jobovima i eksponencijalnim backoffom da preživite nestabilne mreže i ubijanja aplikacije.
  • Prvo upload, zatim insert metapodataka, i eksplicitno obradite česte kvarove kako biste izbjegli “viseće” zapise i zbunjujući UI.

# Zaključak#

Robustan Flutter Supabase tok uploada datoteka uglavnom se svodi na konzistentnost i defense-in-depth: predvidljive putanje objekata, stroge politike, potpisani URL-ovi i retry koji polazi od pretpostavke da će mreža zakazati. Ako želite da Samioda implementira sigurne uploadove, pozadinske retry mehanizme i media pipeline od početka do kraja u vašoj Flutter aplikaciji, kontaktirajte nas i pomoći ćemo vam isporučiti rješenje s produkcijskom kontrolom pristupa i observabilityjem.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

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