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.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
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.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).
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.