Litestore · Developer guide

Storage

Learn how Litestore uploads files to S3-compatible storage, why it sniffs magic bytes instead of trusting the declared MIME type, and how MediaAssociation attaches one file to seven owner types.

Litestore stores every uploaded file in an S3-compatible bucket, and every reference to it in Postgres.

services/s3.ts builds one S3Client with forcePathStyle: true, so AWS S3, Cloudflare R2 and MinIO are all configuration rather than code.

Four upload routes share the same limiter and the same magic-byte validator. The Media and MediaAssociation tables are the record.

The S3 client

services/s3.ts
export const s3Client = new S3Client({
  endpoint: env.S3_ENDPOINT,
  region: env.S3_REGION,
  credentials: {
    accessKeyId: env.S3_ACCESS_KEY,
    secretAccessKey: env.S3_SECRET_ACCESS_KEY,
  },
  maxAttempts: 5,
  retryMode: "standard",
  forcePathStyle: true,
})

Four variables are required:

S3_REGION
S3_BUCKET
S3_ACCESS_KEY
S3_SECRET_ACCESS_KEY

S3_REGION is the client region and S3_BUCKET is the target bucket; the other two are the credentials.

Two are optional:

  • S3_ENDPOINT — set it for R2 or MinIO. Unset means AWS.
  • S3_PUBLIC_URL — a CDN or custom domain in front of the bucket.

How a stored URL is built

getS3PublicEndpoint() returns S3_PUBLIC_URL when set, and otherwise builds:

https://<bucket>.s3.<region>.amazonaws.com

Every stored URL is that prefix plus the object key.

That is what makes storageKeyFromUrl able to recover a key from a URL — and to return null for external URLs, so a pasted remote image is never deleted from a bucket that does not own it.

How the object is written

uploadToS3Storage uses @aws-sdk/lib-storage's multipart Upload with a 5 MB part size and a queue of 4.

It writes two fixed headers:

StorageClass: "STANDARD"
CacheControl: "public, max-age=31536000"

It returns the URL without query parameters.

Upload validation

The admin route at app/api/upload/route.ts caps files at 50 MB and allows seven types:

app/api/upload/route.ts
const ALLOWED_TYPES = [
  // Images. SVG is deliberately EXCLUDED: an SVG is executable markup, so an
  // uploaded `<svg><script>` served from our asset origin is stored XSS — and it
  // would bypass the rich-text sanitizer entirely. Raster + video only.
  "image/jpeg",
  "image/png",
  "image/gif",
  "image/webp",
  // Videos
  "video/mp4",
  "video/webm",
  "video/quicktime",
]

The declared file.type is checked first as a cheap reject before the body is buffered, but it decides nothing.

sniffMediaType in lib/media/sniff.ts reads the actual first bytes and returns null for anything else:

  • FF D8 FF for JPEG
  • The eight-byte PNG signature
  • GIF87a/GIF89a
  • RIFFWEBP
  • The 1A 45 DF A3 EBML header for WebM
  • ftyp at offset 4 for the MP4 family, with a qt brand meaning QuickTime

The sniffed type, never the declared one, is both re-checked against the allowlist and written as the object's Content-Type.

An HTML payload declared as image/png dies at that check.

The sniffer is dependency-free and has its own test file at lib/media/__tests__/sniff.test.ts.

The four upload routes

RouteWho can call itLimitAcceptsKey prefix
app/api/upload/route.tsAdmin with media:create50 MBImages and videomedia/
app/api/reviews/upload/route.tsAuthenticated customer10 MBImages onlyreview-media/
app/api/messages/upload/route.tsAuthenticated customer10 MBImages onlymessage-media/
uploadUserImage in server/shared/user-image-actions.tsThe authenticated user, own folder only512 KBJPEG, PNG, WebPusers/<userId>/

The admin route goes through withAdminAuth, so it gets the same three checks every admin route gets:

  1. role
  2. the fresh-database ban stage
  3. the media:create permission

The two customer routes require a session plus a real Customer row, which is the same gate as submitting a review or sending a message.

They accept images only because reviews and support threads never need video, which narrows the abuse surface.

One rule, four windows

All four share one Redis limiter, rateLimits.upload, defined in lib/rate-limit/core.ts:

Ratelimit.slidingWindow(10, "1 h")

That is 10 uploads per hour per user. Each route namespaces its own key:

upload:
review-upload:
message-upload:
media:

So the four budgets are separate windows over the same rule.

Object keys

Admin keys are media/<nanoid(12)>.<ext>, with the extension taken from the filename and defaulted to bin.

uploadUserImage derives the extension from the MIME type through an explicit MIME_TO_EXTENSION map rather than the filename.

That is what prevents extension spoofing and path traversal into another user's folder.

Media and associations

Media holds the file itself:

type
url
storageKey
dimensions
alt
fileSize
mimeType
User          — who uploaded it

MediaAssociation attaches it to an owner with a position, an isPrimary flag, and per-association caption and alt.

That split is why the same image can be a product's third photo and a collection's hero without being uploaded twice.

The owner vocabulary

The owner vocabulary is one exported tuple:

lib/media/media-asset-types.ts
export const MEDIA_OWNER_TYPES = [
  "Product",
  "Variant",
  "Collection",
  "User",
  "Campaign",
  "Block",
  "Category",
] as const

It lives in lib because both tiers read it. BaseCRUD.mediaOwnerType uses it to clean up associations on permanent delete, and the admin schema validates ownerType against the same tuple.

MediaAssociation.ownerType is a plain string with no foreign key, so an owner missing from one of those lists would orphan rows silently — hence the single source.

Constraints and relations

@@unique([mediaId, ownerType, ownerId]) means one file attaches to a given owner at most once.

campaignId and reviewId are real relations alongside the polymorphic pair, so campaign and review media cascade or null out with their parent. The other five owner types rely on the CRUD cleanup.

The two media routes

app/api/media/route.ts serves the paginated picker behind media:view — images only, at most 80 per page.

app/api/media/associate/route.ts creates the Media row and its association in one call behind media:edit, validating ownerType with mediaOwnerTypeSchema.

Deletion and orphan sweeps

MediaCRUD.beforeDelete deletes the S3 object using storageKey before removing the row.

It continues with the database delete even if the S3 call fails — the error is logged rather than thrown, so a missing object cannot leave an undeletable row.

removeS3Directory refuses to run outside production, as a guard against wiping a shared bucket from a developer machine.

The weekly sweep

functions/cron.data-cleanup.ts runs weekly and sweeps objects that were uploaded but never attached, in both directions:

  • review-media/ objects older than 7 days that no Review.externalImages entry references.
  • media/ objects older than 7 days that no Media.storageKey matches, folding in legacy rows with a null storageKey by deriving it from their URL so an in-use file is never collected.

message-media/ is deliberately not swept. Those attachments are not Media rows, so their references cannot be verified from that table.

Both sweeps are no-ops outside production.

Orphan sweeps issue real S3 deletes

Point a non-production deployment at a production bucket and the weekly cleanup will delete objects it cannot find a row for. The isProd guards assume the bucket belongs to the environment.

Where a new upload goes

Use the admin route when the file is store content an operator manages: product photos, collection heroes, campaign assets. It is the only route that accepts video and the only one that produces media/ keys.

Use a customer route when a shopper supplies the file. Those are images only, 10 MB, and gated on a real Customer row rather than a permission.

Use uploadUserImage when the file belongs to one user's own folder. Its extension comes from the sniffed MIME type, not the filename, precisely because the key contains a user id.

If you add a fifth route, two things are not optional. It must run sniffMediaType and write the sniffed type as the object's Content-Type, and it must namespace its own key against rateLimits.upload.

If the file is attached to something, add its owner to MEDIA_OWNER_TYPES in the same change. And if you introduce a new key prefix, decide deliberately whether the weekly sweep can verify it — message-media/ is unswept because it cannot.

The useful question before adding an upload path is:

Who is allowed to put bytes in the bucket, and what deletes them when nothing references them any more?

On this page