File Uploads
This guide implements a complete file upload flow: client-side upload via signed URLs, metadata storage in Data with RLS, image processing via Functions, and access control.
Prerequisites: Running Reactor server with [storage] and [data] configured.
1. Storage configuration
Section titled “1. Storage configuration”[storage]backend = "fs"fs_base_path = "./.reactor/blobs"signing_secret = "{{ env REACTOR_STORAGE_SIGNING_SECRET }}"signed_url_expiry_secs = 3600max_upload_size = 104857600 # 100 MB[storage]backend = "s3"s3_bucket = "my-app-uploads"s3_region = "auto"s3_endpoint = "https://xxx.r2.cloudflarestorage.com"signing_secret = "{{ env REACTOR_STORAGE_SIGNING_SECRET }}"signed_url_expiry_secs = 3600max_upload_size = 524288000 # 500 MB2. Database schema
Section titled “2. Database schema”-- migrations/003_uploads.sql
CREATE TABLE uploads ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, filename TEXT NOT NULL, content_type TEXT NOT NULL, size_bytes BIGINT, storage_path TEXT NOT NULL, bucket TEXT NOT NULL DEFAULT 'uploads', status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'uploaded', 'processing', 'ready', 'failed')), metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now());
ALTER TABLE uploads ENABLE ROW LEVEL SECURITY;
CREATE POLICY uploads_owner ON uploads FOR ALL USING (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid) WITH CHECK (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid);3. Upload flow
Section titled “3. Upload flow”The recommended pattern: create a pending record, get a signed upload URL, upload directly to storage, then confirm.
sequenceDiagram Client->>Data: Insert pending upload record Client->>Storage: Request signed upload URL Storage-->>Client: Signed PUT URL Client->>Storage: PUT file bytes Client->>Functions: Confirm upload (optional processing) Functions->>Data: Update status to readyStep 1: Create pending record
Section titled “Step 1: Create pending record”import { createClient } from "@reactor/client";
const reactor = createClient({ url: "https://api.myapp.com" });
export async function initiateUpload(file: File) { const storagePath = `${crypto.randomUUID()}/${file.name}`;
const { data: upload } = await reactor.data .from("uploads") .insert({ filename: file.name, content_type: file.type, size_bytes: file.size, storage_path: storagePath, bucket: "uploads", status: "pending", }) .select() .single();
return upload;}Step 2: Get signed upload URL
Section titled “Step 2: Get signed upload URL”export async function getSignedUploadUrl(upload: Upload) { const { signed_url, token } = await reactor.storage .from(upload.bucket) .createSignedUploadUrl(upload.storage_path, { contentType: upload.content_type, expiresIn: 3600, });
return { signed_url, token };}Step 3: Upload file directly to storage
Section titled “Step 3: Upload file directly to storage”export async function uploadFile(file: File, signedUrl: string) { const response = await fetch(signedUrl, { method: "PUT", headers: { "Content-Type": file.type, "Content-Length": String(file.size), }, body: file, });
if (!response.ok) { throw new Error(`Upload failed: ${response.status}`); }}Step 4: Confirm and process
Section titled “Step 4: Confirm and process”export async function confirmUpload(uploadId: string) { // Trigger processing function await reactor.functions.invoke("process-upload", { upload_id: uploadId, });}Complete React component
Section titled “Complete React component”import { useState } from "react";import { initiateUpload, getSignedUploadUrl, uploadFile, confirmUpload } from "../lib/uploads";
export function FileUploader({ onComplete }: { onComplete: (id: string) => void }) { const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return;
try { setProgress(10); const upload = await initiateUpload(file);
setProgress(30); const { signed_url } = await getSignedUploadUrl(upload);
setProgress(50); await uploadFile(file, signed_url);
setProgress(80); await confirmUpload(upload.id);
setProgress(100); onComplete(upload.id); } catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); } }
return ( <div> <input type="file" onChange={handleFileSelect} accept="image/*,.pdf" /> {progress > 0 && progress < 100 && <progress value={progress} max={100} />} {error && <p className="error">{error}</p>} </div> );}4. Processing function
Section titled “4. Processing function”Generate thumbnails and update metadata after upload:
import { ReactorClient } from "@reactor/client";
export default async function handler(req: Request) { const { upload_id } = await req.json(); const reactor = ReactorClient.service();
const { data: upload } = await reactor.data .from("uploads") .select("*") .eq("id", upload_id) .single();
if (!upload) return Response.json({ error: "Not found" }, { status: 404 });
await reactor.data .from("uploads") .update({ status: "processing" }) .eq("id", upload_id);
try { // Download original const blob = await reactor.storage .from(upload.bucket) .download(upload.storage_path);
const metadata: Record<string, unknown> = {};
// Image processing if (upload.content_type.startsWith("image/")) { const thumbPath = upload.storage_path.replace(/(\.[^.]+)$/, "_thumb$1"); const thumbnail = await generateThumbnail(await blob.arrayBuffer()); await reactor.storage.from(upload.bucket).upload(thumbPath, thumbnail, { contentType: upload.content_type, }); metadata.thumbnail_path = thumbPath; }
await reactor.data.from("uploads").update({ status: "ready", metadata, }).eq("id", upload_id);
return Response.json({ ok: true }); } catch (err) { await reactor.data.from("uploads").update({ status: "failed" }).eq("id", upload_id); throw err; }}5. Download with signed URLs
Section titled “5. Download with signed URLs”Never expose raw storage paths to clients. Generate time-limited download URLs:
export async function getDownloadUrl(uploadId: string) { const { data: upload } = await reactor.data .from("uploads") .select("storage_path, bucket, status") .eq("id", uploadId) .single();
if (!upload || upload.status !== "ready") { throw new Error("Upload not available"); }
const { signed_url } = await reactor.storage .from(upload.bucket) .createSignedUrl(upload.storage_path, 3600); // 1 hour
return signed_url;}6. Multipart uploads (large files)
Section titled “6. Multipart uploads (large files)”For files over 100 MB, use multipart upload:
// Initiate multipartconst { upload_id, parts } = await reactor.storage .from("uploads") .createMultipartUpload(storagePath, { contentType: file.type, partSize: 10 * 1024 * 1024, // 10 MB parts });
// Upload each partconst completedParts = [];for (let i = 0; i < parts.length; i++) { const chunk = file.slice(parts[i].start, parts[i].end); const etag = await fetch(parts[i].signed_url, { method: "PUT", body: chunk, }).then(r => r.headers.get("ETag"));
completedParts.push({ part_number: i + 1, etag });}
// Completeawait reactor.storage.from("uploads").completeMultipartUpload(upload_id, completedParts);7. Access control patterns
Section titled “7. Access control patterns”RLS ensures users see only their uploads. Downloads require authenticated signed URL generation.
// Function generates a public signed URL with longer expiryconst { signed_url } = await reactor.storage .from("uploads") .createSignedUrl(path, 86400 * 7); // 7 daysConfigure bucket policy for truly public assets (avatars, static assets):
INSERT INTO _reactor_storage.bucket_policies (bucket, policy)VALUES ('public-assets', '{"public_read": true}');8. Cleanup job
Section titled “8. Cleanup job”Schedule deletion of abandoned pending uploads:
{ "id": "cleanup-pending-uploads", "handler": "cleanup-uploads", "triggers": [{ "type": "cron", "schedule": "0 * * * *" }]}const cutoff = new Date(Date.now() - 3600_000).toISOString(); // 1 hour
const { data: stale } = await reactor.data .from("uploads") .select("id, storage_path, bucket") .eq("status", "pending") .lt("created_at", cutoff);
for (const upload of stale ?? []) { await reactor.storage.from(upload.bucket).remove([upload.storage_path]); await reactor.data.from("uploads").delete().eq("id", upload.id);}See Scheduled jobs for cron setup.
Summary
Section titled “Summary”| Step | Capability | Action |
|---|---|---|
| Create record | Data | Insert pending upload with RLS |
| Get upload URL | Storage | Signed PUT URL |
| Upload bytes | Storage | Direct client → storage (no server proxy) |
| Process | Functions | Thumbnails, validation, metadata |
| Download | Storage | Signed GET URL with expiry |
Related
Section titled “Related”- Security — RLS and signed URL secrets
- Scheduled jobs — cleanup cron
- Configuration —
[storage]reference