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": "<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, sequentially, with progress;
failures get a per-file retry. The thumbnail is drawn client-side
(createImageBitmap + imageOrientation: 'from-image' for EXIF rotation) 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. Object keys are laid out as
<prefix>/<slug>/{originals,thumbs}/…, where <prefix> comes from s3.prefix
(default galleries, '' = bucket root).
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().s3.url_ttl;
a visitor who keeps a tab open >1 h reloads to see images again.