Images

What a file really is, and resizing it

# Images

Two units, and the split between them is the important part.

`Askr.Image` tells you what a file **is** without decoding a pixel. No
dependency, always available, and it is mostly a security unit.

`Askr.Image.Vips` does the work that needs pixels — resizing, cropping,
format conversion — by loading libvips with `dlopen`, the same way TLS
loads OpenSSL. The binary starts without it, and an app that never
resizes an image pays nothing.

## What a file claims, and what it is

```pascal
uses Askr.Image;

var
  F: PUploadedFile;
begin
  F := Req.Multipart.FileAt(0);

  if SniffFormat(F^.Content) = ifUnknown then
    Exit(RespondText('That is not an image.', 400));

  if not ExtensionMatches(F^.SafeName, F^.Content) then
    Exit(RespondText('The file does not match its extension.', 400));
end;
```

**This is the check that matters.** The filename comes from an attacker
and means nothing. So does `Content-Type`: a browser sends whatever the
form said, and a `.exe` can announce itself as `image/png`.

A file named `avatar.jpg` that is actually HTML is a stored XSS: serve it
back with the wrong `Content-Type` and it runs in the reader's browser,
under your domain, with your cookies. `SniffFormat` reads the magic bytes
instead.

## Dimensions without decoding

```pascal
Inf := ReadImageInfo(F^.Content);
if Inf.Ok and ((Inf.Width > 6000) or (Inf.Height > 6000)) then
  Exit(RespondText('That image is too large.', 400));
if Inf.Animated then
  Exit(RespondText('Animated images are not allowed here.', 400));
```

`ReadImageInfo` parses the header only — JPEG's SOF marker, PNG's IHDR,
GIF's screen descriptor, WebP's RIFF chunks. It never allocates a
bitmap, so a 40-megapixel upload costs the same as a thumbnail.

That matters: **decoding an image is how you get a decompression bomb.**
A 100 kB PNG can expand to gigabytes. Checking the dimensions first is
how you refuse it before that happens.

`Animated` is set for animated GIF and WebP, and for APNG. Worth knowing
because an "avatar" that moves is rarely what anyone wanted.

## EXIF is a privacy leak

```pascal
Orient := JpegOrientation(Data);       { read it BEFORE stripping }
if StripJpegMetadata(Data, Clean) then
  Store(Clean);
```

A photo taken on a phone usually carries GPS coordinates. Someone
uploading a profile picture is uploading their home address unless
something removes it.

`StripJpegMetadata` rewrites the JPEG's segments and drops APP1 through
APP15 and COM — where EXIF, XMP and IPTC live. It does not touch the
pixels, so it is fast and lossless. The ICC colour profile in APP2 is
kept: without it, colours can shift visibly, and that is not metadata in
the same sense.

**Read the orientation first.** Phones often store the photo sideways and
set an EXIF flag saying which way up it goes. Strip the metadata without
rotating and the picture is sideways forever.

`ResizeImage` strips metadata too, through libvips, so you do not need
both.

## Resizing

```pascal
uses Askr.Image, Askr.Image.Vips;

Thumb := ResizeImage(Data, 320, 0, ifJpeg, 80);          { width, ratio kept }
Avatar := ResizeImage(Data, 200, 200, ifJpeg, 85, fmCover);
Webp := ConvertImage(Data, ifWebp, 75);
```

A zero for width or height means "work it out from the other one".
`fmInside` fits the image in the box; `fmCover` fills the box and crops
what sticks out — which is what you want for avatars and cards, where
every tile has to be the same size.

**It never scales up.** An image already smaller than the box comes back
at its own size. Enlarging a thumbnail gives you a blurry picture in a
bigger file, and it is never what anyone asked for.

### When libvips is not there

```pascal
if not VipsAvailable then
  LogInfo('image resizing unavailable', ['why', VipsError]);
```

`VipsError` names the package to install for Debian, macOS and Alpine
rather than listing the paths it looked in. Calling `ResizeImage`
without it raises `EVipsError` with that same message.

```sh
apt-get install libvips42     # Debian, Ubuntu
brew install vips             # macOS
apk add vips                  # Alpine
```

## Why this is not pure Pascal, when the crypto is

The crypto in Askr is pure Pascal because **every app with users needs
password hashing**. A framework that puts that on libcrypto cannot call
the dependency optional.

Image processing is not universal. So it follows the same rule as TLS,
Postgres, MySQL and SQLite: loaded with `dlopen` at first use, absent
until you need it, and the binary starts either way.

Writing a JPEG and WebP decoder in Pascal would be several thousand
lines and still slower and less correct than libvips.

## What is not here

**No video.** Transcoding does not belong in a web process: it is
minutes of CPU on a request that has to answer in milliseconds. The
right shape is a job on the [durable queue](queue.md) that shells out to
`ffmpeg` — the same reason the scheduler pushes to the queue rather than
running anything itself. Askr gives you the queue; the `ffmpeg` line is
yours, because the flags depend on what you are making.

**No drawing, no filters, no watermarks.** libvips can do all of it, and
none of it is bound. Resize, crop and convert are what a web framework
actually needs; the rest is an image editor.

**SVG is recognised but never safe to serve from your own origin.** It
is XML, it can carry `<script>`, and a browser will run it. `SniffFormat`
returns `ifSvg` so you can refuse it deliberately. If you must accept
SVG, serve it from a separate domain, or as `Content-Disposition:
attachment`.

**No automatic rotation.** `JpegOrientation` gives you the flag; acting
on it is yours, because it is the one place where doing the obvious
thing silently is worse than asking.

**No image cache.** Resize once, store the result, serve it as a static
file. Doing it per request is a way to turn a CDN miss into a CPU
outage.