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
"images": [
{ "key": "galleries/<slug>/originals/a1b2c3-DSC_0001.jpg",
"thumb": "galleries/<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. Three uses:
gallery.php embeds signed image URLs
(s3.url_ttl, default 1 h). The browser fetches from S3 directly; the
webhost serves only HTML.admin/api.php hands the uploader short-lived upload
URLs. Only the Host header is signed, so the browser may send its own
Content-Type.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
│ action=presign ───────▶ │
│ ◀─── key, thumb, 2 PUT URLs
│ PUT original (unmodified, full res) ───────────────▶
│ canvas → JPEG thumb; PUT thumb ────────────────────▶
│ action=register ──────▶ │ appends to gallery JSON
Files are uploaded sequentially with progress; failures get a per-file retry.
The thumbnail is drawn client-side (createImageBitmap +
imageOrientation: 'from-image' for EXIF rotation). Undecodable files (RAW,
video) upload without a thumbnail; the grid then falls back to the original
key. Random 6-char key prefixes prevent same-filename collisions. Requires a
CORS rule on the bucket (see SETUP.md).
This route exists because shared hosting typically limits post_max_size and
request time — a 2 GB wedding shoot can't pass through the webhost, but it
can go straight to S3.
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.register keys must lie under the gallery's own
S3 prefix; all output HTML-escaped via e().s3.url_ttl;
a visitor who keeps a tab open >1 h reloads to see images again.