# CLAUDE.md Guidance for Claude Code (claude.ai/code) working in this repository. ## Project overview FileMinify compresses and converts files, and merges anything printable into a single PDF. React 19 + TypeScript frontend, Node/Express backend shelling out to FFmpeg, Ghostscript, ImageMagick and LibreOffice, all in Docker behind nginx. This repo began as a redesign of the first FileMinify, which is now retired (its repository was deleted on 2026-09-23). History up to `f4d6b79` is that project's; everything after is the rework. Its last fixes (rate limiter, upload holes, smoke suite) live on here as `d96fa4c`, `53673ab` and `3f95c5c`. The history was rewritten once, on 2026-09-23, before the repo went public: a personal document committed by mistake early on was removed from every commit and author emails were normalised. Commit IDs before that date differ from any older clone or PR link. Security reports: see SECURITY.md. ## Running it **This project runs in Docker Compose. Do not run npm on the host — Node is not installed there.** ```bash docker compose up -d --build # everything docker compose up -d --build frontend # after a frontend change docker compose up -d --build backend # after a backend change docker compose logs --tail=30 backend ``` App on **http://localhost:3051**, API on **4001**. The offset from 3050/4000 dates from running beside the first version; the ports stay as they are, since deployments and bookmarks use them. The frontend image build runs `tsc -b && eslint . && vite build`, so a clean build is the type check and lint. It must be `tsc -b`: the root tsconfig.json is a solution file (`"files": []` plus references), and plain `tsc` checks zero files - it did, silently, until the build was fixed. No test framework is configured. Lint is ESLint 10 with react-hooks 7, whose recommended set includes the React Compiler rules: render must be pure (no `Date.now()` or other impure calls while rendering) and state isn't set straight inside an effect. To follow a prop, keep its previous value in state and adjust during render (see `FileRow`'s `wasBusy` and `AdjustPanel`'s `followed`); errors fail the build. On React 19 a component takes `ref` as an ordinary prop (see `ui/button.tsx`, which Radix's `asChild` passes a ref through); don't reach for `forwardRef`. ## Architecture ### Frontend - `src/processing.ts` — the decision layer. `routeFor` decides compression vs conversion, `requestBodyFor` turns a tier into what each backend route actually understands, plus staleness and output naming. - `src/formats.ts` — the single source of truth for which target formats each source type supports. Mirrors the branches in `backend/routes/conversion.js`. - `src/components/file-processor/FileProcessor.tsx` — orchestrator. Per-file XHR upload with real progress, two concurrency lanes (3 general, 1 for LibreOffice work), rerun, download-all. - `FileRow` / `AdjustPanel` / `DropZone` / `TierControl` / `MergeDialog` — the UI. - `src/lib/api.ts` (fetch wrappers), `src/lib/format.ts` (byte and percentage formatting), `src/hooks/useFiles.ts` (zustand store), `src/types.ts`. ### Backend - `server.js` — Express setup, `/health`, `/config` (upload limits for the frontend), and an hourly sweep deleting temp files and stale `merge-*` scratch directories older than an hour. - `routes/upload.js` — Multer, `` naming, MIME allowlist. - `routes/compression.js` — Sharp (images), Ghostscript (PDF), FFmpeg (video). - `routes/conversion.js` — between formats, using the shared converters. - `routes/merge.js` — ordered merge into one PDF via pdf-lib. - `utils/converters.js` — `toPdf` plus `PDF_SOURCE_EXTS`, shared by conversion and merge, and the LibreOffice lock. - `utils/run.js` — every external command goes through `run(name, args)`, which asks `utils/tools.js` for the binary. In Docker a name is itself; on Windows it is found on PATH, in winget's folders or in the usual install folders. It remembers each running child, so `killAll()` can stop them (the whole tree on Windows) when the desktop app quits. - `utils/desktop.js` — the desktop app's messages over `process.parentPort` (see "Desktop app"); does nothing without it. - `utils/paths.js` — `TEMP_DIR`, the one place uploads and results live. ### Windows build (`desktop/`, `packaging/winget/`) The same backend runs natively, serving the built frontend itself (and the API under `/api`, as nginx would) when `FM_STATIC_DIR` is set. The desktop app's main process sets that and the other `FM_*` variables (data dir, `FM_HOST=127.0.0.1`, `FM_TRUST_PROXY=0`), with overrides from `%LOCALAPPDATA%\FileMinify\settings.env` (see "Desktop app"). - **Phone access** (listening beyond `127.0.0.1`) is switched in the app (`setPhoneAccess` over the bridge, from the phone panel) or in the tray. Main saves `FM_HOST` in settings.env, restarts the server and reloads the window with `/?phone`. - **The phone panel.** In native mode `/config` gives the PC itself (never another machine) a `phone` block: `enabled`, plus the addresses from `utils/network.js` (virtual adapters left out, the default route's first). The header's "Use on phone" button and `PhoneDialog` show it as a QR code (`uqr`), asked afresh on every opening. `e2e/tests/phone.spec.ts` fakes that block, since Docker never sends it. - **Closing the window** quits, unless phone access is on: then it hides to the tray, whose Quit stops it. - **Tools.** The oneClick installer asks nothing; the app offers the missing tools on its first start (winget in a visible console: `installTools()` / `missingTools()` in `utils/tools.js`, asked for over `process.parentPort`). - **No HTTP endpoint starts a program.** 1.0.x had `routes/native.js` (`/native/update`, `/native/tools`) and `utils/update.js`; 2.0 removed them. The update is electron-updater's. - **Notices and logs.** The app shows update and tools notices (`Notices.tsx`); "Later" and "Skip" live in localStorage (`lib/updateNotice.ts`) while the header's "Update available" stays. Logs go to `%LOCALAPPDATA%\FileMinify\logs` (fileminify.log from the backend, desktop.log from main), opened from the tray. The UI reaches all of this through the bridge, which `e2e/tests/native.spec.ts` fakes. `desktop/build.sh` stages the backend (with its own production node_modules), the built app and Ghostscript, and electron-builder makes the per-user NSIS installer (`desktop/electron-builder.yml`, with `desktop/build/installer.nsh`; see "Desktop app"). The winget manifest pulls in FFmpeg, ImageMagick, LibreOffice and the VC++ runtime. Ghostscript is bundled because winget has none (its installer is interactive-only). Without any `FM_*` variable set, nothing about the Docker setup changes. What nginx did and native mode now does itself: - **Host check against DNS rebinding.** Only `localhost`, the PC's own name and plain IP addresses are answered, since Origin == Host alone passes a rebound name. - **Upload timeout.** Node's `requestTimeout` is raised to the job timeout, because a phone's upload now streams straight into Node. - **`X-Frame-Options: DENY`** and nginx's CSP. ### Desktop app (2.0, released 2026-09-27 as v2.0.0: `desktop/`, replaced 1.0.x's `windows/`) Electron 44.4.5 (Node 24), electron-builder 26.16.1, electron-updater 6.8.9, pinned exactly. This is the contract the pieces are built against: - **Layout.** - The asar holds only `desktop/main.cjs`, `preload.cjs`, `lib/*.cjs` and `lib/assets/` (the tray icon, 16 px plus `@1.5x`/`@2x`, rendered from `public/icons/icon-512.png`). - Beside it, as plain `extraResources`: `resources\backend` (the backend staged by `desktop/build.sh`, with its own `npm ci --omit=dev`, sharp included), `resources\dist`, `resources\tools\gs` and `resources\magick`. - Installed per user by a oneClick NSIS installer to `%LOCALAPPDATA%\Programs\fileminify-app\FileMinify.exe`. - `appId com.bardurnielsen.fileminify` never changes: the uninstall key and winget's ProductCode derive from it. - The data folder is `%LOCALAPPDATA%\FileMinify`, as in 1.0.x (settings.env, logs, temp), plus `app\` for Electron's own. - **Server.** A `utilityProcess` runs `resources\backend\server.js`, with an env built by main: 1. Inherited `FM_*`, `MAGICK_*`, `ELECTRON_RUN_AS_NODE` and `NODE_OPTIONS` are dropped. 2. The 1.0.x launcher defaults apply (PORT 3051, FM_HOST 127.0.0.1, FM_TRUST_PROXY 0, MAX_FILE_SIZE 500MB, PROCESS_TIMEOUT_MIN 30, FM_DATA_DIR, FM_STATIC_DIR). 3. The bundled Ghostscript and `MAGICK_CONFIGURE_PATH` are set. 4. settings.env overrides all of that. 5. `FM_VERSION` comes from `app.getVersion()`. - **Main process** (`desktop/main.cjs`, `lib/`): `config` (data dir, settings.env read and `saveHost` exactly as launcher.js did, the server's env), `log` (`logs\desktop.log`, rotated at 5 MB, two kept; the server's output goes there too), `server`, `window`, `tray`, `ipc`, `updater`. - Start: single-instance lock (a second launch shows the window), server, then the window. A port in use is a dialog naming `PORT=`, then quit. A server that dies is restarted once; again within 10 minutes, the user picks Restart or Quit. - Close button: phone access off quits (asking first if busy); on, the window hides to the tray, with a one-time balloon. Tray: Open, Phone access, Open log folder, Quit. Quitting sends `shutdown` first, and an update's `quitAndInstall` skips the question and the tray. - Window: size and place in `app\window.json`, restored only if still on a screen; no menu; new windows denied, only `https://github.com/bardurnielsen/file-minify/` links and the author's profile (exactly `https://github.com/bardurnielsen`, the footer's "Get in touch") open in the browser; navigation stays on the app's origin; every permission but `clipboard-sanitized-write` denied. Downloads save straight to Downloads (`name (1).ext` when taken); the page gets only an opaque id, never a path. - Markers in `app\`: `first-run-done` (the tools question was asked; not asked with `FM_NO_WINDOW`), `tray-hint-shown`, and `disable-gpu`, written when the GPU process fails to launch or crashes: the app relaunches without GPU acceleration (unless busy; then from the next start). - Updates: electron-updater, nothing automatic; checked when the page asks and every 12 h (cached 12 h). Any check error means "no update". - **Messages over `process.parentPort`** (`backend/utils/desktop.js`, which does nothing outside the app): - Server to main: `{type:'listening',host,port}`, `{type:'listen-error',code}`, and `{type:'busy',busy}` when processing requests in flight (`trackJobs`: uploads, compressions, conversions, merges, from the window or a phone) go from none to some and back. - Main to server, also: `{type:'phone-network',id}` gets the QR address's network (`{category,name,kind,index}` or null), and `{type:'network-changed',id}` drops the 20 s network/firewall cache. - Main to server: `{type:'install-tools',id}` gets `{type:'install-tools',id,outcome,packages}`; `{type:'missing-tools',id}` gets `{type:'missing-tools',id,missing}`; `{type:'shutdown'}` closes the server and exits. - The window loads `http://127.0.0.1:/` only after `listening`. - **Bridge `window.fileminify`** (preload, `contextIsolation` and `sandbox`), typed in `src/lib/native.ts`: `updateStatus()`, `startUpdate()`, `onUpdateProgress(cb)`, `installTools()`, `setPhoneAccess(on)`, `openLogFolder()`, `openNetworkSettings(kind)`, `makeNetworkPrivate()`, `onDownloadSaved(cb)`, `showDownload(id)`, `setBusy(busy)`. - `ipcMain` handlers accept only the app window from the app origin. - No HTTP endpoint starts a program: `/native/*` goes, and `/config` keeps its read-only `phone`, `version`, `missingTools` and `installingTools`. - What the UI expects of main. `startUpdate()` resolves once the download is verified, just before `quitAndInstall`, and rejects if it fails; `onUpdateProgress` gives 0-100 meanwhile. `installTools()` resolves with `{ok:false, problem}` rather than rejecting; `'running'` is waited for like a fresh start (polling `/config`'s `installingTools`). `setPhoneAccess(on)` resolves once the server is back with the new setting; main then reloads the window, with `?phone` when turned on. `onDownloadSaved` fires for every download saved to Downloads, with an `id` that `showDownload(id)` accepts. `setBusy` is sent on every change, starting with `false`: true while any file uploads, waits in the queue or processes, or a merge runs. - Without the bridge (a browser tab on the same PC) the UI offers no update or tools install and points to the app's window or its tray icon. - `e2e/tests/desktop-fake.ts` fakes the bridge for the Docker stack; `DESKTOP_BRIDGE_KEYS` there is what the real preload must expose. - **Priority.** The server runs below normal priority, and the tools it starts inherit it: an encode gets the whole processor when the PC is otherwise idle, but what the user is doing comes first. - **Public networks.** `/config`'s `phone` says `publicNetwork` and, if so, `firewall` (`blocks`/`allows`/`unknown`, from FileMinify.exe's own inbound rules for the Public profile), and `networkKind` (no name: the profile name can be a DNS domain, and the SSID needs Location permission on Windows 11 24H2). The panel warns only when the firewall doesn't allow it. `makeNetworkPrivate()` runs `Set-NetConnectionProfile -InterfaceIndex -NetworkCategory Private` behind Windows' admin prompt (n from the backend, checked to be an integer). `openNetworkSettings(kind)` opens only `ms-settings:network-wifi`, `-ethernet` or `ms-settings:network`. - **Busy work and sleep.** "Busy" is the page's own work (`setBusy`) or the server's `busy`. While busy, Windows is kept awake (`powerSaveBlocker 'prevent-app-suspension'`, released 30 s after the last job), and quitting, updating or switching phone access asks first. - **Start with Windows** (a tray checkbox): a login item named `FileMinify` that runs `FileMinify.exe --hidden`, which starts in the tray only; the tools question waits until the window first opens. It is read back by its name (`launchItems`), and the uninstaller deletes the Run value. - **settings.env keys added:** `FM_UPDATE_PRERELEASE=1` (testers get betas), `FM_DISABLE_GPU=1`, `FM_UPDATE_CHECK=0` (never look). For CI only: `FM_UPDATE_FEED` (a generic feed; `http://127.0.0.1` or `http://localhost` only, anything else is ignored) and `FM_NO_WINDOW=1` (server and tray, no window). `FM_NO_WINDOW` and `FM_DISABLE_GPU` are also read from the environment. - **Every release must stay updatable from 1.0.3.** - It is the `releases/latest` (published, not a draft, not a prerelease). - Its asset is named exactly `FileMinify-Setup-X.Y.Z.exe`, with a matching `vX.Y.Z` tag, and GitHub's sha256 digest is present. - It installs with no arguments and no elevation, stops the old app itself, and starts the new one. - The installer's `customInit` removes a 1.0.x install *without* running Inno's uninstaller, which would delete settings.env. - A prerelease is only ever `vX.Y.Z-beta.N` (the build and release jobs refuse anything else). electron-updater reads the part after the `-` as a channel: an install from an `-rc.N` would only look for more rc releases, never the stable one, while `beta` (and `alpha`) take stable. - A published release is never replaced: the release job fills in and publishes a draft, and fails on a published one. Release a new version. - **Installer** (`desktop/build/installer.nsh`, electron-builder's NSIS macros, and the PowerShell scripts beside it, unpacked to the installer's temp folder and read in as a script block, so no execution policy applies; paths reach them in `FMSETUP_*` environment variables, never quoted into a command line). - `customInit`, before anything is extracted, finds 1.0.x by its Inno uninstall key (`{A714B514-…}_is1`) or its files. `stop-processes.ps1` stops the old `node.exe` (by exact path), the bundled Ghostscript under its `tools\gs\`, the FFmpeg, ImageMagick and LibreOffice processes it started (down the parent chain; never its whole tree, since setup is its child when 1.0.3's updater starts it) and its Edge window. Then it deletes 1.0.x's files by name, its shortcuts, the Edge profile (`FileMinify\window`) and, last, the key. settings.env, logs and temp stay. Every step can run again: a file still locked keeps the key, and the next install finishes the job. Half done (`node.exe` held), the phone-access and log-folder shortcuts still go, the key gets `SystemComponent=1` (out of Apps & Features) and Inno's `unins000.*` are deleted, so nobody can run the uninstaller that would wipe 2.0's data. It also moves its working directory out of the old folder, which 1.0.3's updater starts setup in, notes whether a 1.0.x user had a desktop shortcut, and whether a silent install (winget) is about to stop a running FileMinify (2.x or 1.0.x). It must never ask anything (a MessageBox needs `/SD`): winget and CI run it with `/S`, 1.0.3's updater with no arguments. - `customCheckAppRunning` replaces electron-builder's check (which broke on a quote in the path): an update (`--updated`) first gets 8 s to quit by itself, then everything running from the install folder is stopped, with the tools it started (`stop-processes.ps1`; `taskkill` by name if PowerShell can't run). `customUnInstallCheck` is electron-builder's own with `/SD` on its MessageBox. - `customInstall` grants "ALL APPLICATION PACKAGES" read access to the install folder (the per-user GPU start-up failure); deletes the updater's `current.blockmap` unless it is an update (installed any other way it is stale, and the next differential download would fail and fetch everything) and 1.0.3's leftover `%TEMP%\FileMinify-update-*` downloads (not the one it runs from). After a 1.0.x install it removes the new desktop shortcut if the user had none, and `retarget-shortcuts.ps1` points shortcuts to the old `node.exe` (desktop, Start-menu folders, taskbar pins) at the new exe, with the app's AppUserModelID. If a silent install stopped FileMinify, it starts it again with `--hidden`. - `customUnInstall` removes `%LOCALAPPDATA%\FileMinify` and the updater's cache, except during an update (`--updated`). ## Invariants — these are fixed bugs, do not regress them 1. **`formats.ts` is the source of truth.** Never offer a file its own format, or one its type cannot reach. Adding a backend format means updating both sides. 2. **One routing rule.** `routeFor` decides both how a file is processed and where it is downloaded from. These were once two hand-maintained condition lists that drifted, sending downloads to the wrong route. 3. **`-y` on every FFmpeg invocation.** Without it FFmpeg prompts before overwriting an existing output and blocks forever, because `exec()` gives it no stdin. This hung the app three separate times: two-pass output to `/dev/null`, and converting a video to its own format (making output == input). Any shelled command that might overwrite needs its non-interactive flag. 4. **LibreOffice is serialised** through `withOfficeLock`. Headless mode holds a per-profile lock; a second concurrent conversion fails outright. 5. **`format: 'original'` means "keep the source format"** and is resolved to the source extension server-side. It must never reach a filename or an encoder as a literal — it once produced files named `compressed-.original`. 6. **The backend port stays on `127.0.0.1`.** Published on `0.0.0.0` it is a second front door that skips nginx, and because `trust proxy` makes Express believe `X-Forwarded-For`, a direct client can set that header per request and rotate itself out of the rate limiter. Verified: rotating the header held `RateLimit-Remaining` at 599 indefinitely. 7. **nginx forwards `Host` as `$http_host`, not `$host`.** `$host` strips the port, so the same-origin check compared `example` against an `Origin` of `example:3051` and rejected the app's own uploads with a 403. 8. **An nginx `add_header` in a nested location drops every inherited one.** The asset-cache block therefore uses `expires` alone — when it also set `Cache-Control` by hand, every JS and CSS file was served with no CSP and no `X-Frame-Options`. 9. **No `cpus:` on the backend.** Docker refuses to start a container whose `cpus` exceeds the host's core count, rather than clamping — `cpus: 4.0`, picked on a 12-core dev machine, stopped the backend dead on CI's two-core runner and would have done the same on any smaller server. Memory is the limit that protects the host; keep `mem_limit`. A value at or below 1 (as the frontend uses) is safe anywhere. To be a good neighbour on a shared host the backend uses `cpu_shares` instead: a relative weight, which only bites under contention and cannot be too large for any host. 10. **Never run a bare `convert` on Windows.** `System32\convert.exe` is Windows' disk-format converter; ImageMagick there is `magick`. `utils/tools.js` maps the name. Call tools by name through `run()`, never by a path or an `exec` of your own. 11. **No Unix-only paths or separators in tool arguments.** The null device is `os.devNull`, not `/dev/null`. `-x265-params` splits on `:`, which a Windows path contains, so x265's two-pass stats file is named relative to the temp dir that FFmpeg runs in. 12. **Natively, nothing is in front of the backend**, so the desktop app sets `FM_TRUST_PROXY=0`. Trusting `X-Forwarded-For` there reopens invariant 6's rate-limiter bypass. Verified: with 0, rotating the header still counts down. 13. **ImageMagick is told the decoder** (`magickInput`: `png:`), never left to guess. It sniffs a file's bytes over its name, so an upload declared PNG that is really SVG was rendered, and SVG can read local files. In Docker only a missing SVG delegate stopped it; ImageMagick 7 on Windows renders SVG itself. Verified by giving a container `rsvg-convert`: the old call made a PDF, the pinned one refuses. `desktop/magick/policy.xml` is the second lock. Smoke: "svg disguised as png". 14. **No HTTP endpoint starts a program.** 1.0.x's `/native/*` did, guarded to the PC itself. In 2.0, updating and installing tools go through the desktop bridge (`window.fileminify`), whose handlers accept only the app window, from the app origin, while our own server listens. 15. **`appId` and the 1.0.x migration are permanent.** The appId `com.bardurnielsen.fileminify` is where the uninstall key, winget's ProductCode and every update come from. `customInit`'s 1.0.x removal must stay in every release, because a 1.0.3 that updates late still gets whatever is newest. 16. **Every release stays updatable from 1.0.3:** - It is published as `releases/latest`, never as a draft. - Its asset is named exactly `FileMinify-Setup-X.Y.Z.exe`, with a matching `vX.Y.Z` tag and GitHub's sha256 digest. - Prereleases are only `vX.Y.Z-beta.N`: an `rc` channel never gets the stable release in electron-updater. - A published release's installer is never replaced; release a new version instead. The release job checks all of this. ## Behaviour worth knowing - **Compression can inflate.** Re-encoding already-compressed input often grows it. When the result is no smaller and the format is unchanged, the backend keeps the original and reports `compressionRatio: "1.00"`. The UI shows this as "already compact", not as a failure. - **Video ignores numeric quality.** With `maxSize` set, FFmpeg runs two-pass targeting that size and the quality value does nothing. PDF and video take a *named* level (`low|medium|high`), not a number — hence tiers. - **Video tiers differ by resolution and a bitrate ceiling, not just CRF.** On an already-lean source (a 4 Mbps 1080p phone clip) CRF alone asked for more bits than the source had, so Balanced and Best both came out larger and the original was kept. Each tier now also caps peak bitrate at a share of the source's (45/55/80% for H.264, a quarter lower for H.265, scaled down further when the picture is), and by default caps the short side (720p / 1080p / 1440p). Nothing is ever kept at 4K: 1440p is the ceiling for every path, including target size and an explicit `resolution` (`1440|1080|720|480`; `source` is still accepted and means 1440). On the test server a 4K Best encode ran at 0.08x real time. A `resolution` option overrides the tier's cap; with a target size, the resolution is picked from the bitrate the target allows. `codec: 'h265'` is MP4/MOV only and tagged `hvc1` for Apple players. See `VIDEO_SETTINGS` in `routes/compression.js`. - **Videos wait for the user.** A dropped video uploads straight away but is held (`FileItem.hold`, status `ready`) with its settings panel open until Compress is pressed: an encode takes minutes, can't be cancelled, and target size is the setting that matters most. Everything else starts on drop. - **`set_mempolicy: Operation not permitted` from x265 is harmless.** x265 asks the kernel to place each worker thread's memory (a NUMA optimisation); newer Docker seccomp profiles refuse that call without CAP_SYS_NICE, so it prints one line per thread and carries on. Don't grant the capability to silence it. - **Rename and share.** `FileItem.baseName` is a user-given name without the extension (`cleanBaseName` strips path and reserved characters); `displayName()` is what every name shown, downloaded or shared uses. Share (`lib/share.ts`) uses the Web Share API: shown only when `canShareFiles()` says this browser can share those exact names and types (secure context needed; Chrome on Android won't share .mov/.avi). share() must run within the tap's activation window, so a fetch that outlasts it caches the File and asks for a second tap, which shares at once. `e2e/tests/share.spec.ts` fakes the share sheet to check names, types and contents. - **Installable (PWA).** `public/manifest.json` (named .json because nginx's stock MIME table has no .webmanifest; don't add a `types` block, it replaces the whole table) and `public/icons/`, rendered from `favicon.svg`'s light-scheme colours; the maskable ones have the glyph scaled to 0.85 so it stays inside Android's 80% safe zone. Browsers install only from a secure origin, so over plain http on the LAN it needs HTTPS in front (or Chrome's "insecure origins treated as secure" flag). `e2e/tests/install.spec.ts` asks Chrome for installability errors. - **Results describe themselves.** `POST /compression` returns `details` (video: preset, codec, output and source short side; images: quality; PDFs: preset), null when the original was kept. `outputNameFor` builds the download name from it and `describeResult` the row's line; the backend writes the same into the file (`-metadata comment` on video, EXIF ImageDescription - ASCII only - on images, Producer on repacked PDFs). Video output drops all source metadata (`-map_metadata -1`), GPS included; Sharp already drops it for images. - **Photos are auto-oriented** (`autoOrient()` in Sharp, `-auto-orient` for image→PDF). Sharp strips the EXIF Orientation tag with the rest of the metadata, so without it a phone's portrait photo came out sideways. Pinned in smoke by `fx-rotated.jpg`. - **Office → PDF honours the tier.** The conversion route takes an optional `quality` (`low|medium|high`) for Office files and runs the LibreOffice PDF through Ghostscript at that level, keeping whichever is smaller. Other conversions ignore quality. - **Every Ghostscript PDF is then repacked by pdf-lib** into compressed object streams (lossless). Ghostscript 10.00 writes objects loose, so a spreadsheet with thousands of hyperlinks stayed ~1.3 MB at every tier; repacked it is ~570 KB. Best effort: if pdf-lib can't read the file, or the repack isn't smaller, the Ghostscript output is kept. - **Ghostscript exits 0 on a PDF that needs a password to open** and writes a blank page, which used to come back as a 95% saving. `needsPassword` spots its stderr message and the route answers 422 instead. PDFs that only restrict printing or editing still work. Merge reports the same case itself. - **The email sizes** on the video target size aim for 7 MB and 18 MB: email grows an attachment by about a third (base64), so those fit a 10 MB and a 25 MB limit. - **`maxSize` is video only**: a target file size in MB. Images once treated it as a silent megapixel cap; that is gone. - **A missing `format` means `original`** on compression, same as sending it. - **Merging is atomic.** One unconvertible file fails the whole merge, with `failedId` naming it. The UI offers leaving it out. - **Merge uses the processed output** (`processedId ?? serverId`), so a converted docx is not pushed through LibreOffice twice. - **A foreign `Origin` is rejected with a 403**, not just denied CORS headers. A multipart POST is a "simple" request: the browser sends it regardless and only hides the reply, so CORS alone still let a hostile page upload a file and start an encode. `CORS_ORIGIN` opts specific origins back in; a request with no `Origin` at all (curl, the smoke suite) is left alone. - **The rate limiter is mounted on the paths nginx actually delivers** (`/upload`, `/compression`, `/conversion`, `/merge`) rather than `/api/`, which the proxy strips. `trust proxy` is set to 1 so clients are counted by `X-Forwarded-For` and not by nginx's own container address. `/health` is exempt. - **PNG quality is non-monotonic in Sharp** — "Smaller" can produce a larger PNG than "Balanced". - Temp files are `.` in `/app/temp` on the `temp_files` volume, swept hourly. The volume survives `docker compose down`; use `-v` to clear it. ## Testing Two suites, both against a running stack and both in CI (jobs `backend` and `e2e`, required on `main`): - `backend/test/smoke.sh` - the API: every route, the error paths and the security regressions (below). - `e2e/run.sh` - the browser suite (Playwright, `e2e/tests/`): drop-and-process, held videos and their settings, labelled download names, merge, sliders and the format picker, untyped uploads, the drag overlay, the video queue, the settings panel's exit, and the desktop app's notices, phone switch and download toast against a faked bridge. It runs in the Playwright image whose tag run.sh reads from `e2e/package.json` (pinned exactly, so a Dependabot bump moves both), and makes its test videos with the backend's ffmpeg (`e2e/make-media.sh`). Every test also fails on any browser console error. Each of its bug tests was checked by re-introducing the bug and watching it fail. - `e2e/screenshots/update.sh` regenerates the README screenshots (`docs/screenshots/*.png`, dark theme only - light ones vanished into GitHub's white page) from demo files it makes with the backend's tools. Each shot is then framed in the browser - rounded to the element's own radius, soft shadow, faint light hairline, transparent background - since a README can't carry CSS: the shadow lifts it off GitHub's white page, the hairline edges it on the dark one. Re-run it after a visible UI change; don't edit the PNGs. A separate workflow, `windows.yml` (not a required check), runs on Windows runners when the backend, app or `desktop/` change: - `build`: `desktop/build.sh --next `, the installer plus a second build one patch up (`build/desktop-next`) that serves as the update feed. Both are kept as run artifacts; the first is what to try on a real PC. - `installed`: installs it silently, starts it with stale `FM_VERSION` and `MAGICK_CONFIGURE_PATH` (which it must ignore) and a DevTools port, runs the smoke suite at `:3051/api`, checks `/native/*` is gone and the version and ImageMagick policy, takes a screenshot, then runs the desktop suite (`e2e/desktop/`, `e2e/desktop.config.ts`: Playwright over CDP, in file order). That checks the bridge's keys against `DESKTOP_BRIDGE_KEYS`, a download to Downloads, phone access on and off, a second start, the update to desktop-next through the Update button (`FM_UPDATE_FEED` to a local `python -m http.server`, which also serves the installed version's blockmap, and must be asked for it), and that closing the window stops everything. Then it uninstalls and checks nothing is left. - `migration`, three times: installs the real 1.0.3 (hash-checked), seeds settings.env, logs, an Edge profile, a taskbar pin and a stale `%TEMP%\FileMinify-update-*`, starts the old app and an Edge, then runs the new installer silently (`/S`, no desktop icon), as 1.0.3's updater does (a detached child of the old `node.exe`, no arguments, in the old folder, with its stale environment), or silently with the old `node.exe` held open (it must stop half way: key kept but hidden, `unins000.*` gone), then again once let go. The old processes, folder, key and shortcuts must be gone, the pin and any desktop icon pointing at the new exe (and no desktop icon where 1.0.x had none), settings.env byte-identical, and the new app running with phone access still on. - `release`, on a `v*` tag: checks latest.yml against the exe, then one `gh release create` with the exe, its blockmap and latest.yml, never a draft (a draft made by hand is filled in and published; a published release fails the job); a `vX.Y.Z-beta.N` tag is a prerelease, and any other tag with a `-` fails. It then checks `releases/latest` still meets the 1.0.3 rules above, and prints the SHA-256 for the winget manifest. The native mode can be exercised on Linux too: run the backend image with `FM_STATIC_DIR` pointing at a built `dist/`, then run smoke and `e2e/run.sh` against it. Per-server settings (`MAX_FILE_MB`, `PROCESS_TIMEOUT_MIN`, `BACKEND_CPU_SHARES`, `TEMP_DIR`) come from an optional `.env` beside `docker-compose.yml`; see `.env.example`. nginx's config is a template (`frontend/nginx.conf.template`) whose upload limit and read timeout `frontend/nginx-limits.envsh` derives from the same settings at container start - never hard-code them again. `backend/test/bench.sh` times every tier and codec on the current host; use it to choose those settings. No unit tests. There is an API smoke suite at `backend/test/smoke.sh`, with fixtures beside it. It needs the stack running and defaults to :4001; pass a base URL to point it elsewhere. It exits non-zero on failure. It covers upload, compression, conversion, merge, error handling, and a security block pinning the bugs fixed in `53673ab` — format injection, `id` path traversal on the download and delete routes, MIME rejection, the upload size cap, and the same-origin check in both directions. Those last cases are regression tests: if one starts failing, a hole has reopened. CI (`.github/workflows/docker-build.yml`) runs one job per image: `frontend` builds (and so type-checks and lints), `backend` builds, starts that exact image and runs the smoke suite. Each caches under its own `gha` scope - with a shared scope the two overwrote each other and the apt layer was rebuilt every run. Build contexts are trimmed per image by `.dockerignore`. Docs-only pushes to `main` skip CI, but PRs always run it: `main` requires both checks, and a required check that never reports would block the PR. Actions are pinned to commit SHAs; Dependabot (`.github/dependabot.yml`) proposes updates for npm, the Docker base images and the actions weekly. `curl` needs an explicit `;type=` on `-F` uploads or the MIME allowlist rejects the file.