# 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": "galleries//originals/a1b2c3-DSC_0001.jpg", "thumb": "galleries//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. Three uses: 1. **Presigned GET** — `gallery.php` embeds signed image URLs (`s3.url_ttl`, default 1 h). The browser fetches from S3 directly; the webhost serves only HTML. 2. **Presigned PUT** — `admin/api.php` hands the uploader short-lived upload URLs. Only the `Host` header is signed, so the browser may send its own `Content-Type`. 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 → S3) ``` admin.js api.php Hetzner S3 │ action=presign ───────▶ │ │ ◀─── key, thumb, 2 PUT URLs │ PUT original (unmodified, full res) ───────────────▶ │ canvas → JPEG thumb; PUT thumb ────────────────────▶ │ action=register ──────▶ │ appends to gallery JSON ``` Files are uploaded sequentially with progress; failures get a per-file retry. The thumbnail is drawn client-side (`createImageBitmap` + `imageOrientation: 'from-image'` for EXIF rotation). Undecodable files (RAW, video) upload without a thumbnail; the grid then falls back to the original key. Random 6-char key prefixes prevent same-filename collisions. Requires a CORS rule on the bucket (see SETUP.md). This route exists because shared hosting typically limits `post_max_size` and request time — a 2 GB wedding shoot can't pass through the webhost, but it can go straight to S3. ## 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; `register` keys must lie under the gallery's own S3 prefix; 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.