File storage & uploads

Property photos, tenant ID scans, signed contract PDFs, generated invoice PDFs. Cloudflare R2 via S3-compatible API. Presigned upload — API never proxies bytes.

Phase 5 (parts in Phase 1 for property photos)
Updated by audit (May 26):
  • R2 had 2 real incidents in 13 months: Feb 6 2025 (59-min full outage), Mar 21 2025 (1h7m, 100% writes failed, 35% reads).
  • Mirror invoice PDFs to Backblaze B2 ($0.006/GB, 60% cheaper) for legal retention — invoices must be retrievable even if R2 is down.
  • Custom-domain CORS bugs are well-documented. Test CORS in staging before prod.
  • Class A operations (multipart init/complete, list, write) are the silent cost driver. Batch small uploads carefully.
  • Presigned URLs: sign 5min in the past to absorb client clock drift; expiry ≥15min for mobile flaky network.

Storage pick: Cloudflare R2

OptionVerdict
Cloudflare R2Pick. $0.015/GB-mo, zero egress. Killer feature for photos served to mobile/web.
AWS S3~15× more expensive at moderate scale due to egress.
Backblaze B2Cheaper at rest, but more friction; egress fees apply.
MinIO (self-host)Only if compliance forces on-prem. Adds ops overhead.
Hetzner Object StorageFine for EU; R2 + Cloudflare DNS (likely already in use) wins on simplicity.

Client picks

pnpm --filter @spacehub/integrations add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner sharp

Data model

// packages/db/src/schema/files.ts
export const files = pgTable("files", {
  id: uuid().primaryKey().defaultRandom(),
  ownerId: uuid("owner_id").notNull(),
  uploadedByUserId: uuid("uploaded_by_user_id"),
  bucket: text().notNull(),                       // 'spacehub-files'
  key: text().notNull().unique(),                 // 'owners/{ownerId}/properties/{id}/photos/{uuid}.jpg'
  url: text().notNull(),
  thumbnailUrl: text("thumbnail_url"),
  contentType: text("content_type").notNull(),
  sizeBytes: bigint("size_bytes", { mode: "bigint" }),
  width: integer(),                               // for images
  height: integer(),
  attachedTo: text("attached_to"),               // 'property:{id}' | 'contract:{id}' | 'invoice:{id}'
  status: text({ enum: ["pending","ready","quarantined"] }).notNull().default("pending"),
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
}, (t) => [
  index("files_owner").on(t.ownerId),
  index("files_attached").on(t.attachedTo),
]);

Presigned upload flow

  1. Client requests upload URL. POST /v2/files/presign with { contentType, kind, attachedTo? }. Server creates files row with status: pending, returns presigned PUT URL (5-min TTL) + the file row id.
  2. Client PUTs bytes directly to R2. Never through your API.
  3. Client confirms. POST /v2/files/{id}/confirm. Server HEADs the R2 object, validates size + content-type, flips status to ready, optionally queues thumbnail render.
  4. (Async) thumbnail worker downloads original, runs sharp, uploads {key}.thumb.jpg, sets thumbnailUrl.

Code sketch

// packages/integrations/src/storage/r2.ts
import { S3Client, PutObjectCommand, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export const r2 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export async function presignPut(key: string, contentType: string, expiresIn = 300) {
  return getSignedUrl(r2, new PutObjectCommand({
    Bucket: process.env.R2_BUCKET!, Key: key, ContentType: contentType,
  }), { expiresIn });
}

export async function headObject(key: string) {
  return r2.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key }));
}

export function publicUrl(key: string) {
  return `https://files.spacehub.mn/${key}`;     // custom domain in front of R2 public bucket
}
// apps/api/src/routes/files.ts
app.post("/files/presign", withRls, zValidator("json", PresignSchema), async (c) => {
  const session = c.get("session");
  const { contentType, kind, attachedTo } = c.req.valid("json");
  const id = crypto.randomUUID();
  const ext = mimeToExt(contentType);
  const key = `owners/${session.org.id}/${kind}/${id}.${ext}`;

  await c.get("tx").insert(files).values({
    id, ownerId: session.org.id, uploadedByUserId: session.user.id,
    bucket: process.env.R2_BUCKET!, key, url: publicUrl(key),
    contentType, attachedTo, status: "pending",
  });
  const uploadUrl = await presignPut(key, contentType);
  return c.json({ fileId: id, uploadUrl, expiresIn: 300 });
});

app.post("/files/:id/confirm", withRls, async (c) => {
  const id = c.req.param("id");
  const file = await c.get("tx").query.files.findFirst({ where: eq(files.id, id) });
  if (!file) return c.json({ error: { code: "NOT_FOUND" } }, 404);
  const head = await headObject(file.key);
  if (!head.ContentLength) return c.json({ error: { code: "UPLOAD_INCOMPLETE" } }, 400);
  await c.get("tx").update(files).set({
    status: "ready", sizeBytes: BigInt(head.ContentLength),
  }).where(eq(files.id, id));
  if (file.contentType.startsWith("image/")) {
    await outboxQueue.add({ eventType: "file.thumbnail", aggregateId: id, payload: { id } });
  }
  return c.json({ ok: true, url: file.url });
});

Thumbnail worker

import sharp from "sharp";

async function makeThumbnail(fileId: string) {
  const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
  if (!file) return;
  const get = await r2.send(new GetObjectCommand({ Bucket: file.bucket, Key: file.key }));
  const orig = Buffer.from(await get.Body!.transformToByteArray());
  const thumb = await sharp(orig).resize(400, 400, { fit: "inside" }).jpeg({ quality: 80 }).toBuffer();
  const thumbKey = file.key.replace(/(\.[^.]+)$/, ".thumb.jpg");
  await r2.send(new PutObjectCommand({ Bucket: file.bucket, Key: thumbKey, Body: thumb, ContentType: "image/jpeg" }));
  await db.update(files).set({ thumbnailUrl: publicUrl(thumbKey) }).where(eq(files.id, fileId));
}

API surface

POST   /v2/files/presign           # body: { contentType, kind, attachedTo? } → { uploadUrl, fileId }
POST   /v2/files/{id}/confirm      # after PUT, finalize
GET    /v2/files/{id}              # metadata
DELETE /v2/files/{id}              # soft-delete + queue R2 cleanup
GET    /v2/files?attachedTo={ref}&kind=photo

Build steps

  1. R2 bucket + custom domain files.spacehub.mn (Cloudflare DNS + R2 public bucket setting).
  2. Schema + RLS for files.
  3. Storage helpers in packages/integrations/storage.
  4. Presign + confirm endpoints.
  5. Thumbnail worker (BullMQ file.thumbnail queue, concurrency 4).
  6. Web: drag-drop photo uploader for property page (Phase 1).
  7. Flutter: native image picker → presign → PUT → confirm.
  8. Backfill (later): copy v1 image blobs (if any) to R2.

Open questions

Sources