# Server crop

> [llms.txt](https://zriz.dev/llms.txt)

Send the photo and its crop value to your server, and cut the full-size original there with sharp.

The page at https\://zriz.dev/docs/server-crop runs this cropper: a 3:2 photo, the straighten dial with rotate and flip, and open, reset and send in the corners. Send shows the value and the sharp plan.

## Install sharp

A browser export can come out smaller than the crop. [Loading images](https://zriz.dev/docs/loading-images.md) has the size limits, and [errors](https://zriz.dev/docs/errors.md) explains `blank`. sharp has no canvas and never retries smaller, so cut on the server when you need the full‑size crop.

The route runs on your server and needs `sharp`. Add it and `@zriz/core` as in [exporting](https://zriz.dev/docs/exporting.md):

```sh
pnpm add sharp @zriz/core
```

Use sharp 0.35.5 or newer and keep it updated. It bundles libvips and every image decoder, so an old sharp means old decoders. [Security](https://zriz.dev/docs/security.md) lists the advisories.

## Send the file and the value

The demo above uploads nothing. Its send button works out the plan in the browser, the way the route does, and shows the value and the `toSharp` steps under the dial, with numbers rounded to four places.

`getValue()` returns the crop value, and `serialize` turns it into a small JSON string. Send that, not the export plan, so the server works out which pixels to keep on its own. The upload button waits for `onLoad`, because a value means nothing without its photo.

`crop-limits.ts` holds `minSize` and `maxZoom` for `Root` and for the route, so the server recomputes the plan the client saw. Change them there, and both ends stay the same.

A bad request comes back as text, and the alert shows it. A photo the browser cannot open shows the same alert.

Each pick bumps a counter that `Root` takes as its `key`, so every photo starts from the full frame. [Loading images](https://zriz.dev/docs/loading-images.md) explains why. Safari fires no `change` for the same file twice, so `onClick` clears the input first.

`last` holds the upload in flight. A new upload, a new pick or an unmount aborts it, so an answer for an old photo never shows under a new one. Starting an upload clears the old result and error, and the effect on `result` revokes each URL it leaves behind.

```tsx
const unreadable =
  "This photo does not open. Pick a JPEG, PNG, WebP or AVIF.";

const [picks, setPicks] = useState(0);

const last = useRef<AbortController | null>(null);

useEffect(() => () => last.current?.abort(), []);

useEffect(
  () => () => {
    if (result) URL.revokeObjectURL(result);
  },
  [result]
);

const upload = async () => {
  const value = actions.current?.getValue();

  if (!file || !value) return;
  last.current?.abort();
  const controller = new AbortController();
  const { signal } = controller;

  last.current = controller;
  setError(null);
  setResult(null);
  const form = new FormData();

  form.append("file", file);
  form.append("value", serialize(value));

  try {
    const response = await fetch("/api/crop", {
      method: "POST",
      body: form,
      signal,
    });

    if (response.ok) {
      const blob = await response.blob();

      if (!signal.aborted) {
        setResult(URL.createObjectURL(blob));
      }
    } else {
      const message = await response.text();

      if (!signal.aborted) setError(message);
    }
  } catch {
    if (!signal.aborted) {
      setError("The upload did not reach the server.");
    }
  }
};

<input
  aria-label="Choose a photo"
  type="file"
  accept="image/jpeg,image/png,image/webp,image/avif"
  onClick={(event) => {
    event.currentTarget.value = "";
  }}
  onChange={(event) => {
    last.current?.abort();
    setReady(false);
    setError(null);
    setResult(null);
    setPicks((count) => count + 1);
    setFile(event.target.files?.[0] ?? null);
  }}
/>

<Cropper.Root
  key={picks}
  actionsRef={actions}
  maxZoom={MAX_ZOOM}
  minSize={MIN_SIZE}
  src={file}
  svg={false}
  onLoad={() => setReady(true)}
  onError={() => setError(unreadable)}
>

<button type="button" disabled={!ready} onClick={upload}>
  Upload
</button>
```

## Cut the original with sharp

`rotate()` with no angle turns the upload upright from its EXIF tag. The route reads the value with `parse`, builds a cropper at the size of that upright image and asks it for the plan. A value out of range lands inside the photo, the way it does in the browser. `toSharp` then turns the plan into steps for sharp.

A crop with no straighten comes back as `"extract"`: a rectangle, a flop, a turn in steps of 90° and a resize to the size of the plan. A straightened crop comes back as `"affine"`: extract the `source` window, the part of the photo the crop can touch, pad at least 2 px on each side by copying the edge, turn with a bicubic affine, then extract the crop.

In one pipeline sharp runs `extend()` after `affine()`, and `affine()` after `extract()` and `rotate()`, so the route ends each stage as a raw buffer and opens a new pipeline for the next one. Each new pipeline passes `limitInputPixels: false`, since the upload already passed the check and a turned crop can grow past it. The first one holds the limit.

`toSharp` does not apply perspective, so the route answers 422 for a value with `perspective`. Export those in the browser instead.

```ts
type Raw = { data: Buffer; info: OutputInfo };
const open = (image: Raw) =>
  sharp(image.data, {
    raw: image.info,
    limitInputPixels: false,
  });
const raw = (pipeline: Sharp) =>
  pipeline
    .timeout({ seconds: MAX_SECONDS })
    .raw()
    .toBuffer({ resolveWithObject: true });

const cut = async (input: Buffer, value: string) => {
  const saved = parse(value);

  if (!saved) throw new TypeError("not a crop value");
  const upright = await raw(
    sharp(input, { limitInputPixels: MAX_PIXELS }).rotate()
  );
  const cropper = createCropper({
    image: upright.info,
    maxZoom: MAX_ZOOM,
    minSize: MIN_SIZE,
  });

  cropper.setValue(saved);
  const plan = cropper.getPixelCrop();

  if (!plan || plan.homography)
    throw new RangeError("no flat plan");
  const steps = toSharp(plan, upright.info);

  if (steps.kind === "extract") {
    const turned = await raw(
      open(upright)
        .extract(steps.extract)
        .flop(steps.flop)
        .rotate(steps.rotate)
    );

    return open(turned).resize({
      ...steps.resize,
      fit: "fill",
    });
  }

  const [[a, b], [c, d]] = steps.affine;
  const padded = await raw(
    open(upright).extract(steps.source).extend(steps.extend)
  );
  const turned = await raw(
    open(padded).affine([a, b, c, d], steps.options)
  );

  return open(turned).extract(steps.extract);
};
```

The upload is cut as pixels, and each stage drops everything else, so EXIF, GPS, XMP, thumbnails, gain maps and bytes after the image never reach the answer. Don't add `keepMetadata()` or `keepExif()`. [Security](https://zriz.dev/docs/security.md) explains why.

## Answer the request

The route is a Next.js route handler saved as `app/api/crop/route.ts`. A JPEG at quality 92 with clear pixels filled white comes back when all goes well. Otherwise:

| Status | When |
| - | - |
| `400` | The form lacks the file or the value, or sharp cannot open the file |
| `401` | `canCrop` says no |
| `413` | The body is over 50 MB or declares no `content-length`, or the photo is over `MAX_PIXELS` |
| `415` | A HEIC or other HEIF that is not AVIF, because prebuilt sharp has no HEVC decoder |
| `422` | The crop cannot be cut, or the value is over 4096 characters |
| `503` | `MAX_RUNNING` crops are already running in this process |

Chromium and Firefox ignore the orientation tag of a WebP and Safari honours it, so the value can describe a different frame from the one sharp turns upright. The route answers 422 for a WebP with an orientation above 1 instead of cutting the wrong pixels.

`canCrop` in `can-crop.ts` answers `false` until you connect your session check, so a copied route refuses everyone. Replace its body before you ship.

The value and the file come from the browser, so treat both as hostile. The top of the route holds the limits:

```ts
const MAX_BODY = 50 * 1024 * 1024;
const MAX_VALUE = 4096;
const MAX_PIXELS = 120_000_000;
const MAX_RUNNING = 2;
const MAX_SECONDS = 30;
const HEADERS = {
  "cache-control": "private, no-store",
  "x-content-type-options": "nosniff",
};

sharp.block({ operation: ["VipsForeignLoad"] });

sharp.unblock({
  operation: [
    "VipsForeignLoadJpegBuffer",
    "VipsForeignLoadPngBuffer",
    "VipsForeignLoadWebpBuffer",
    "VipsForeignLoadHeifBuffer",
  ],
});
```

A small PNG can ask for a huge image, and the bytes on the wire say nothing about the memory it takes. A photo at `MAX_PIXELS` peaked at about 2 GB in our test, for a 460 kB upload at a 45° tilt, so give the server that much per running crop. `MAX_PIXELS` is the one constant to raise, and only with memory to match. 120 million covers a 108 megapixel phone photo, and a 200 megapixel one gets 413. `MAX_RUNNING` counts one process, so add a rate limit per user on top.

`sharp.block()` turns off every decoder but JPEG, PNG, WebP and AVIF for the whole process, because the route never needs SVG, TIFF, GIF or the others. Move it if another route in the same process does.

```ts
const readForm = async (request: Request) => {
  let seen = 0;
  const capped = request.body?.pipeThrough(
    new TransformStream<Uint8Array, Uint8Array>({
      transform(chunk, stream) {
        seen += chunk.length;
        if (seen > MAX_BODY) stream.error(new RangeError());
        else stream.enqueue(chunk);
      },
    })
  );
  const form = await new Response(capped, {
    headers: request.headers,
  })
    .formData()
    .catch(() => null);

  return { form, over: seen > MAX_BODY };
};

const answer = async (request: Request) => {
  const { form, over } = await readForm(request);

  if (over) return refuse("This upload is too large.", 413);
  const file = form?.get("file");
  const value = form?.get("value");

  if (
    !(file instanceof File) ||
    file.size === 0 ||
    typeof value !== "string"
  ) {
    return refuse("Send a file and a crop value.", 400);
  }

  if (value.length > MAX_VALUE) {
    return refuse("This crop does not fit this photo.", 422);
  }

  const input = Buffer.from(await file.arrayBuffer());
  const meta = await sharp(input)
    .metadata()
    .catch(() => null);

  if (!meta)
    return refuse("The server cannot open this file.", 400);

  if (meta.format === "heif" && meta.compression !== "av1") {
    return refuse("Send a JPEG, PNG, WebP or AVIF.", 415);
  }

  if (meta.width * meta.height > MAX_PIXELS) {
    return refuse("This photo has too many pixels.", 413);
  }

  if (meta.format === "webp" && (meta.orientation ?? 1) > 1) {
    return refuse(
      "save this webp without an orientation tag.",
      422
    );
  }

  try {
    const crop = await cut(input, value);
    const jpeg = await crop
      .timeout({ seconds: MAX_SECONDS })
      .flatten({ background: "#ffffff" })
      .jpeg({ quality: 92 })
      .toBuffer();

    return new Response(new Uint8Array(jpeg), {
      headers: { ...HEADERS, "content-type": "image/jpeg" },
    });
  } catch {
    return refuse("This crop does not fit this photo.", 422);
  }
};
```

```ts
export const POST = async (request: Request) => {
  if (!(await canCrop(request))) {
    return refuse("Sign in to crop a photo.", 401);
  }

  const length = Number(request.headers.get("content-length"));

  if (!(length > 0) || length > MAX_BODY) {
    return refuse("This upload is too large.", 413);
  }

  if (running >= MAX_RUNNING) {
    return refuse("The server is busy. Try again.", 503);
  }

  running += 1;

  try {
    return await answer(request);
  } finally {
    running -= 1;
  }
};
```

## All four files

Save `crop-limits.ts`, `can-crop.ts` and `crop-upload.tsx` next to the `app` folder. That is where the imports look for them.

`crop-limits.ts` is shared by both:

```ts
export const MIN_SIZE = { width: 1, height: 1 };
export const MAX_ZOOM = 15;
```

`can-crop.ts` runs on the server. Connect your session check here:

```ts
export const canCrop = async (_request: Request) => false;
```

`crop-upload.tsx` runs in the browser:

```tsx
"use client";

import { serialize } from "@zriz/core";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useRef, useState } from "react";
import { MAX_ZOOM, MIN_SIZE } from "./crop-limits";

const unreadable =
  "This photo does not open. Pick a JPEG, PNG, WebP or AVIF.";

export const CropUpload = () => {
  const [file, setFile] = useState<File | null>(null);
  const [ready, setReady] = useState(false);
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [picks, setPicks] = useState(0);
  const actions = useRef<CropperApi>(null);
  const last = useRef<AbortController | null>(null);

  useEffect(() => () => last.current?.abort(), []);

  useEffect(
    () => () => {
      if (result) URL.revokeObjectURL(result);
    },
    [result]
  );

  const upload = async () => {
    const value = actions.current?.getValue();

    if (!file || !value) return;
    last.current?.abort();
    const controller = new AbortController();
    const { signal } = controller;

    last.current = controller;
    setError(null);
    setResult(null);
    const form = new FormData();

    form.append("file", file);
    form.append("value", serialize(value));

    try {
      const response = await fetch("/api/crop", {
        method: "POST",
        body: form,
        signal,
      });

      if (response.ok) {
        const blob = await response.blob();

        if (!signal.aborted) {
          setResult(URL.createObjectURL(blob));
        }
      } else {
        const message = await response.text();

        if (!signal.aborted) setError(message);
      }
    } catch {
      if (!signal.aborted) {
        setError("The upload did not reach the server.");
      }
    }
  };

  return (
    <>
      <input
        aria-label="Choose a photo"
        type="file"
        accept="image/jpeg,image/png,image/webp,image/avif"
        onClick={(event) => {
          event.currentTarget.value = "";
        }}
        onChange={(event) => {
          last.current?.abort();
          setReady(false);
          setError(null);
          setResult(null);
          setPicks((count) => count + 1);
          setFile(event.target.files?.[0] ?? null);
        }}
      />
      <Cropper.Root
        key={picks}
        actionsRef={actions}
        maxZoom={MAX_ZOOM}
        minSize={MIN_SIZE}
        src={file}
        svg={false}
        onLoad={() => setReady(true)}
        onError={() => setError(unreadable)}
      >
        <Cropper.Viewport
          style={{ maxWidth: 640, height: 480 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
        <Cropper.Dial
          aria-label="Straighten"
          style={{ maxWidth: 361 }}
        >
          <Cropper.DialTicks />
        </Cropper.Dial>
      </Cropper.Root>
      <button type="button" disabled={!ready} onClick={upload}>
        Upload
      </button>
      {error && <p role="alert">{error}</p>}
      {result && (
        <img src={result} alt="Crop from the server" />
      )}
    </>
  );
};
```

`app/api/crop/route.ts` runs on the server:

```ts
import { createCropper, parse, toSharp } from "@zriz/core";
import sharp from "sharp";
import type { OutputInfo, Sharp } from "sharp";
import { canCrop } from "../../../can-crop";
import { MAX_ZOOM, MIN_SIZE } from "../../../crop-limits";

const MAX_BODY = 50 * 1024 * 1024;
const MAX_VALUE = 4096;
const MAX_PIXELS = 120_000_000;
const MAX_RUNNING = 2;
const MAX_SECONDS = 30;
const HEADERS = {
  "cache-control": "private, no-store",
  "x-content-type-options": "nosniff",
};

sharp.block({ operation: ["VipsForeignLoad"] });

sharp.unblock({
  operation: [
    "VipsForeignLoadJpegBuffer",
    "VipsForeignLoadPngBuffer",
    "VipsForeignLoadWebpBuffer",
    "VipsForeignLoadHeifBuffer",
  ],
});

let running = 0;

const refuse = (text: string, status: number) =>
  new Response(text, { headers: HEADERS, status });

type Raw = { data: Buffer; info: OutputInfo };
const open = (image: Raw) =>
  sharp(image.data, {
    raw: image.info,
    limitInputPixels: false,
  });
const raw = (pipeline: Sharp) =>
  pipeline
    .timeout({ seconds: MAX_SECONDS })
    .raw()
    .toBuffer({ resolveWithObject: true });

const cut = async (input: Buffer, value: string) => {
  const saved = parse(value);

  if (!saved) throw new TypeError("not a crop value");
  const upright = await raw(
    sharp(input, { limitInputPixels: MAX_PIXELS }).rotate()
  );
  const cropper = createCropper({
    image: upright.info,
    maxZoom: MAX_ZOOM,
    minSize: MIN_SIZE,
  });

  cropper.setValue(saved);
  const plan = cropper.getPixelCrop();

  if (!plan || plan.homography)
    throw new RangeError("no flat plan");
  const steps = toSharp(plan, upright.info);

  if (steps.kind === "extract") {
    const turned = await raw(
      open(upright)
        .extract(steps.extract)
        .flop(steps.flop)
        .rotate(steps.rotate)
    );

    return open(turned).resize({
      ...steps.resize,
      fit: "fill",
    });
  }

  const [[a, b], [c, d]] = steps.affine;
  const padded = await raw(
    open(upright).extract(steps.source).extend(steps.extend)
  );
  const turned = await raw(
    open(padded).affine([a, b, c, d], steps.options)
  );

  return open(turned).extract(steps.extract);
};

const readForm = async (request: Request) => {
  let seen = 0;
  const capped = request.body?.pipeThrough(
    new TransformStream<Uint8Array, Uint8Array>({
      transform(chunk, stream) {
        seen += chunk.length;
        if (seen > MAX_BODY) stream.error(new RangeError());
        else stream.enqueue(chunk);
      },
    })
  );
  const form = await new Response(capped, {
    headers: request.headers,
  })
    .formData()
    .catch(() => null);

  return { form, over: seen > MAX_BODY };
};

const answer = async (request: Request) => {
  const { form, over } = await readForm(request);

  if (over) return refuse("This upload is too large.", 413);
  const file = form?.get("file");
  const value = form?.get("value");

  if (
    !(file instanceof File) ||
    file.size === 0 ||
    typeof value !== "string"
  ) {
    return refuse("Send a file and a crop value.", 400);
  }

  if (value.length > MAX_VALUE) {
    return refuse("This crop does not fit this photo.", 422);
  }

  const input = Buffer.from(await file.arrayBuffer());
  const meta = await sharp(input)
    .metadata()
    .catch(() => null);

  if (!meta)
    return refuse("The server cannot open this file.", 400);

  if (meta.format === "heif" && meta.compression !== "av1") {
    return refuse("Send a JPEG, PNG, WebP or AVIF.", 415);
  }

  if (meta.width * meta.height > MAX_PIXELS) {
    return refuse("This photo has too many pixels.", 413);
  }

  if (meta.format === "webp" && (meta.orientation ?? 1) > 1) {
    return refuse(
      "save this webp without an orientation tag.",
      422
    );
  }

  try {
    const crop = await cut(input, value);
    const jpeg = await crop
      .timeout({ seconds: MAX_SECONDS })
      .flatten({ background: "#ffffff" })
      .jpeg({ quality: 92 })
      .toBuffer();

    return new Response(new Uint8Array(jpeg), {
      headers: { ...HEADERS, "content-type": "image/jpeg" },
    });
  } catch {
    return refuse("This crop does not fit this photo.", 422);
  }
};

export const POST = async (request: Request) => {
  if (!(await canCrop(request))) {
    return refuse("Sign in to crop a photo.", 401);
  }

  const length = Number(request.headers.get("content-length"));

  if (!(length > 0) || length > MAX_BODY) {
    return refuse("This upload is too large.", 413);
  }

  if (running >= MAX_RUNNING) {
    return refuse("The server is busy. Try again.", 503);
  }

  running += 1;

  try {
    return await answer(request);
  } finally {
    running -= 1;
  }
};
```

The demo above keeps the limits and parts of `crop-upload.tsx`. It adds rotate, flip, handles, a grid and reset, shows the angle to one decimal and uses the styles from the zriz site.
