# Architecture Plain PHP 8, no framework, no database, no Composer. Designed for shared webhosting where the only deployment tool is FTP. ## Layout The document root is the project folder itself — `index.php` is the home page. Application internals sit in the same tree but are blocked from the web by `.htaccess`. ``` index.php landing page (hero + intro) ← document root showreel.php fullscreen portfolio, scroll-snap gallery/ client gallery viewer, served as /gallery/?g= index.php password gate, expiry, grid + lightbox download.php redirects to the presigned URL of the gallery's ZIP worker.php background archive builder (self-dispatching, key-protected) admin/ backoffice (session-protected) api.php JSON API for the uploader (presign / register) archive-api.php JSON API for building an archive on demand assets/ site.css, site.js (nav + lightbox), admin.js (uploader), archive.js (archive build progress) media/ local images: hero + showreel (full resolution) app/ library code — blocked by .htaccess bootstrap.php config loading, session, helpers storage.php JSON flat-file store, slugs, local media handling auth.php login, throttling, online password change s3.php AWS Signature v4 (presign, PUT, DELETE, GET, multipart) zip.php store-only ZIP64 writer archive.php archive build slices, dirty queue, worker dispatch csrf.php CSRF tokens partials.php shared HTML header/footer for public + admin pages config/ static config (S3, site) + admin credentials — blocked data/ flat-file content: site.json, galleries/.json — blocked router.php local dev only: applies the .htaccess rules under php -S ``` ## Flat-file storage - `data/site.json` — front page text, hero filename, ordered showreel list. - `data/galleries/.json` — one file per gallery: ```json { "slug": "wedding-mueller-x7Kf3q", "title": "Wedding Müller", "created_at": "2026-07-05 12:00:00", "password_hash": "$2y$...", // or null "expires_at": "2026-12-31", // or null "max_resolution": 2560, // longest edge in px, or null = original "images": [ { "key": "//originals/a1b2c3-DSC_0001.jpg", "thumb": "//thumbs/a1b2c3-DSC_0001.jpg.jpg", "name": "DSC_0001.jpg", "size": 18349201 } ] } ``` Writes go through `json_write()`: serialize to a temp file, then `rename()` — atomic on the same filesystem, so a crashed request can't corrupt data. Reads take a shared lock. The slug embeds a random token, making gallery URLs unguessable; the slug is also validated (`gallery_file()`) before being used in a filesystem path. ## Image storage split | What | Where | Why | | --- | --- | --- | | Hero + showreel | `media/` on the webhost | Few images, served directly, no S3 round-trip for the portfolio | | Gallery images | Hetzner S3, **private** bucket | Hundreds of full-res files per event; webspace stays small; traffic goes to S3 | Originals are **never modified** anywhere in the pipeline — no resize, no re-encode, no EXIF stripping. ## Presigned URLs (app/s3.php) AWS Signature v4 implemented directly (~100 lines, `hash_hmac` only), verified against the official AWS example vectors. Addressing style follows `s3.path_style` (default **path-style**, `https:////`, which Hetzner serves reliably; set `false` for virtual-hosted-style `https://./`). Three uses: 1. **Presigned GET** — `gallery/index.php` embeds signed image URLs (`s3.url_ttl`, default 1 h). The browser fetches from S3 directly, so gallery image traffic never touches the webhost. 2. **Signed PUT** — `s3_put_file()` streams uploaded originals and thumbnails from the webhost to S3 (header auth, `UNSIGNED-PAYLOAD` so the body is never buffered in memory). The browser never gets an S3 write URL. 3. **Signed DELETE** — server-side via curl when images or galleries are deleted. Because the bucket is private, access control is entirely on the PHP side: no unlock → no signed URL → no image. Once a gallery expires or is deleted, outstanding URLs die within the TTL. ## Upload flow (admin browser → webhost → S3) ``` admin.js api.php Hetzner S3 │ canvas → JPEG thumb │ POST multipart (original + thumb, one file) ─▶ │ │ │ PUT original ─────▶ │ │ PUT thumb ────────▶ │ │ append to gallery JSON │ ◀──────────────────────── { ok, key, thumb, count } ``` Files are uploaded **one request per file**, with `uploads.concurrency` (default 3) files in flight at once and per-file progress. The thumbnail is drawn client-side (`createImageBitmap` + `imageOrientation: 'from-image'` for EXIF rotation, with a `resizeWidth` hint so large JPEGs downsample during decode instead of being decoded at full resolution) and sent alongside the original; undecodable files (RAW, video) upload without a thumbnail and the grid falls back to the original key. A random 6-char token per file prevents same-filename collisions. A gallery may cap its stored resolution (`max_resolution`). The cap is applied in the browser, off the same decode as the thumbnail, so the smaller file is what crosses the wire and PHP's `upload_max_filesize` stops being the ceiling on image size. The re-encode costs the EXIF block, which is why "Original" is the default. Two consequences are worth knowing. A capped gallery decodes at natural size rather than using the `resizeWidth` hint: that hint scales up as readily as down and the resulting bitmap carries no memory of which happened, which is fine for a thumbnail but not for pixels about to be stored. To pay for that, decode and resize run one file at a time even while uploads overlap — it is main-thread canvas work, and concurrent full-size bitmaps are what actually exhausts a phone. Second, undecodable files (RAW) ignore the cap and upload whole, so it is best-effort, not enforced: the server stores what arrives. Object keys are laid out as `//{originals,thumbs}/…`, where `` comes from `s3.prefix` (default `galleries`, `''` = bucket root). **Why parallel.** Each request is store-and-forward: PHP buffers the whole body to a temp file before `s3_put_file()` starts, so during the webhost→S3 leg (and during every thumbnail decode) the browser's uplink sits idle. Overlapping a few requests keeps it saturated. Three things make that safe rather than merely faster: - Both endpoints call `session_write_close()` right after authenticating. PHP holds an exclusive lock on the session file for the whole request, so without it every parallel upload would queue behind the previous one and the uploader would be serial again regardless of how many requests it starts. - The gallery entry is appended via `gallery_append_image()` →`json_update()`, which holds `flock(LOCK_EX)` on a sidecar `.lock` across the whole read-modify-write. (The lock cannot live on the JSON file itself: `json_write()` replaces it by `rename()`, so the inode changes on every write.) Unlocked, eight simultaneous appends lose about five of them. - Transient failures are retried on both sides — up to 3 attempts with backoff in `s3_put_file()` (re-signed and rewound per attempt) and in `admin.js` for network errors, 408, 429 and 5xx. 4xx is a real rejection and is never retried. The manual per-file *retry* link remains for permanent failures. Uploads reuse one curl handle per PHP process (`s3_curl()`), so the thumbnail PUT and any retry skip a fresh TCP + TLS handshake. Proxying uploads through the webhost keeps them same-origin (no bucket CORS) and means no S3 write credential ever reaches the browser. The one-file-per-request rule bounds each PHP process to a single image, so the total gallery size is irrelevant — only the **largest single image** must fit within the host's `upload_max_filesize` / `post_max_size` (see SETUP.md). ## Gallery archives ("Download all") Enable downloads on a gallery and visitors get one ZIP of every photo. It is built **once, into S3**, and the visitor is redirected to a presigned URL for it (`gallery/download.php`) — so a 3 GB download runs browser ↔ S3, resumable via Range requests, and never occupies the webhost at all. **Why not stream the ZIP through PHP.** `max_execution_time` on shared hosting is typically 60 s and cannot be raised, while a gallery can hold 400+ originals of 8 MB. A streaming `download.php` would have to stay alive for the entire transfer. `ZipArchive` is out for the same reason plus disk quota, and a Composer package is out by project policy. Building ahead of time is what makes the 60 s cap irrelevant. **Slices.** `archive_run_slice()` copies as many photos as fit in `archive.step_seconds` (default 25) and returns; state is committed after every photo, so a build is "run slices until finished". The budget is checked *before* starting a photo and never during one, with headroom for one as slow as the slowest seen so far — but a slice always does at least one photo, so a gallery of very large files still creeps forward instead of stalling. Each photo streams S3 → buffer file → S3 in a single pass that also computes its CRC-32. The ZIP needs a local header immediately before each file's bytes and multipart parts are atomic, so `UploadPartCopy` cannot be used and the bytes must travel through the webhost. The header is written with placeholder values and patched once the real size and CRC are known. The buffer accumulates until it passes the 5 MB multipart minimum, then becomes one part; the final part carries the central directory and is exempt from the minimum. **Interruptions.** State goes through `json_write()` (tmp + rename), so it is never half-written. The rest is ordering: | Interrupted | Recovery | | --- | --- | | between photos | resume at `next_index`; nothing to undo | | mid photo | every slice starts by truncating the buffer back to the last committed length, so a partial tail needs no error handling to clean up | | mid part upload | ETag is committed only after S3 accepts, buffer truncated only after that — a crash re-uploads the same part number, which S3 allows | | mid completion | a retried complete returns `NoSuchUpload` once it has already succeeded; a HEAD confirms the object and the build counts as done | | abandoned | the queue entry survives; a build with no progress for `archive.abandon_hours` is aborted (freeing the multipart parts S3 bills for) and restarted | **Staying current.** Every stored or deleted image marks its gallery dirty (`archive_mark_dirty()`, hooked into `gallery_append_image()`). While a gallery is dirty its download button renders disabled with a hover explanation, and `gallery/download.php` refuses too — a client must never receive a ZIP that silently omits the newest photos. Rebuilds are batched by `archive.settle_seconds` (default 5 min) so thirty guests uploading over an hour cause one rebuild, not thirty. **Getting work done without cron.** `archive_kick()` runs at the end of every public page render. It flushes the page to the visitor first, then fires a request at `worker.php` and hangs up; the worker runs a slice and dispatches its own successor, so one upload starts a chain that finishes unattended. The chain lives exactly as long as the queue is non-empty and is bounded by `archive.max_chain`; a site-wide `flock` keeps it to one worker. Where the host cannot make an HTTP request to itself, the same page-render hook runs a slice inline after `fastcgi_finish_request()` instead, and progress needs one page view per slice. A real cron job hitting `worker.php?key=…` works too and is better than either (see SETUP.md). ## Security model - **Admin auth**: credentials in `config/credentials.php` (`password_hash`/`password_verify`); session flag; 5 failed logins → 15 min lock (flat file). Online password change rewrites the credentials file atomically and invalidates the opcache entry. - **CSRF**: session token required on every admin POST (form field) and API call (`X-CSRF-Token` header), and on gallery password submissions. - **Gallery access**: bcrypt-hashed gallery passwords; unlock state is per-gallery in the session. Expiry is a pure server-side date check — expired and nonexistent galleries return the identical 404 page. - **Web exposure**: the document root is the project folder. The root `.htaccess` blocks `app/`, `config/`, `data/`, `docs/` (via `mod_rewrite`) and denies dotfiles, `*.json`, `*.md` and config templates (via `FilesMatch`); each of `app/`, `config/`, `data/` also carries a deny-all `.htaccess` as a fallback for hosts without `mod_rewrite`. `media/.htaccess` serves images only and disables PHP execution. `router.php` reproduces these rules for the PHP built-in server during local development. - **Input hygiene**: slugs validated by regex before touching the filesystem; upload filenames sanitized; S3 object keys are generated server-side under the gallery's own prefix (never taken from the client); only real `is_uploaded_file()` temp files are streamed to S3; all output HTML-escaped via `e()`. ## Known trade-offs - One admin account, one shared session store — fine for a single photographer, not a multi-user CMS. - Gallery JSON writes are last-writer-wins, *except* image appends during upload, which take an exclusive lock (`json_update()`). So this only matters if two admin tabs edit the same gallery's settings simultaneously. - Presigned URLs mean gallery pages must be re-rendered after `s3.url_ttl`; a visitor who keeps a tab open >1 h reloads to see images again. - A gallery archive doubles that gallery's S3 storage while it exists, and rebuilding is all-or-nothing: one new photo re-copies the whole gallery through the webhost. The settle delay keeps that to once per upload burst.