Media System

Central media table, proxy endpoint, and upload helpers for all file storage

Overview

All file uploads across the system go through a media system that:

  1. Stores S3 coordinates in a media table
  2. Uses a generated filename (e.g., photo-a3f2.jpg) as the public reference instead of raw S3 URLs
  3. Serves content via a proxy endpoint that resolves filenames to S3 and streams the bytes

This keeps S3 bucket details private, allows future storage migration, and provides a single place to manage media lifecycle (soft delete, etc.).

Architecture

sequenceDiagram participant Caller as Upload Caller participant S3 participant DB as media table participant Proxy as GET /api/media/[filename] participant Client Note over Caller,DB: Upload Flow Caller->>DB: createMedia(status COMPLETE) Caller->>S3: uploadToS3(buffer, key) Caller->>Caller: Store filename in target table (likeness_assets, account, etc.) Note over Client,S3: Read Flow Client->>Proxy: GET /api/media/photo-a3f2.jpg Proxy->>DB: getMediaByFilename(filename) Proxy->>S3: getS3Object(key, bucket) Proxy-->>Client: stream content with Content-Type

Media Table

Location: packages/db/src/schema/media.ts

ColumnTypeDescription
idtext (PK)nanoid
filenametext (unique)Public reference: {originalBase}-{4char}.{ext}
originalFilenametextOriginal file name
s3KeytextS3 object key
s3BuckettextS3 bucket name
s3RegiontextAWS region
contentTypetextMIME type
statusenumUPLOADING, COMPLETE, SOFT_DELETE, DELETE
createdAt, updatedAttimestampAudit fields

Filename format: {base}-{suffix}.{ext} — e.g., headshot_studio-a3f2.jpg. The 4-character suffix (from nanoid) ensures uniqueness.

Access Functions

Location: packages/db/src/access/media.ts

FunctionPurpose
createMedia(data)Insert new media record
getMediaByFilename(filename)Lookup by unique filename (used by proxy)
getMediaById(id)Lookup by primary key
updateMediaStatus(id, status)Transition status (e.g., to SOFT_DELETE)

Upload Helpers

Location: packages/util-srv/src/s3/media-upload.ts

Import: import { uploadMediaFile, uploadMediaFromUrl, getMediaUrl } from "@zooly/util-srv"

uploadMediaFile(buffer, originalFilename, contentType, basePath)

Uploads a buffer to S3 and returns metadata for the caller to persist in the media table.

  • Returns: { filename, s3Key, s3Bucket, s3Region, contentType }
  • S3 key: {basePath}/{filename} (e.g., likeness-image-assets/photo-a3f2.jpg)
  • Does not create the DB record — the caller does that with createMedia()

uploadMediaFromUrl(sourceUrl, basePath, defaultContentType?, defaultExtension?)

Fetches content from a URL, then delegates to uploadMediaFile. Used for social scraping (profile images) and URL-based asset imports.

getMediaUrl(filename)

Builds the proxy URL for a filename:

{NEXT_PUBLIC_APP_URL}/api/media/{filename}

Example: https://app.example.com/api/media/photo-a3f2.jpg

Proxy Endpoint

Endpoint: GET /api/media/[filename]

Location: apps/zooly-app/app/api/media/[filename]/route.ts

Authentication: None (media URLs are public, same as S3 was)

Behavior:

  1. Extracts filename from route params
  2. Calls getMediaByFilename(filename)
  3. If not found or status is SOFT_DELETE or DELETE → 404
  4. If status is UPLOADING → 202 (file still uploading)
  5. Fetches bytes from S3 via getS3Object(s3Key, s3Bucket)
  6. Returns Response with Content-Type and Cache-Control: public, max-age=31536000, immutable

Usage in Asset Uploads

Asset uploads use the media system as follows:

  1. Image assets: addImageAssetsuploadMediaFilecreateMedia → store filename as contentUrl in likeness_assets
  2. Voice assets: addVoiceAssets → same pattern with basePath: "likeness-voice-assets"
  3. Social scraping: scrapeSocialuploadMediaFromUrlcreateMedia → store filename as account.imageUrl and contentUrl
  4. Voice samples (ElevenLabs TTS): createVoiceSampleuploadMediaFile → store filename as voiceSampleUrl and voiceExample

When returning data to clients (e.g., in search results or list endpoints), filenames are mapped through getMediaUrl() so the UI receives proxy URLs, never raw S3 URLs.