ARCHITECTURE.md 6.3 KB

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/<slug>.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/<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 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: 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.