ARCHITECTURE.md 7.1 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":   "<prefix>/<slug>/originals/a1b2c3-DSC_0001.jpg",
        "thumb": "<prefix>/<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. Addressing style follows s3.path_style (default path-style, https://<endpoint-host>/<bucket>/<key>, which Hetzner serves reliably; set false for virtual-hosted-style https://<bucket>.<endpoint-host>/<key>). Three uses:

  1. Presigned GETgallery.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 PUTs3_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 <prefix>/<slug>/{originals,thumbs}/…, where <prefix> 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.