Skip to content

Latest commit

 

History

History
161 lines (111 loc) · 8.43 KB

File metadata and controls

161 lines (111 loc) · 8.43 KB

Cloudinary Integration

Cloudinary is the centrepiece of this app. It handles upload, AI background removal, content moderation, dynamic card composition, and optimised delivery — without any server-side image processing in our Node process.

Overview

User picks a photo
       │
       ▼
POST /api/sign-upload  (server signs params)
       │
       ▼
Browser POSTs file directly to Cloudinary
       │
       ├─ Cloudinary stores the asset at a deterministic public_id
       │
       └─ Async aws_rek moderation runs
              │
              ▼
       POST /api/cloudinary/webhook  (moderation verdict)
              │
              ├─ approved → card stays, client notified via Supabase Realtime
              └─ rejected → card + asset deleted, user shown reason

1. Signed uploads

Why signed, not unsigned or an upload preset?

Upload presets are global config and can't vary per-user or include dynamic values like the user's ID. Unsigned uploads give the client full control over the destination path and parameters. Signed uploads let the server set the exact public_id, moderation parameters, and notification_url for this specific upload, then prove it with a signature the client can't forge.

Flow (lib/cloudinary/sign.ts + app/api/sign-upload/route.ts):

  1. Component calls POST /api/sign-upload with { kind: 'avatar' | 'card' }.
  2. The route verifies the Supabase session.
  3. getSignedUploadParams() builds the upload params and signs them with HMAC-SHA1 (api_sign_request).
  4. The browser POSTs the file + signed params directly to https://api.cloudinary.com/v1_1/{cloudName}/image/upload.
  5. Cloudinary validates the signature, stores the asset, and starts moderation.

The signature covers timestamp, public_id, overwrite, moderation, and notification_url. Tampering with any of these invalidates it.

2. Folder modes — Fixed and Dynamic

Cloudinary has two folder modes. This app works with both.

Mode How it works
Fixed folders The public_id IS the full path. kickoff/users/abc/avatar → folder kickoff/users/abc, asset name avatar. Slashes in the URL are the folder hierarchy.
Dynamic folders public_id and asset_folder are decoupled. public_id can still contain slashes and those slashes appear in delivery URLs, but folder organisation in the Media Library console is driven by asset_folder, not the public_id path. Default for accounts created after mid-2023.

Why both work: delivery URLs are constructed from public_id regardless of folder mode — cloudinary.url("templates/arg", {...}) produces the same URL in either mode, as long as the asset was uploaded with public_id: "templates/arg". All uploads in this app use signed uploads with explicit public_id, so the path is always set correctly.

What the code does for Dynamic folder accounts: lib/cloudinary/sign.ts includes asset_folder in both the upload parameters and the HMAC signature. Cloudinary uses asset_folder to organise assets in the Media Library console in dynamic-folder mode. In fixed-folder mode, asset_folder is ignored — the folder is inferred from the public_id path. Either way the delivery URL and all SDK calls (cloudinary.api.resource, uploader.destroy, layer overlays) work identically.

Template uploads must always set an explicit public_id. Use the provided script:

pnpm upload-templates --dir ./path/to/template-images

The script uploads each nation template with public_id: "templates/{nation}" and asset_folder: "templates", which works correctly in both modes. Never upload templates using the Cloudinary dashboard's auto-name feature — the resulting public_id will be a UUID and cloudinary.url("templates/arg") will produce a broken URL.

Check your account mode: Cloudinary dashboard → Settings → Upload → Folder behavior.

3. Deterministic public_ids

All user assets live under a user-scoped prefix (lib/cloudinary/publicIds.ts):

kickoff/users/{userId}/avatar               ← avatar (overwrite on re-upload)
kickoff/users/{userId}/uploads/{uuid}       ← per-card photo (unique per upload)
templates/{nation}                          ← card backgrounds (pre-uploaded, no prefix)

isOwnedBy(publicId, userId) is a prefix check used in every server action before any Cloudinary operation. This is the app-layer equivalent of RLS — no user can delete or reference another user's assets.

4. AI background removal

Used in two places (lib/cloudinary/urls.ts):

  • getRemovedBgUrl(publicId) — transparent PNG with background cut out, used as a source for card composition.
  • getAvatarUrl(publicId) — background removed, then face-centred, circle-cropped to 160×160.
// Avatar pipeline
{ effect: 'background_removal' },
{ crop: 'fill', gravity: 'face', width: 160, height: 160, radius: 'max' }

g_face centres the crop on the detected face. radius: 'max' clips to a circle in the Cloudinary layer itself, not in CSS.

Cloudinary caches transformed assets after the first request — background removal only runs once per asset, subsequent requests hit the CDN.

5. Card composition

The showcase featurelib/cloudinary/buildCardUrl.ts.

The card is a single Cloudinary URL. No server-side image generation; Cloudinary executes the transformation chain on first request and caches it.

Transformation chain:

Base: templates/{nation}                    ← 896×1200 card template PNG
  │
  ├─ overlay: {user photo public_id}        ← open the user's photo as a layer
  ├─ effect: background_removal             ← AI-remove the background
  ├─ crop: fill, gravity: face,             ← face-anchored crop to the card's photo window
  │   width: tmpl.photo.w, height: tmpl.photo.h
  ├─ flags: layer_apply, gravity, x, y      ← composite at template coordinates
  │
  ├─ (× 6) overlay: {stat text}            ← stat number as text layer (Arial Bold 44px)
  └─ (× 6) flags: layer_apply, x, y        ← position at template slot
  │
  └─ fetch_format: auto, quality: auto      ← f_auto / q_auto delivery

Layer IDs use : instead of / (Cloudinary escaping). The public_id kickoff/users/abc/uploads/xyz becomes layer id kickoff:users:abc:uploads:xyz.

Stat coordinates come from lib/cloudinary/templates.ts. All six nations currently share a single SHARED layout (card templates are the same design). To add per-nation layouts, add a key to TEMPLATES.

On level-up, awardXp regenerates the card URL with incremented stats. The new URL is stored in cards.card_url. Old URLs continue to work (Cloudinary doesn't delete the cache), but won't reflect the new stats.

6. Content moderation

Add-on required: AWS Rekognition (paid).

Every upload includes the moderation parameter in the signed params:

aws_rek:explicit_nudity:0.4:suggestive:0.4:violence:0.4:rude_gestures:0.4:visually_disturbing:0.4:drugs:0.4:hate_symbols:0.4

0.4 is the rejection threshold per category (0.0–1.0). Cloudinary runs the check asynchronously and calls CLOUDINARY_WEBHOOK_URL with the result.

Webhook (app/api/cloudinary/webhook/route.ts):

  1. Verify the x-cld-signature header to confirm the request is from Cloudinary.
  2. Parse moderation_status (approved or rejected) and public_id.
  3. Update cards.moderation_status in Supabase.
  4. On rejection: delete the card rows and call cloudinary.uploader.destroy(publicId).

The cards table is in the Supabase Realtime publication, so the client's subscription fires immediately on the status change — no polling needed.

7. Delivery optimisation

All card and avatar URLs include:

  • f_auto (fetch_format: 'auto') — Cloudinary serves WebP or AVIF to browsers that support it, falling back to the original format.
  • q_auto — Cloudinary picks the optimal quality level automatically.

These are set in buildCardUrl.ts and urls.ts. Combined they typically cut bandwidth by 30–60% vs naïve JPEG delivery.

8. Environment variables

Variable Purpose
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME Cloud name — safe for client, used in upload URLs
NEXT_PUBLIC_CLOUDINARY_API_KEY API key — safe for client, included in signed upload requests
CLOUDINARY_API_SECRET Used to generate signatures — server only, never exposed
CLOUDINARY_WEBHOOK_URL Public HTTPS URL Cloudinary calls after moderation