Skip to content

feat(upload): add pushduck + Cloudflare R2 avatar upload#923

Open
abhay-ramesh wants to merge 1 commit intoslopus:mainfrom
abhay-ramesh:feat/pushduck-r2-upload
Open

feat(upload): add pushduck + Cloudflare R2 avatar upload#923
abhay-ramesh wants to merge 1 commit intoslopus:mainfrom
abhay-ramesh:feat/pushduck-r2-upload

Conversation

@abhay-ramesh
Copy link
Copy Markdown

Closes #417

Problem

Avatar uploads had no client-side path — the flow was designed to route image bytes through the server, which adds unnecessary bandwidth and latency. There was also no UI for users to upload a custom avatar photo.

Solution

Use pushduck to wire up a presigned-URL upload flow. Image bytes go directly from the client to Cloudflare R2 — the server only issues the presigned URL and runs post-processing after the upload completes.

What changed

Server (packages/happy-server)

sources/app/api/routes/uploadRoutes.ts (new)

  • Configures a pushduck cloudflareR2 provider from CLOUDFLARE_R2_* env vars
  • Defines an avatarUpload route:
    • Max 5 MB, images only
    • Auth: reads Authorization: Bearer <token> from the Web Request and calls auth.verifyToken() — auth runs inside pushduck middleware so structured JSON errors are returned on failure
    • Key pattern: public/users/{userId}/avatars/{random12}.{ext}
    • onUploadComplete: fetches the uploaded image from R2 → Sharp (thumbhash + dimensions) → single DB transaction (remove old avatar file records, insert new UploadedFile, update Account.avatar) → emits real-time update-account socket event so all connected clients refresh immediately
    • Graceful degradation: logs a warning at startup and returns 503 when R2 env vars are not set

sources/app/api/api.ts (modified)

  • Registers uploadRoutes at GET /v1/upload and POST /v1/upload

.env.dev (modified)

  • Documents the required CLOUDFLARE_R2_* env vars with setup link

App (packages/happy-app)

sources/sync/apiUpload.ts (new)

Plain async uploadAvatar(credentials, asset) — React Native compatible:

  1. POST /v1/upload?route=avatarUpload&action=presign → presigned URL + key
  2. fetch(asset.uri).blob()PUT directly to R2
  3. POST /v1/upload?route=avatarUpload&action=complete → triggers onUploadComplete

sources/app/(app)/settings/account.tsx (modified)

  • Avatar section is always visible in Settings → Account (previously hidden unless a GitHub avatar existed)
  • Tapping the avatar item opens the system image picker (square crop, 0.85 quality), calls uploadAvatar, then sync.refreshProfile() to pick up the socket update
  • Upload button is disabled while an upload is in progress

Setup

To enable uploads in a self-hosted deployment, set these env vars in happy-server:

CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_R2_BUCKET=happy-uploads
CLOUDFLARE_R2_ACCESS_KEY_ID=your-r2-access-key-id
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
CLOUDFLARE_R2_PUBLIC_URL=https://pub-your-bucket-hash.r2.dev

Create an R2 bucket and API token at https://developers.cloudflare.com/r2/api/s3/tokens/ (Object Read & Write permissions).

Test plan

  • Set R2 env vars and start server — confirm no 503 on /v1/upload
  • Open Settings → Account — Avatar row is visible
  • Tap Avatar → image picker opens, select a photo
  • Upload completes, avatar updates in real time on all connected devices
  • Missing R2 env vars → server logs warning, upload returns 503 (app shows error alert)
  • Uploading a file > 5 MB is rejected with a clear error

Replaces the planned server-proxied upload flow with a direct
client-to-R2 presigned URL flow, so image bytes never transit
the server.

Server (packages/happy-server):
- Add uploadRoutes.ts: pushduck router with `avatarUpload` route
  - cloudflareR2 provider (CLOUDFLARE_R2_* env vars)
  - Bearer-token auth inside pushduck middleware
  - Key pattern: public/users/{userId}/avatars/{random12}.{ext}
  - onUploadComplete: fetch image from R2 → Sharp (thumbhash +
    dimensions) → DB transaction (delete old, insert new
    UploadedFile, update Account.avatar) → real-time socket update
  - Graceful 503 when R2 env vars are not set
- Register uploadRoutes in api.ts (GET + POST /v1/upload)
- Document R2 env vars in .env.dev

App (packages/happy-app):
- Add apiUpload.ts: plain async uploadAvatar() using the
  presign → PUT → complete pattern (React Native compatible)
- account.tsx: avatar item always visible in Profile section;
  image picker with square crop → uploadAvatar → refreshProfile
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore CloudFlare R2 as storage solution

1 participant