Storage
Set up a storage bucket, add a new kind of upload and find the job that deletes old files.
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 find out which job deletes old files.
platform/s3.ts builds a single S3Client with forcePathStyle: true. Because of that setting, switching between AWS S3, Cloudflare R2 and MinIO is a configuration change that doesn't touch 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.
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",
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
forcePathStyle: true,
})| Variable | What it does | Required |
|---|---|---|
S3_REGION | It sets the client's region. | Yes |
S3_BUCKET | It names the bucket Litestore writes to. | Yes |
S3_ACCESS_KEY | It is the access key credential. | Yes |
S3_SECRET_ACCESS_KEY | It is the secret key credential. | Yes |
S3_ENDPOINT | It points the client at R2 or MinIO. If you leave it unset, Litestore uses AWS. | No |
S3_PUBLIC_URL | It is a CDN or custom domain that you put in front of the bucket. | No |
Stored URLs
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, and for an external URL it returns null. This means Litestore never deletes a pasted remote image from a bucket that does not own it.
Object uploads
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 returns the file's URL without query parameters, and it sets two fixed headers on every object:
StorageClass: "STANDARD"
CacheControl: "public, max-age=31536000"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 quick early rejection before the route reads the whole file, and it doesn't determine whether the file is accepted.
The check that determines acceptance is sniffMediaType in modules/media/sniff.ts. It reads the first bytes of the file and returns null for anything it doesn't recognise.
| Signature | Type |
|---|---|
FF D8 FF | JPEG |
| The eight-byte PNG signature | PNG |
GIF87a/GIF89a | GIF |
RIFF…WEBP | WebP |
The 1A 45 DF A3 EBML header | WebM |
ftyp at offset 4 | The MP4 family, where a qt brand means QuickTime |
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. If someone uploads an HTML file and labels it image/png, the route rejects it at this check.
The sniffer has no dependencies.
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 modules/team/admin/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, then the ban status against the database instead of a cached session, and then the media:create permission.
The two customer routes need a signed-in session and a real Customer row, which is the same requirement as submitting a review or sending a message. These routes accept only images, because reviews and support threads never need video, and accepting only images leaves less room for abuse.
Upload rate limits
All four routes use one Redis limiter, rateLimits.upload, which is defined in platform/rate-limit/limiters.ts. It allows each user 10 uploads per hour.
Ratelimit.slidingWindow(10, "1 h")Each route adds its own prefix to the limiter key, so the rule is the same everywhere but each route counts its uploads separately.
upload:
review-upload:
message-upload:
media: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.
Media owners
One exported tuple lists every type of owner:
export const MEDIA_OWNER_TYPES = ["Product", "Variant", "Collection", "User", "Category"] as constThe tuple is in the module root (modules/media/) because both the server and the admin code read it. BaseCRUD.mediaOwnerType uses it to remove associations when a record is permanently deleted, and 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 attaches to a given owner only once.
Besides the general ownerType and ownerId pair, MediaAssociation has one real relation, through reviewId. When you delete a review, the database deletes its media rows too. The five owner types depend on the CRUD cleanup instead.
Media routes
| Route or action | Permission | What it does |
|---|---|---|
app/api/media/route.ts | media:view | It serves the media picker in the admin, returns images only, and pages results at 80 per page at most. |
createMediaAssociation in modules/media/admin/actions.ts (server action) | media:edit | In one call it creates the Media row and its association, and it validates ownerType with mediaOwnerTypeSchema. |
Deletion and orphan sweeps
Before the media delete action removes a row, removeMediaFiles in modules/media/admin/crud.ts deletes the S3 object using storageKey. If the S3 call fails, Litestore logs the error and still deletes the database row, so a file that is already missing from the bucket never leaves a row you cannot delete.
removeS3Directory in modules/media/storage.ts won't run outside production. This stops a developer machine from wiping a shared bucket.
Weekly sweep
jobs/cron.data-cleanup.ts runs once a week. It deletes files that were uploaded but never attached to anything, and it checks two prefixes.
| Prefix | What the sweep deletes |
|---|---|
review-media/ | Objects older than 7 days when no Review.externalImages entry points to them. |
media/ | Objects older than 7 days when no Media.storageKey matches them. Some older rows have a null storageKey, so the sweep works out their key from the URL, and 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.
Adding uploads
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.
New route
File type detection
Run sniffMediaType and write the sniffed type as the object's Content-Type.
Rate-limit prefix
Give the route its own key prefix on rateLimits.upload.
Owner registration
If the new files are attached to a record, add that owner to MEDIA_OWNER_TYPES in the same change.
Sweep coverage
If you introduce a new key prefix, choose whether the weekly sweep should be able to check it. For example, message-media/ isn't swept because the sweep has no table to check it against.
Related
Schema
The core Prisma models in schema.prisma follow shared conventions, and the schema changes safely when you follow a few rules.
Providers
Some external services are swappable, Litestore calls others directly, and the app needs certain environment variables to start.
Crons
Litestore runs scheduled background jobs on Inngest, and each job defines its schedule in its own file.