# Upload limits, and the 413 that keeps coming back

## What goes wrong

Attachments — property photographs, lease documents, member ID scans, staff
photos — are stored as base64 data URLs inside the record. There is no file
server yet, so **a file's size is the request's size**, and base64 adds about a
third on top.

When the body exceeds what the web server accepts, the request is rejected
**before PHP runs**. No route executes, no validation happens, and the reply is
the host's own HTML page:

> Request Entity Too Large
> The requested resource does not allow request data with GET requests, or the
> amount of data provided in the request exceeds the capacity limit.

Nothing in Laravel or the frontend can turn that into a helpful message after
the fact, and the whole save is lost — including every field that was typed.

## The measured limit

**The dev server (kskbdev.graspsoft.xyz) refuses any body over 1,048,576 bytes —
exactly 1 MB.**

Measured by POSTing bodies of increasing size to a route that does not exist, so
nothing is written and no login is attempted. Laravel answers 404 when the body
got through; the web server answers 413 when it did not:

```
   1024 KB  ->  HTTP 404   (reached Laravel)
   1100 KB  ->  HTTP 413   (refused by the web server)
```

A limit of exactly 1 MB is the signature of **ModSecurity**
(`SecRequestBodyNoFilesLimit`), not of Apache or PHP. That matters, because it
means `LimitRequestBody` in `.htaccess` and `post_max_size` in `.user.ini` — both
set generously in this repo — are *not* the binding constraint. ModSecurity is
checked first and can only be changed in WHM/cPanel.

### Re-measuring after a hosting change

```
node scripts/probe-body-limit.mjs https://kskbdev.graspsoft.xyz
```

## How the application stays under it

Three layers, so no single mistake brings the 413 back:

1. **Every image is compressed on the way in.** `compressImage()` caps the
   longest edge at 1600px and re-encodes as JPEG — a 450 KB screenshot becomes
   about 50 KB. Every upload path now goes through `fileToStoredDataUrl()` or
   `compressImage()`; `ImagesField` and `AttachmentsField` previously stored
   files byte-for-byte as they came off disk, which is what caused this.

2. **A running total per field.** Compressing each file is not enough — five
   files that each pass a per-file check still add up. `ImagesField` and
   `AttachmentsField` add files one at a time against a budget, keep the ones
   that fit, and name the ones they could not take.

3. **A pre-flight guard in the HTTP layer.** `guardPayloadSize()` in
   `src/lib/http.ts` measures every JSON body before it is sent and refuses it
   locally if it is over budget, with a message naming the actual size. This is
   the layer that covers screens that do not exist yet: any new upload field is
   protected without having to remember.

## Changing the limits

The two numbers must move together, **and the server's has to move first.**

### 1. Raise the server limit

| Where | What | Notes |
|---|---|---|
| WHM → ModSecurity Tools → rule configuration | `SecRequestBodyNoFilesLimit` | **This is the binding limit on the dev server.** Needs root/WHM. |
| cPanel → MultiPHP INI Editor | `post_max_size`, `upload_max_filesize` | Also set by `public/.user.ini` in this repo. |
| `public/.htaccess` | `LimitRequestBody` | Already 20 MB here. Apache/LiteSpeed level. |

Then re-measure with the probe above to confirm it actually changed — a setting
that appears saved but is overridden elsewhere is the usual outcome.

### 2. Raise the client budget to match

In the frontend `.env` / `.env.production`:

```
VITE_MAX_REQUEST_BYTES=921600
```

Keep it under the measured server limit, with room for headers and the rest of
the form. `MAX_DOCUMENT_BYTES` and `MAX_ATTACHMENTS_BYTES` are derived from it
in `src/utils/fileUpload.ts`, so this one value moves all of them.

At the current 900 KB budget: roughly 14 compressed photographs per property, or
a single non-image document (a PDF) of about 460 KB.

## The real fix, when there is time for it

Storing attachments as base64 in the database is what makes every one of these
limits necessary. Uploading to disk or object storage and keeping a path in the
record would remove the constraint entirely, shrink the database considerably,
and let a photograph be served with normal HTTP caching. That is a larger change
— it touches properties, leases, members and staff, and existing base64 rows
would need migrating — so it has not been done.
