Skip to content

Latest commit

 

History

History
147 lines (125 loc) · 10.2 KB

File metadata and controls

147 lines (125 loc) · 10.2 KB

lib/cloudinary

All Cloudinary calls in unshutter live here (see AGENTS.md). This directory depends on two named transformations that must exist in the Cloudinary console before Preview/OG URLs will resolve — they are not created by code.

Required Cloudinary console setup

  1. Strict Transformations: ON for the account/environment used by this app (Settings → Security). Without this, derived versions of type: private assets are publicly addressable without going through an allowlisted named transformation — the watermark would be bypassable.

  2. Named transformation preview (renders as t_preview in URLs) — allowed_for_strict: true. Must contain:

    • a watermark overlay (l_<watermark-overlay-public-id> + positioning)
    • q_auto:low (aggressive compression — the degradation is the product)
    • c_limit,w_1200 (size cap)
  3. Named transformation og (renders as t_og in URLs) — allowed_for_strict: true. Must contain:

    • w_1200,h_630,c_auto,g_auto (smart crop to exact OG dimensions)
    • a generative-fill fallback for source images whose aspect ratio makes a straight crop ugly (e.g. b_gen_fill or an if-conditional variant)
  4. Allowlist the chained f_auto variant of each, since f_auto cannot live inside a named transformation itself (confirmed in docs/VERIFIED.md):

    • t_preview/f_auto
    • t_og/f_auto

    Either add both chained forms to the strict-transformation allowlist directly, or eager-generate them on upload so Cloudinary already has a cached derived asset matching the exact requested transformation string. buildPreviewUrl.ts / buildOgUrl.ts always request exactly image/private/t_preview/f_auto/<public_id> and image/private/t_og/f_auto/<public_id> respectively — the allowlist entry (or eager transformation) must match that exact chained string.

  5. Videos are type: authenticated, not private. The image model (private original + strict-mode-allowlisted derived transformations) did not prove a reliable fit for video delivery in this environment when tested 2026-07-09 — treat that as a local observation, not confirmed Cloudinary behavior. authenticated removes the ambiguity entirely: the original AND every derived asset require a URL signature regardless of strict-mode configuration, which the video URL builders add via sign_url (signatures are deterministic, so the URLs are stable and cacheable). The watermark stays un-bypassable because the only signed URLs the app ever emits are the watermarked t_video_preview/t_video_still renditions and the short-lived signed Download. See originalDeliveryType in uploadOriginal.ts.

  6. Named transformation video_preview (renders as t_video_preview) — the 5-second watermarked Preview of a video Original. Created 2026-07-09 via the Admin API with, in this order:

    • e_preview:duration_5 (AI-selected 5s excerpt — must come first so the watermark/scale apply to the excerpt, not the full video)
    • c_limit,w_640 (low-res — the degradation is the product)
    • the same text watermark overlay as preview (l_text:Arial_60_bold:%C2%A9%20unshutter, white, o_30, relative w_0.5, centered)
    • q_auto:low

    e_preview cannot be generated on the fly (a dynamic request returns HTTP 423 until the derived asset exists), so uploadOriginal.ts eager-generates t_video_preview (as mp4) at ingestion.

  7. Named transformation video_still (renders as t_video_still) — applied to each of the six extracted filmstrip frames. Created 2026-07-09 via the Admin API with:

    • c_limit,w_800
    • the same watermark overlay as above
    • q_auto:low

    Delivered chained after a start offset: video/authenticated/s--<sig>--/so_<N>p/t_video_still/<public_id>.jpg with offsets 14/29/43/57/71/86 (see videoTransformations.ts). All six chains are eager-generated at upload so no viewer waits on frame extraction; the eager strings and the URL builders share their raw transformation strings, keeping them byte-identical.

  8. Webhook: video eager generation runs with eager_async (video analysis takes seconds–minutes) and notifies <NEXT_PUBLIC_SERVER_URL>/api/cloudinary/notify, which verifies the X-Cld-Signature header and flips the doc's previewReady. The storefront hides a video until then. Cloudinary can't POST to localhost — in local dev, tick previewReady manually in the admin once the derived assets exist.

Security note: strict allowlisting is account-wide, per-transformation

Strict Transformations allowlists a transformation string (t_preview, t_og, t_preview/f_auto, …) for every private asset in the account — it is NOT scoped per asset or per folder. Consequence for photosets: a watermarked, low-res (q_auto:low, 1200px-capped) rendition of every set member is publicly derivable via t_preview/f_auto by whoever can name its public_id — the isPreview flag only controls what the storefront links, not what Cloudinary will serve. Member public_ids are guessable (unshutter/sets/<setId>/<filename-slug>), so this effectively exposes a watermarked contact sheet of a whole set.

This is an accepted trade-off (2026-07-09): only the full-resolution, un-watermarked Original is truly gated (raw + arbitrary transforms return 401; delivered only via the signed post-payment zip). If hiding non-preview members ever becomes a requirement, the fix is to upload set members as type: authenticated (like videos) and sign only the curated preview URLs — then unsigned t_preview requests 401 and enumeration yields nothing.

Files

File Responsibility
config.ts Configures the cloudinary v2 SDK singleton from src/lib/env.ts (supports CLOUDINARY_URL or discrete vars), secure: true. Every other file here imports its cloudinary export — nothing else calls cloudinary.config.
publicIds.ts Single source of truth for public_id conventions: unshutter/originals/<slug> for Photos, unshutter/sets/<photosetId>/<filename-slug> for photoset members (filename in the id = human-readable zip entries). Pure, no SDK.
uploadOriginal.ts Uploads a buffer via upload_stream with a deterministic public_id and overwrite: true. Images: type: private + AI options (categorization: 'google_tagging', auto_tagging: 0.6, detection: 'captioning'). Videos (resourceType: 'video'): type: authenticated (see console setup §5) + the eager derived assets from videoTransformations.ts with eager_async + eager_notification_url (no AI — the built-in video tagger is dialogue-only, so video tags are manual). Also sets asset_folder + display_name (see dynamic folders note below). Defensively parses tags + caption + duration from the response. Exports originalDeliveryType and destroyOriginal (both resource-type aware).
videoTransformations.ts Pure single source of truth for video derived-asset transformation strings: t_video_preview (mp4) and the six so_<N>p/t_video_still chains (jpg). Shared by the eager array and the URL builders so both stay byte-identical.
buildPreviewUrl.ts Public unsigned URL string for the watermarked Preview: image/private/t_preview/f_auto/<public_id>. Used by non-JSX consumers (Stripe line-item thumbnails). JSX renders Previews via src/components/PreviewImage.tsx instead — a next-cloudinary CldImage whose strictTransformations + namedTransformations: ['preview/f_auto'] props emit this same allowlisted chain.
buildVideoPreviewUrl.ts Signed URL for a video's 5s watermarked Preview: video/authenticated/s--<sig>--/t_video_preview/<public_id>.mp4 (eager-generated at upload — see console setup above).
buildVideoStillUrls.ts Signed URLs for the six watermarked Stills: video/authenticated/s--<sig>--/so_<N>p/t_video_still/<public_id>.jpg. buildVideoPosterUrl (the first Still) doubles as gallery poster, Stripe thumbnail, and OG image for videos.
buildOgUrl.ts Public unsigned URL for the 1200x630 OG image: image/private/t_og/f_auto/<public_id>. Images only — videos use buildVideoPosterUrl.
buildDownloadUrl.ts Signed, ~10-minute Download URL for the Original via cloudinary.utils.private_download_url. Takes resourceType — the download endpoint is per-resource-type, and the delivery type follows originalDeliveryType.
buildPhotosetZipUrl.ts Signed, ~10-minute URL streaming a zip of a photoset's member Originals via cloudinary.utils.download_zip_url (generate_archive mode=download; verified live against type: private assets). Zip entry names come from the public_ids — which is why member public_ids encode the source filename (setImagePublicId). No console setup required.
verifyNotificationSignature.ts SDK wrapper verifying Cloudinary webhook signatures for /api/cloudinary/notify.

Dynamic folders

This account uses dynamic folders (asset_folder decoupled from public_id). Verified empirically: uploading with a slash-containing public_id (e.g. unshutter/originals/<slug>) and no explicit asset_folder lands the asset with an empty asset_folder — it shows as uncategorized in the Media Library even though the public_id still encodes the intended path, and delivery URLs are unaffected either way (they're keyed off public_id, not asset_folder). uploadOriginal.ts sets asset_folder: ORIGINALS_FOLDER and display_name: <slug> explicitly so Originals are organized under unshutter/originals in the Media Library. publicIds.ts remains the single source of truth for the deterministic, slash-containing public_id — this is purely an admin-UI organization improvement with zero effect on URL semantics.

Testing without real credentials

vitest.setup.ts sets a fake CLOUDINARY_URL (cloud_name test-cloud) before any test file loads, so every test in this directory — including ones that import config.ts transitively — runs deterministically without a real Cloudinary account. config.test.ts overrides this further per-test (via vi.resetModules()) to exercise both the CLOUDINARY_URL and discrete-vars branches. uploadOriginal.test.ts mocks the cloudinary uploader entirely — no network calls are made by pnpm test:int.