# 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.php client gallery: password gate, expiry, grid + lightbox admin/ backoffice (session-protected) api.php JSON API for the uploader (presign / register) assets/ site.css, site.js (nav + lightbox), admin.js (uploader) 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 GET/PUT, signed DELETE) 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 "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.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**, sequentially, with progress; failures get a per-file retry. The thumbnail is drawn client-side (`createImageBitmap` + `imageOrientation: 'from-image'` for EXIF rotation) 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. Object keys are laid out as `//{originals,thumbs}/…`, where `` comes from `s3.prefix` (default `galleries`, `''` = bucket root). 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). ## 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; the uploader registers files sequentially, so this only matters if two admin tabs edit the same gallery 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.