Plain PHP 8, no framework, no database, no Composer. Designed for shared webhosting where the only deployment tool is FTP.
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
cron.php the optional cronjob: drains every background job at once
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
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.
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.
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, 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.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.archive_entries()). Filenames are deduped per folder, so the same
DSC_0001.jpg may appear once in every topic.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.
| 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.
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:
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.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.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.
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:
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.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.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.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).
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.
Getting work done with cron. Where the host has cron, cron.php at the
project root is the one line to install, from the shell or as a URL fetch. It
drains everything outstanding in a single invocation — every settled gallery, not
just the first, plus whatever the backup/heartbeat schedule owes — bounded by
cron.max_seconds, and is off until cron.enabled is set. The logic is in
app/cron.php.
It runs alongside the page-render hooks rather than replacing them, and the
locks decide who does what. The one subtlety: the drain takes and releases the
site-wide archive lock per slice, where worker.php holds it across its whole
body. Holding it for a four-minute drain would starve the worker chain and make
an admin's "Rebuild now" — which waits only archive_lock(15) — fail in front of
a human. Releasing between slices means the two drivers interleave slice by
slice, while the lock still guarantees that only one process ever touches a given
archive's state file, buffer and multipart upload. The drain also never sleeps out
a settle window (the next tick will look again), skips a gallery for the rest of
the invocation if its slice errored or made no progress, and resets the chain
counter when it empties the queue, exactly as a finished chain does.
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.
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.X-CSRF-Token header), and on gallery password submissions.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..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.is_uploaded_file() temp files are streamed to S3; all output HTML-escaped
via e().json_update()). So this only matters
if two admin tabs edit the same gallery's settings simultaneously.s3.url_ttl;
a visitor who keeps a tab open >1 h reloads to see images again.