Media & Images
Ozeaon stores media as objects in a blob store, not as database rows. Every uploaded asset is written to an R2-style object store under a generated, collision-resistant key; the database persists only that key. Rendering code converts the key back to a URL through a single image-URL helper. This page covers the client-side upload transport and the image URL helpers. For the StorageAdapter, the R2 read path, and the server-side moderation pipeline see Storage Abstraction & R2 Integration.
Overview
Section titled “Overview”Key-based indirection buys three things:
- Single source of truth for asset locations. Application code never concatenates storage hosts by hand; it calls
getImageUrlorgetImageUrlFromKey, so a CDN host change is a one-file change. - Cache-friendly, immutable URLs. Uploads are written with
Cache-Control: public, max-age=31536000, immutable. This is safe because each upload gets a unique key — a new image produces a new URL, so cached responses never go stale. - Uniform validation. File-type and file-size policy runs through shared helpers before any write touches storage.
Client Upload Transport
Section titled “Client Upload Transport”src/lib/images/client.ts is the one module every upload surface shares. Every image route answers with the same three outcomes — success, moderation rejection (422 with categories), or failure — so FormData assembly and response normalisation are written once.
Key exports:
uploadModeratedImage<T>(url, file, options?)— single-file upload to a moderating route; returnsModeratedUploadResult<T>.uploadModeratedFiles<T>(url, files, options?)— multi-file wrapper;uploadModeratedImagedelegates to it.imageRejectedMessage(categories)— produces the shared rejection wording:"We couldn't add this image because it may contain <categories>.".ModeratedUploadResult<T>—{ status: "uploaded", data: T } | { status: "rejected", categories } | { status: "failed", message }.
Behaviours worth knowing:
- Dimensions travel with the bytes. For each
image/*file the module measures the image and appendswidth/heightform fields in file order, because server routes map them back to files when insertingimagesrows. - Rejections are structured. A
422response is flattened to the de-duplicated union ofmoderation[].categories; callers pass that array toimageRejectedMessage. 503has dedicated wording. When moderation is unavailable the module substitutes"We couldn't complete the content check. Please try again.".- Aborts are rethrown. A caller passing an
AbortSignalkeeps its ownAbortErrorhandling; only genuine failures collapse into{ status: "failed" }.
Consumers: use-profile-image-upload, use-post-images, FormImageUpload, OrganizationSettingsForm, useArticleAttachments, and the Tiptap editor’s ImageUploadNodeView — every client surface that uploads an image.
Image URL Helpers
Section titled “Image URL Helpers”Application code holds keys, not URLs. To render an asset it calls one of:
getImageUrl({ path: key }) // object form — prefer for new code; extensible with width/quality optionsgetImageUrl(key) // shorthand for the common casegetImageUrlFromKey(key) // explicit alias that signals the argument is a storage keyAll three resolve to the same public URL via src/utils/url/image.ts. The object form is preferred in new code because additional options (transform width, quality, format) can be threaded in without touching call sites.
StorageAdapter.getPublicUrl(key) (server-side) produces the same URL from the same key.
Key Generation & Validation
Section titled “Key Generation & Validation”Before calling the adapter, upload paths:
- Validate the file with
validateFileTypeandvalidateFileSizefromsrc/utils/validators/. - Generate a collision-resistant key with
generateUniqueKey(prefix, filename)fromsrc/utils/generators/storage-key.ts. The prefix is a logical folder (e.g.profiles/{userId}/covers); the function appends a unique segment so the same filename uploaded twice produces two distinct keys, making theimmutablecache header safe.
Validation runs before the adapter so a rejected file never consumes a storage write.
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- Disallowed type or oversized file. Rejected by
validateFileType/validateFileSizebeforeuploadFileruns. - Same filename uploaded twice. Two distinct keys, two distinct objects — no overwrite.
generateUniqueKeyowns uniqueness; never hand-build keys. - Asset replaced for an entity. Write a new key and update the stored reference; do not overwrite. The old URL remains valid and cached for up to a year.
- Dangling reference. If the database row holds a key and the object is deleted (or the upload failed silently), the read path returns nothing. The database column is a soft reference, not a foreign key into the bucket.
- Orphaned object. Deleting the database row without calling
deleteFileleaves the object in the bucket. Any account-deletion or cleanup flow must delete objects explicitly by key. - Moderation rejection (
422).uploadModeratedImagereturns{ status: "rejected", categories }; nothing was persisted server-side. RenderimageRejectedMessage(categories). - Moderation unavailable (
503). Returns{ status: "failed" }with the shared “content check” wording; surfaced as retryable. - Upload cancelled.
AbortErroris rethrown; callers that pass asignalkeep their own cancellation handling.
Operational Notes
Section titled “Operational Notes”Every host that serves media must be listed in STORAGE_HOSTS, which is interpolated into the img-src directive of the Content-Security-Policy in next.config.ts. data: and blob: are also allowed in img-src to permit inline previews during upload flows. media-src is restricted to 'self'; features that stream audio or video from the bucket would need it extended.
/api/storage serves assets with Cache-Control: public, max-age=31536000, immutable and a quoted ETag. It also exposes /api/storage/audit. The full caching chain (Cloudflare Cache API → ETag 304 → R2 read) and the adapter’s additional methods (uploadBuffer, getFile, headFile, deleteFiles) are documented on Storage Abstraction & R2 Integration.
Extension Points
Section titled “Extension Points”- New asset categories. Add a new prefix to
generateUniqueKey(e.g.articles/{id}/media). The prefix is the only namespacing mechanism. - URL transformations. Add width/quality/format options to the
getImageUrl({ path, ... })object form. This is why the object form exists. - Serving behavior. Caching policy for reads lives in
/api/storage; tuning TTL, adding range support, or changing the ETag strategy is localized there.
Related Links
Section titled “Related Links”- Moderation: images are moderated before the upload is recorded
src/lib/images/client.ts—uploadModeratedImage/uploadModeratedFiles/imageRejectedMessagesrc/utils/url/image.ts—getImageUrl/getImageUrlFromKeysrc/utils/generators/storage-key.ts—generateUniqueKey- Storage Abstraction & R2 Integration —
StorageAdapter,/api/storageserving path, server-side upload pipeline - Post Images & Reposts — post image upload flow using this transport
next.config.ts— CSPimg-src/media-srcwiring