|
|
@@ -0,0 +1,131 @@
|
|
|
+# Architecture
|
|
|
+
|
|
|
+Plain PHP 8, no framework, no database, no Composer. Designed for shared
|
|
|
+webhosting where the only deployment tool is FTP.
|
|
|
+
|
|
|
+## Layout
|
|
|
+
|
|
|
+```
|
|
|
+public/ web root — the only web-accessible directory
|
|
|
+ index.php landing page (hero + intro)
|
|
|
+ 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, not web-accessible
|
|
|
+ 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
|
|
|
+data/ flat-file content: site.json, galleries/<slug>.json
|
|
|
+```
|
|
|
+
|
|
|
+## Flat-file storage
|
|
|
+
|
|
|
+- `data/site.json` — front page text, hero filename, ordered showreel list.
|
|
|
+- `data/galleries/<slug>.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/<slug>/originals/a1b2c3-DSC_0001.jpg",
|
|
|
+ "thumb": "galleries/<slug>/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 | `public/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**: only `public/` is served. If the docroot can't be moved,
|
|
|
+ the root `.htaccess` rewrites into `public/` and deny-all `.htaccess` files
|
|
|
+ protect `app/`, `config/`, `data/`. `public/media/.htaccess` serves images
|
|
|
+ only and disables PHP execution.
|
|
|
+- **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.
|