ARCHITECTURE.md 5.8 KB

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:

    {
    "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 GETgallery.php embeds signed image URLs (s3.url_ttl, default 1 h). The browser fetches from S3 directly; the webhost serves only HTML.
  2. Presigned PUTadmin/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.