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.
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
| Option | Verdict |
|---|---|
| Cloudflare R2 | Pick. $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 B2 | Cheaper at rest, but more friction; egress fees apply. |
| MinIO (self-host) | Only if compliance forces on-prem. Adds ops overhead. |
| Hetzner Object Storage | Fine for EU; R2 + Cloudflare DNS (likely already in use) wins on simplicity. |
Client picks
@aws-sdk/client-s3+@aws-sdk/s3-request-presigner— R2 is S3-compatible.sharpv0.34+ for thumbnails (in-process on upload).- Skip
uploadthing(vendor lock-in) andtus-js-server(only worth it if Flutter sees >10% upload failures on 3G — reconsider later).
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
- Client requests upload URL.
POST /v2/files/presignwith{ contentType, kind, attachedTo? }. Server createsfilesrow withstatus: pending, returns presigned PUT URL (5-min TTL) + the file row id. - Client PUTs bytes directly to R2. Never through your API.
- Client confirms.
POST /v2/files/{id}/confirm. Server HEADs the R2 object, validates size + content-type, flips status toready, optionally queues thumbnail render. - (Async) thumbnail worker downloads original, runs sharp, uploads
{key}.thumb.jpg, setsthumbnailUrl.
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
- R2 bucket + custom domain
files.spacehub.mn(Cloudflare DNS + R2 public bucket setting). - Schema + RLS for
files. - Storage helpers in
packages/integrations/storage. - Presign + confirm endpoints.
- Thumbnail worker (BullMQ
file.thumbnailqueue, concurrency 4). - Web: drag-drop photo uploader for property page (Phase 1).
- Flutter: native image picker → presign → PUT → confirm.
- Backfill (later): copy v1 image blobs (if any) to R2.
Open questions
- Public vs signed reads? Property photos are essentially public (tenants share listings). ID scans MUST be signed reads (5-min TTL). Two buckets, two rules.
- Image processing for tenant photo galleries:
imgproxylater if on-the-fly resize is needed. Day-one: just thumbnail + original. - Virus scan? Not for property photos. For tenant ID scans, consider
clamavin a separate worker.