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)
admin/ backoffice (session-protected)
api.php JSON API for the uploader (presign / register)
archive-api.php JSON API for building an archive on demand
assets/ site.css, site.js (nav + lightbox), admin.js (uploader),
archive.js (archive build progress)
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)
zip.php store-only ZIP64 writer
archive.php archive build slices, dirty queue, worker dispatch
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
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.
| 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.
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 ────────▶
│ │ 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:
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.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()). 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).
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..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.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.