Storage

Photo uploads to Supabase Storage with per-job progress.

The Upload sample is an Example feature: pick a photo, upload it to a private Supabase Storage bucket scoped to the signed-in user, with per-job progress. It's the reference to copy when you add your own file features.

How it works

UploadsRepository talks to Supabase Storage directly (no separate data source — there's no row-mapping to isolate) and returns an AsyncStream<UploadStatus> so the view can render live progress:

enum UploadStatus: Sendable {
    case progress(Double)
    case completed(UploadedFile)
    case failed(String)
}

The flow: PhotosPicker in UploadView hands the picked image data to UploadViewModel, which calls UploadsRepository.upload(data:fileName:contentType:userId:) and drives the UI off the emitted UploadStatus values.

Where files go

Uploads write to the sample-uploads bucket under a per-user path so RLS can scope access by folder:

sample-uploads/<user-id>/<timestamp>-<short-uuid>-<sanitized-filename>

The user ID is lowercased and becomes the first path segment. Filenames are sanitized to [A-Za-z0-9.-_], and uploads use upsert: false (each upload is a new object).

The iOS app uses Storage only — it does not write a sample_uploads table row (the Expo original does). If you need a queryable index of uploads, add a table and insert after completed, mirroring the Data Layer repository shape.

Bucket and policies

Create the private bucket (also shown in Supabase Setup):

insert into storage.buckets (id, name, public, file_size_limit)
values ('sample-uploads', 'sample-uploads', false, 52428800)  -- 50 MB
on conflict (id) do nothing;

Then add storage.objects policies that gate each row by its first path segment matching the caller's UID — this is what makes the per-user folder private:

create policy "Users can read own sample upload objects"
  on storage.objects for select to authenticated
  using (bucket_id = 'sample-uploads'
    and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Users can upload own sample upload objects"
  on storage.objects for insert to authenticated
  with check (bucket_id = 'sample-uploads'
    and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Users can update own sample upload objects"
  on storage.objects for update to authenticated
  using (bucket_id = 'sample-uploads'
    and (storage.foldername(name))[1] = auth.uid()::text)
  with check (bucket_id = 'sample-uploads'
    and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Users can delete own sample upload objects"
  on storage.objects for delete to authenticated
  using (bucket_id = 'sample-uploads'
    and (storage.foldername(name))[1] = auth.uid()::text);

Cleanup on account deletion

The delete-account Edge Function lists everything under sample-uploads/<user-id>/ and removes it before deleting the auth user, so no orphaned objects remain. A project without the bucket is treated as "nothing to clean up". See Authentication.

Displaying images

For remote images elsewhere in the app, use RemoteImage — the thin wrapper over Kingfisher that handles async loading, caching, and placeholder/error states. See UI Components.

Swapping in your own buckets

  • Change the bucket name by passing bucketName: when constructing UploadsRepository (defaults to sample-uploads).
  • Recreate the four storage.objects policies for your bucket id.
  • Keep the <user-id>/… path prefix so the foldername RLS check still applies, or rewrite the policies to match your own layout.
  • For public assets, create a public bucket and drop the SELECT policy — but never make the user-scoped bucket public.

On this page