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 StoredFile row pointing at its parent through ParentFileId, and that row is not addressable. Both routes return 404 for an id whose ParentFileId is 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.