ARCHITECTURE.md 24 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/           client gallery viewer, served as /gallery/?g=<slug>
  index.php        password gate, expiry, grid + lightbox
  download.php     redirects to the presigned URL of the gallery's ZIP
worker.php         background archive builder (self-dispatching, key-protected)
manage-worker.php  background backup + heartbeat runner (key-protected)
admin/             backoffice (session-protected)
  maintenance.php  backup, update, migrations — the manage client's UI
  api.php          JSON API for the uploader (presign / register)
  archive-api.php  JSON API for building an archive on demand
  topics-api.php   JSON API for moving one image into a topic
  migrate.php      one-time data migrations, run by hand after an update
assets/            site.css, site.js (nav + lightbox + topic collapse),
                   admin.js (uploader), archive.js (archive build progress),
                   topics.js (topic menu + drag-and-drop in the editor)
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, PUT, DELETE, GET, multipart)
  exif.php         metadata stripping for uploads (JPEG/PNG/WebP containers)
  zip.php          store-only ZIP64 writer
  archive.php      archive build slices, dirty queue, worker dispatch
  manage.php       backup/heartbeat schedule for hosts without cron
  after-update.php post-update hook, run by the manage client
  version.php      APP_VERSION, rewritten when a release is built
  migrate.php      numbered schema migrations + the schema version constant
  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
manage-client/     update + backup client, config.php holds the instance token
migrations/        one-time scripts shipped inside a release package
scripts/           release build script, cron examples
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
    "strip_exif": true,                 // remove metadata from uploads
    "schema_version": 1,                // absent on files older than admin/migrate.php
    "topics": [                         // optional sections, in display order
      { "id": "t7k3f9a", "name": "Day 1" }
    ],
    "images": [
      { "key":   "<prefix>/<slug>/originals/a1b2c3-DSC_0001.jpg",
        "thumb": "<prefix>/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
        "name":  "DSC_0001.jpg", "size": 18349201,
        "topic": "t7k3f9a" }            // absent = no topic
    ]
    }
    

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.

Topics

A gallery can be split into named sections — the days of a trip, the stops of a shoot. They are optional and additive, so a gallery file written before they existed is already valid: it has no topics, no image carries a topic, and it renders, uploads and archives exactly as it always did.

The images array stays one flat list in upload order. The grouping is derived, by gallery_groups() in app/storage.php, wherever it is needed — the client gallery, the editor, and the ZIP layout all come from that one function, so they can never disagree. Nothing is nested on disk, which is what keeps the upload ordering (batch/seq, see below) untouched by any of this.

  • Images without a topic always render first, with no heading. Topics follow in their stored order, each with a heading and a collapse toggle.
  • A topic naming a topic that no longer exists reads as no topic. A dangling reference must never make a photo disappear from a client's gallery.
  • Guest uploads land in a topic with the reserved id guest, created on demand inside gallery_append_image()'s lock — two guests uploading at the same moment cannot each create their own. The admin may rename or reorder it; the id stays, so later guest uploads keep landing there.
  • Assignment runs through admin/topics-api.php (drag-and-drop cannot reload the page after every drop). Creating, renaming, reordering and deleting topics are plain form posts on the editor page. Deleting a topic keeps its images; they go back to having none.
  • In the ZIP, each topic is a folder and untopiced photos sit in the root (archive_entries()). Filenames are deduped per folder, so the same DSC_0001.jpg may appear once in every topic.

One-time migrations (app/migrate.php, admin/migrate.php)

Schema changes have always been absorbed by lazy defaults ($g['x'] ?? default), and still are — the application never requires a migration to have been run. What app/migrate.php adds is a place to finish a change instead of leaving every gallery in one of two shapes for ever: numbered steps in MIGRATIONS, a schema_version recorded per gallery, and admin/migrate.php to run them.

Nothing runs automatically. An FTP upload of new files must never be able to lock a site out of its own data, so the operator triggers the pass from the backoffice; the dashboard shows a banner while anything is outstanding. Every step must be idempotent, must write through json_update(), and must never delete anything.

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 re-encoded anywhere in the pipeline. Two per-gallery options change what is stored, both off by default: max_resolution downscales in the browser before the upload, and strip_exif removes the metadata blocks on the webhost. Neither ever decodes and re-compresses an original the server has received — stripping is a container rewrite, and the pixels come out bit-identical.

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 GET — gallery/index.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 PUT — s3_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 ────────▶
   │                                                 │  store in 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.

A gallery may also strip metadata (strip_exif, app/exif.php). Unlike the cap this runs on the webhost, between the upload temp file and the S3 PUT: the file is rewritten without its EXIF, XMP, IPTC/Photoshop and comment blocks into a second temp file, which is what goes up. Doing it server-side rather than in admin.js means the guarantee holds for every upload, including one from a stale cached uploader or a hand-made POST, and it costs nothing extra — the bytes are already sitting in a temp file.

The pixels are never touched: no decode, no re-encode, no quality change. What the picture needs to render stays — the ICC colour profile, the JFIF density block, the Adobe colour-transform block — and the orientation flag is re-emitted in a segment holding that tag and nothing else, so a portrait photo is not turned on its side by having its metadata removed. JPEG (marker segments), PNG (chunks) and WebP (RIFF chunks, including the VP8X presence flags) are understood; anything else, RAW included, is uploaded exactly as it arrived. The result is checked with getimagesize() against the input before it is used, so a parse that goes wrong costs the strip and never the photo. A capped gallery gets stripping for free from the browser's re-encode, which is why the two are separate switches.

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. Four 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 stored 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.
  • Gallery order is array order, and uploads finish in an order set by file size and network luck — so the entry is placed, not appended. admin.js tags each selection (one drop, or one trip through the file dialog) with a random batch id and each file with its seq within it; gallery_image_position() puts the arrival next to its siblings. A batch therefore occupies one contiguous run: the first arrival appends at the end, later ones insert inside that run, so a second selection dropped mid-upload stays separate and in order, and a manual retry rejoins its original place. Entries stored before this existed carry no batch and are never moved; an upload without usable ordering (an older cached admin.js) simply appends.
  • 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).

Gallery archives ("Download all")

Enable downloads on a gallery and visitors get one ZIP of every photo. It is built once, into S3, and the visitor is redirected to a presigned URL for it (gallery/download.php) — so a 3 GB download runs browser ↔ S3, resumable via Range requests, and never occupies the webhost at all.

Why not stream the ZIP through PHP. max_execution_time on shared hosting is typically 60 s and cannot be raised, while a gallery can hold 400+ originals of 8 MB. A streaming download.php would have to stay alive for the entire transfer. ZipArchive is out for the same reason plus disk quota, and a Composer package is out by project policy. Building ahead of time is what makes the 60 s cap irrelevant.

Slices. archive_run_slice() copies as many photos as fit in archive.step_seconds (default 25) and returns; state is committed after every photo, so a build is "run slices until finished". The budget is checked before starting a photo and never during one, with headroom for one as slow as the slowest seen so far — but a slice always does at least one photo, so a gallery of very large files still creeps forward instead of stalling.

Each photo streams S3 → buffer file → S3 in a single pass that also computes its CRC-32. The ZIP needs a local header immediately before each file's bytes and multipart parts are atomic, so UploadPartCopy cannot be used and the bytes must travel through the webhost. The header is written with placeholder values and patched once the real size and CRC are known. The buffer accumulates until it passes the 5 MB multipart minimum, then becomes one part; the final part carries the central directory and is exempt from the minimum.

Interruptions. State goes through json_write() (tmp + rename), so it is never half-written. The rest is ordering:

Interrupted Recovery
between photos resume at next_index; nothing to undo
mid photo every slice starts by truncating the buffer back to the last committed length, so a partial tail needs no error handling to clean up
mid part upload ETag is committed only after S3 accepts, buffer truncated only after that — a crash re-uploads the same part number, which S3 allows
mid completion a retried complete returns NoSuchUpload once it has already succeeded; a HEAD confirms the object and the build counts as done
abandoned the queue entry survives; a build with no progress for archive.abandon_hours is aborted (freeing the multipart parts S3 bills for) and restarted

Staying current. Every stored or deleted image marks its gallery dirty (archive_mark_dirty(), hooked into gallery_append_image()), as does anything that moves a photo between the ZIP's folders — assigning a topic, renaming one, reordering them. Staleness is decided by archive_source_hash(), which a build records and later compares against. For a gallery using no topics that hash is still taken over the image keys alone, exactly as before topics existed, so installing this version invalidates no archive that has already been built; once topics are in play it is taken over the ZIP paths instead, which covers keys, order, membership, topic order and topic names at once. While a gallery is dirty its download button renders disabled with a hover explanation, and gallery/download.php refuses too — a client must never receive a ZIP that silently omits the newest photos. Rebuilds are batched by archive.settle_seconds (default 5 min) so thirty guests uploading over an hour cause one rebuild, not thirty.

Getting work done without cron. archive_kick() runs at the end of every public page render. It flushes the page to the visitor first, then fires a request at worker.php and hangs up; the worker runs a slice and dispatches its own successor, so one upload starts a chain that finishes unattended. The chain lives exactly as long as the queue is non-empty and is bounded by archive.max_chain; a site-wide flock keeps it to one worker. Where the host cannot make an HTTP request to itself, the same page-render hook runs a slice inline after fastcgi_finish_request() instead, and progress needs one page view per slice. A real cron job hitting worker.php?key=… works too and is better than either (see SETUP.md).

Updates and backups (manage-client/, app/manage.php)

The installation talks to a central manage server: it fetches releases from it and sends backups to it. The client is vendor code kept unmodified in manage-client/, so a newer version of it can be dropped in; everything project-specific lives outside it — manage-client/config.php (paths, backup sources, protected paths), app/after-update.php, admin/maintenance.php and the schedule in app/manage.php.

Version. app/version.php defines APP_VERSION (vMAJOR.MINOR.PATCH) and nothing writes it at runtime: scripts/create-release-zip.sh writes it into the package, and an update changes it as a side effect of copying the file. The client reads the literal back with a regular expression rather than by including the file, so the value is correct even in the request that just deployed it.

Update. A release is a ZIP whose root is the document root. The client verifies size and SHA-256 against the manifest, refuses a package that does not contain app/bootstrap.php, copies every file over the installation while saving each replaced one to data/manage/updates/, then runs the pending scripts in migrations/ and finally app/after-update.php. config/config.php, config/credentials.php, data/ and media/ are on the protected list and are never written. There is no rollback and no maintenance mode, which is why the Maintenance page takes a backup first by default and why updates never happen on a timer. Deleted files are not removed — deployment is an overlay, so dropping a file is a migration's job.

Backup. data/ and media/ — the flat-file content and the locally hosted images. Not gallery photos: those live in S3, which is their own copy. Not config/: a backup is uploaded to the manage server and can be downloaded from it, so it must not carry the S3 keys or the password hash. There is no restore command; the archive is a plain ZIP to unpack over data/ and media/.

Schedule without cron. manage_kick() runs at the end of every backoffice page render, the same trick as archive_kick(): one stat() on data/manage.tick when nothing is due, otherwise flush the page and fire a request at manage-worker.php, falling back to inline work where the host cannot call itself. It runs a backup when the newest scheduled one is older than MANAGE_BACKUP_AUTO_INTERVAL_SECONDS (a week) and a heartbeat hourly, and never an update. The release check is not on the schedule either: it is a network round trip, so it happens only when the Maintenance page is opened, which keeps every other admin page independent of the manage server being reachable.

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.
  • Manage client: manage-client/config.php holds an instance token that is equivalent to write access to the backups on the manage server — it is gitignored, kept out of release packages and out of backups, and blocked from the web twice over. manage-worker.php is authenticated by the same data/worker-key.json key as worker.php and does nothing an unauthenticated caller could exploit; admin/maintenance.php, which can deploy code, sits behind the admin session and the CSRF token like every other admin page.
  • Web exposure: the document root is the project folder. The root .htaccess blocks app/, config/, data/, docs/, manage-client/, migrations/, scripts/ (via mod_rewrite) and denies dotfiles, *.json, *.md and config templates (via FilesMatch); each of app/, config/, data/ and manage-client/ 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.
  • A gallery archive doubles that gallery's S3 storage while it exists, and rebuilding is all-or-nothing: one new photo re-copies the whole gallery through the webhost. The settle delay keeps that to once per upload burst.