ARCHITECTURE.md 9.7 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
    "max_resolution": 2560,             // longest edge in px, or null = original
    "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, with uploads.concurrency (default 3) files in flight at once and per-file progress. The thumbnail is drawn client-side (createImageBitmap + imageOrientation: 'from-image' for EXIF rotation, with a resizeWidth hint so large JPEGs downsample during decode instead of being decoded at full resolution) 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.

A gallery may cap its stored resolution (max_resolution). The cap is applied in the browser, off the same decode as the thumbnail, so the smaller file is what crosses the wire and PHP's upload_max_filesize stops being the ceiling on image size. The re-encode costs the EXIF block, which is why "Original" is the default.

Two consequences are worth knowing. A capped gallery decodes at natural size rather than using the resizeWidth hint: that hint scales up as readily as down and the resulting bitmap carries no memory of which happened, which is fine for a thumbnail but not for pixels about to be stored. To pay for that, decode and resize run one file at a time even while uploads overlap — it is main-thread canvas work, and concurrent full-size bitmaps are what actually exhausts a phone. Second, undecodable files (RAW) ignore the cap and upload whole, so it is best-effort, not enforced: the server stores what arrives. Object keys are laid out as <prefix>/<slug>/{originals,thumbs}/…, where <prefix> comes from s3.prefix (default galleries, '' = bucket root).

Why parallel. Each request is store-and-forward: PHP buffers the whole body to a temp file before s3_put_file() starts, so during the webhost→S3 leg (and during every thumbnail decode) the browser's uplink sits idle. Overlapping a few requests keeps it saturated. Three things make that safe rather than merely faster:

  • Both endpoints call session_write_close() right after authenticating. PHP holds an exclusive lock on the session file for the whole request, so without it every parallel upload would queue behind the previous one and the uploader would be serial again regardless of how many requests it starts.
  • The gallery entry is appended via gallery_append_image()json_update(), which holds flock(LOCK_EX) on a sidecar <file>.lock across the whole read-modify-write. (The lock cannot live on the JSON file itself: json_write() replaces it by rename(), so the inode changes on every write.) Unlocked, eight simultaneous appends lose about five of them.
  • Transient failures are retried on both sides — up to 3 attempts with backoff in s3_put_file() (re-signed and rewound per attempt) and in admin.js for network errors, 408, 429 and 5xx. 4xx is a real rejection and is never retried. The manual per-file retry link remains for permanent failures.

Uploads reuse one curl handle per PHP process (s3_curl()), so the thumbnail PUT and any retry skip a fresh TCP + TLS handshake.

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, except image appends during upload, which take an exclusive lock (json_update()). So this only matters if two admin tabs edit the same gallery's settings 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.