image variants - BaryoDev/barakoCMS GitHub Wiki
Image variants: resizing on request
Both download routes take a ?w= and answer with a narrower copy of the image.
GET /api/public/files/{id}?w=640 anonymous, public files only
GET /api/files/{id}?w=640 authenticated, same rules as without the parameter
Leave ?w= off and you get exactly what you got before this existed: the file, unchanged.
What a width actually gets you
Requests are snapped onto a ladder rather than honoured literally. ?w=400 is answered at 640,
?w=640 at 640, ?w=700 at 960. The ladder is:
160 320 640 960 1280 1920 and the cap itself
That is not a rounding convenience, it is the reason the cache is safe to have. Honouring an
arbitrary width means an anonymous caller can walk ?w=1 through ?w=2048 on one public image and
leave two thousand stored blobs behind. Snapping bounds it at one stored variant per rung, so the
worst an attacker gets out of a public image is seven of them.
An image already narrower than the rung is served unchanged. Nothing is ever upscaled.
The cap
?w= above the cap is refused with a 400 that names the cap. The default is 2048.
{
"Files": {
"Images": {
"MaxWidth": 2048,
"MaxSourcePixels": 50000000
}
}
}
Or as environment variables:
Files__Images__MaxWidth=2048
Files__Images__MaxSourcePixels=50000000
MaxWidth set to 0 turns variants off. A ?w= is then ignored and the original is served, which
is the answer every one of these URLs gave before variants existed.
MaxSourcePixels is the other limit, and it is the one people forget. The upload cap is ten
megabytes of compressed bytes, and a ten megabyte PNG can decode to a bitmap of tens of gigabytes.
Dimensions are read from the header before a single pixel is decoded, and an image over the limit is
served at full size rather than resized. Nobody is refused; the server just declines to do the work.
What gets resized
PNG, JPEG and WebP. Everything else is served unchanged, including with a ?w= on it, so a frontend
that appends the parameter to every asset URL does not break on the one that is a PDF.
GIF is left out because resizing an animated one resamples every frame, which makes the cost of a request a property of the file rather than of the requested width, on an anonymous route. AVIF is left out because the bundled decoder has none. Both are served at full size.
An animated WebP is served whole, at full size, for the same reason as a GIF and because the decoder reads only its first frame. The pixel limit is still checked on its header first; serving the original decodes nothing. An animated PNG is different: the decoder sees only its first frame, so its variant is a still of that frame.
A variant keeps its original's format: a PNG stays a PNG, a JPEG a JPEG, a WebP a WebP, and a
lossless WebP stays lossless (read from its VP8L chunk). JPEG and lossy WebP variants are written
at quality 75. That is fixed rather than matched to the source: reading a JPEG's quality back means
estimating it from its quantisation tables, which is only a guess for encoders that did not use the
standard ones.
A variant carries no EXIF, so camera details and location do not follow the image into its copies. A photo stored sideways with an orientation tag is turned upright in the variant's pixels instead, so it shows the way the original does. The rung still applies to the stored width, before turning: a 4032x3024 photo tagged as turned a quarter, asked for at 640, is stored 640x480 and becomes 480x640, which is how it showed before.
Bytes that do not decode are served unchanged too. A download does not fail because a resize did. So are bytes that do not carry the signature of the type the file was stored as, which only a row stored before uploads were checked can have: they never reach a decoder.
The library
Resizing uses SkiaSharp (MIT) over Skia (BSD-3-Clause), with no licence key. The Files module
brings Skia's Linux build that needs nothing beyond libc and libstdc++, so the stock .NET runtime
image runs it on x64 and arm64 without extra packages. Dockerfile.suite keeps only the native
library for the architecture it is building, because a portable publish copies the one for every
platform Skia supports. The native library bundles code under several licences; their notices are
in the suite image under /app/licenses. A host that references BarakoCMS.Files from NuGet and publishes without a
runtime identifier gets those copies too; publish with -r linux-x64 (or the target it runs on), or
prune runtimes/*/native the same way.
Access rules
A variant is reachable exactly when its original is, and there is nothing to configure about that.
- The access check runs on the original, before any resizing is considered. A private file with a
?w=on the public route is a 404 and no work is done. - A cached variant is stored as its own
StoredFilerow pointing at its parent throughParentFileId, and that row is not addressable. Both routes return 404 for an id whoseParentFileIdis set, including for an admin who could read the original.
The second point is the design rather than an extra precaution. If a variant were addressable it would need access rules of its own, and a second copy of an access rule is a copy that can drift out of step with the file it came from. There is one record whose readability is ever decided and it is the original.
Where the bytes go
Through the same IFileStorage as the upload, with the same public-ness, under a key derived from
the original's (public/abc123.png becomes public/abc123_w640.png). The prefix is the one for the
original's visibility, also for an original stored before keys had a prefix. On S3 that means a variant of a public
object gets its own public URL and the route redirects to it; on Postgres the bytes are proxied like
any other.
Variants are made on the first request that asks for them and kept. They are not counted against anything, and there is no eviction: seven rungs per image is a bounded amount of storage, and the cost of keeping them is far below the cost of recomputing them.
What is not here
There is no cropping, no format conversion, no quality parameter and no ?h=. Each of those is a
new axis on the cache key, and the number of stored variants is the product of the axes. If one is
worth adding, it is worth adding with the same ladder treatment.
Deleting an original does not delete its variants, because nothing deletes a file yet: the module
has no delete endpoint. When one arrives it has to sweep ParentFileId.
ParentFileId is indexed on a database created after this shipped. It is not indexed on an upgraded
one: the app runs Marten with AutoCreate.CreateOnly, which creates a table that is missing and
never alters one that already exists, and stored_files exists on every deployed instance. Nothing
in the running code queries by it, so there is nothing slow today, but the sweep above needs it.
Apply migrations/4.2.0/stored-files-parent-index.sql before writing that endpoint.
Generated from docs/image-variants.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.