Storage
This page shows you how Litestore stores uploaded files, checks what they really are, and attaches them to products and other records.
Litestore keeps every uploaded file in an S3-compatible bucket and records each one in Postgres. Read this page when you set up a bucket, add a new kind of upload, or need to know what deletes old files.
services/s3.ts builds a single S3Client with forcePathStyle: true. Because of that setting, you can switch between AWS S3, Cloudflare R2 and MinIO by changing configuration, without touching code.
Litestore has four upload routes. They all use the same rate limiter and the same check that reads a file's first bytes (its "magic bytes"). The Media and MediaAssociation tables record what was uploaded and where it is used.
The S3 client
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,
})You must set these four variables:
S3_REGION
S3_BUCKET
S3_ACCESS_KEY
S3_SECRET_ACCESS_KEYS3_REGION sets the client's region and S3_BUCKET names the bucket Litestore writes to. The other two are the credentials.
You can also set two optional variables:
S3_ENDPOINTpoints the client at R2 or MinIO. If you leave it unset, Litestore uses AWS.S3_PUBLIC_URLis a CDN or custom domain that you put in front of the bucket.
How a stored URL is built
getS3PublicEndpoint() returns S3_PUBLIC_URL when you set it. Otherwise it builds this address:
https://<bucket>.s3.<region>.amazonaws.comLitestore stores every file URL as that prefix followed by the object key.
Because every URL follows this pattern, storageKeyFromUrl can recover the key from a URL. For an external URL it returns null. This means Litestore never deletes a pasted remote image from a bucket that does not own it.
How the object is written
uploadToS3Storage uploads the file in parts with the multipart Upload from @aws-sdk/lib-storage. Each part is 5 MB, and up to 4 parts upload at once.
It sets two fixed headers on every object:
StorageClass: "STANDARD"
CacheControl: "public, max-age=31536000"It returns the file's URL without query parameters.
Upload validation
The admin route at app/api/upload/route.ts accepts files up to 50 MB in seven formats:
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 route first checks the type the browser declared in file.type. This is only a cheap early reject before the route reads the whole file, and it does not decide whether the file is accepted.
The real check is sniffMediaType in lib/media/sniff.ts. It reads the first bytes of the file and recognizes these signatures:
FF D8 FFfor JPEG- The eight-byte PNG signature
GIF87a/GIF89aRIFF…WEBP- The
1A 45 DF A3EBML header for WebM ftypat offset 4 for the MP4 family, where aqtbrand means QuickTime
For anything else it returns null.
The route checks the sniffed type against the allowed list again, and it writes the sniffed type as the object's Content-Type. It never uses the declared type for either step. So if someone uploads an HTML file and labels it image/png, the route rejects it at this check.
The sniffer has no dependencies, and you can find its tests in lib/media/__tests__/sniff.test.ts.
The four upload routes
| Route | Who can call it | Limit | Accepts | Key prefix |
|---|---|---|---|---|
app/api/upload/route.ts | Admin with media:create | 50 MB | Images and video | media/ |
app/api/reviews/upload/route.ts | Authenticated customer | 10 MB | Images only | review-media/ |
app/api/messages/upload/route.ts | Authenticated customer | 10 MB | Images only | message-media/ |
uploadUserImage in server/shared/user-image-actions.ts | The authenticated user, own folder only | 512 KB | JPEG, PNG, WebP | users/<userId>/ |
The admin route runs through withAdminAuth, so it gets the same three checks as every other admin route:
- It checks the caller's role.
- It checks the ban status against the database, not a cached session.
- It checks the
media:createpermission.
The two customer routes need a signed-in session and a real Customer row. Submitting a review or sending a message uses the same requirement.
These routes accept only images, because reviews and support threads never need video. Refusing video gives abusers less to work with.
One rate limit with four separate counters
All four routes use one Redis limiter, rateLimits.upload, which is defined in lib/rate-limit/core.ts:
Ratelimit.slidingWindow(10, "1 h")This allows each user 10 uploads per hour. Each route adds its own prefix to the limiter key:
upload:
review-upload:
message-upload:
media:The rule is the same everywhere, but each route counts its uploads separately.
Object keys
The admin route names files media/<nanoid(12)>.<ext>. It takes the extension from the filename and uses bin when there is none.
uploadUserImage works differently. It looks up the extension from the MIME type in an explicit MIME_TO_EXTENSION map and ignores the filename.
This stops a user from faking the extension or using the filename to write into another user's folder.
Media and associations
A Media row describes the file itself:
type
url
storageKey
dimensions
alt
fileSize
mimeType
User — who uploaded itA MediaAssociation row attaches that file to an owner. It stores a position and an isPrimary flag, and it can give the file its own caption and alt for that owner.
Because the file and its uses are separate rows, the same image can be a product's third photo and a collection's hero image without being uploaded twice.
Which records can own media
One exported tuple lists every type of owner:
export const MEDIA_OWNER_TYPES = [
"Product",
"Variant",
"Collection",
"User",
"Campaign",
"Block",
"Category",
] as constThe tuple is in lib because both the server and the admin code read it. BaseCRUD.mediaOwnerType uses it to remove associations when a record is permanently deleted. The admin schema uses the same tuple to validate ownerType.
MediaAssociation.ownerType is a plain string with no foreign key. If an owner type were missing from one of those lists, associations for it would be left behind without any error. Keeping one shared list prevents that.
Constraints and relations
@@unique([mediaId, ownerType, ownerId]) means a file can be attached to a given owner only once.
Besides the general ownerType and ownerId pair, MediaAssociation has real relations through campaignId and reviewId. When a campaign or review is deleted, the database deletes or clears its media rows too. The other five owner types depend on the CRUD cleanup instead.
The two media routes
app/api/media/route.ts serves the media picker in the admin. It requires media:view, returns images only, and pages results at 80 per page at most.
app/api/media/associate/route.ts requires media:edit. In one call it creates the Media row and its association, and it validates ownerType with mediaOwnerTypeSchema.
Deletion and orphan sweeps
Before MediaCRUD.beforeDelete removes a row, it deletes the S3 object using storageKey.
If the S3 call fails, Litestore logs the error and still deletes the database row. A file that is already missing from the bucket therefore never leaves a row you cannot delete.
removeS3Directory refuses to run outside production. This stops a developer machine from wiping a shared bucket.
The weekly sweep
functions/cron.data-cleanup.ts runs once a week. It deletes files that were uploaded but never attached to anything, and it checks two prefixes:
- It deletes
review-media/objects older than 7 days when noReview.externalImagesentry points to them. - It deletes
media/objects older than 7 days when noMedia.storageKeymatches them. Some older rows have a nullstorageKey, so the sweep works out their key from the URL. This way a file that is still in use is never deleted.
The sweep skips message-media/ on purpose. Message attachments are not Media rows, so the sweep has no table to check them against.
Outside production, both sweeps do nothing.
Orphan sweeps issue real S3 deletes
If you point a non-production deployment at a production bucket, the weekly
cleanup will delete objects it cannot find a row for. The isProd guards
assume that each environment has its own bucket.
Choosing a route for a new upload
Use the admin route when the file is store content that an operator manages, such as product photos, collection hero images and campaign assets. It is the only route that accepts video and the only one that creates media/ keys.
Use a customer route when a shopper provides the file. These routes accept images up to 10 MB, and they require a real Customer row instead of a permission.
Use uploadUserImage when the file belongs in one user's own folder. Because the key contains the user's id, this route takes the extension from the sniffed MIME type and never from the filename.
If you add a fifth route, you must do two things. Run sniffMediaType and write the sniffed type as the object's Content-Type. Then give the route its own key prefix on rateLimits.upload.
If the new files are attached to a record, add that owner to MEDIA_OWNER_TYPES in the same change. If you introduce a new key prefix, decide on purpose whether the weekly sweep can verify it. message-media/ stays unswept because the sweep cannot verify it.
Before you add any upload path, answer one question:
Who is allowed to put files in the bucket, and what deletes those files once nothing uses them?
Related
Schema
See the core Prisma models in schema.prisma, the conventions they follow, and how to change the schema safely.
Providers
See which external services you can swap, which are wired in directly, and which environment variables the app needs to start.
Crons
See what each of the 13 Inngest crons in functions/ does and when it runs.