.lock" does. * $mutate receives the current contents and returns the array to store, or * null to leave the file untouched. Returns the current (or stored) array. */ function json_update(string $file, callable $mutate, array $default = []): array { $dir = dirname($file); if (!is_dir($dir)) { mkdir($dir, 0755, true); } // Cannot lock (read-only dir, exotic host): still perform the update rather // than dropping it — degrades to the previous last-writer-wins behaviour. $lock = fopen($file . '.lock', 'c'); if ($lock !== false) { flock($lock, LOCK_EX); } try { $current = json_read($file, $default); $data = $mutate($current); if ($data === null) { return $current; } json_write($file, $data); return $data; } finally { if ($lock !== false) { flock($lock, LOCK_UN); fclose($lock); } } } /** URL-safe random token. */ function random_token(int $chars = 8): string { $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $out = ''; for ($i = 0; $i < $chars; $i++) { $out .= $alphabet[random_int(0, strlen($alphabet) - 1)]; } return $out; } /** * Transliterate German (and, best effort, other accented) characters to ASCII * so they survive in slugs, filenames and S3 keys instead of being dropped: * ä→ae, ö→oe, ü→ue, ß→ss, é→e, … Case is preserved. */ function ascii_transliterate(string $s): string { $map = [ 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss', ]; $s = strtr($s, $map); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s); if ($converted !== false) { $s = $converted; } } return $s; } /** Turn a title into a URL slug fragment ("Wedding Müller" → "wedding-mueller"). */ function slugify(string $title): string { $s = strtolower(ascii_transliterate($title)); $s = preg_replace('/[^a-z0-9]+/', '-', $s) ?? ''; $s = trim($s, '-'); return $s !== '' ? $s : 'gallery'; } /** * Sanitize an upload filename to a safe ASCII basename, keeping the extension. * German characters are transliterated rather than replaced by dashes, so * "Straße.jpg" becomes "Strasse.jpg" instead of "Stra-e.jpg". */ function safe_filename(string $name, string $fallback = 'file'): string { $name = basename($name); $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $base = ascii_transliterate(pathinfo($name, PATHINFO_FILENAME)); $base = preg_replace('/[^A-Za-z0-9._-]+/', '-', $base) ?? ''; $base = trim($base, '-.'); if ($base === '') { $base = $fallback; } $ext = preg_replace('/[^A-Za-z0-9]+/', '', $ext) ?? ''; return $ext !== '' ? $base . '.' . $ext : $base; } // --------------------------------------------------------------------------- // Local media (hero + showreel images in media/) // --------------------------------------------------------------------------- const MEDIA_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']; /** * Store one uploaded image in media/, full resolution, unmodified. * Returns the stored filename, or null if the upload is invalid. */ function media_store_upload(array $file): ?string { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return null; } $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); if (!in_array($ext, MEDIA_EXTENSIONS, true)) { return null; } // Cheap content sanity check without touching the image data. if (function_exists('getimagesize') && @getimagesize($file['tmp_name']) === false) { return null; } $base = ascii_transliterate(pathinfo($file['name'], PATHINFO_FILENAME)); $base = trim(preg_replace('/[^A-Za-z0-9._-]+/', '-', $base) ?? '', '-.') ?: 'image'; $name = substr($base, 0, 60) . '-' . random_token(6) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], MEDIA_DIR . '/' . $name)) { return null; } return $name; } /** Delete a local media file (filename only, no paths). */ function media_delete(string $name): void { if ($name !== '' && basename($name) === $name) { @unlink(MEDIA_DIR . '/' . $name); } } // --------------------------------------------------------------------------- // Site content (landing page + showreel) // --------------------------------------------------------------------------- function site_get(): array { return json_read(DATA_DIR . '/site.json', [ 'intro_title' => 'Jane Doe', 'intro_text' => "Photographer based in Berlin.\nAvailable for portraits, weddings and events.", 'hero_image' => null, 'showreel' => [], 'contact_title' => 'Get in touch', 'contact_text' => "For bookings and enquiries, drop me a line.", 'contact_email' => '', 'contact_phone' => '', 'contact_instagram' => '', 'impressum' => '', 'datenschutz' => '', ]); } function site_save(array $site): void { json_write(DATA_DIR . '/site.json', $site); } // --------------------------------------------------------------------------- // Galleries — one JSON file per gallery in data/galleries/ // --------------------------------------------------------------------------- /** * Path of one of a gallery's data files. The single place a slug becomes a * filesystem path, so the validation below covers every one of them. */ function gallery_path(string $slug, string $suffix): string { // Slugs are generated by us, but never trust a request parameter in a path. if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/', $slug) || strlen($slug) > 120) { throw new InvalidArgumentException('Invalid gallery slug'); } return DATA_DIR . '/galleries/' . $slug . $suffix; } function gallery_file(string $slug): string { return gallery_path($slug, '.json'); } /** Progress state of an in-flight archive build (see app/archive.php). */ function gallery_archive_file(string $slug): string { return gallery_path($slug, '.archive.json'); } /** The archive build's pending multipart part, waiting to reach 5 MB. */ function gallery_archive_buffer(string $slug): string { return gallery_path($slug, '.archive.buf'); } function gallery_load(string $slug): ?array { try { $file = gallery_file($slug); } catch (InvalidArgumentException) { return null; } $g = json_read($file); return $g === [] ? null : $g; } function gallery_save(array $gallery): void { json_write(gallery_file($gallery['slug']), $gallery); } function gallery_delete(string $slug): void { // Any half-finished archive build dies with the gallery. This has to abort // the multipart upload it was feeding, not just drop the local state file — // S3 stores and bills for the parts of an incomplete upload indefinitely. archive_abort($slug); archive_unqueue($slug); $file = gallery_file($slug); if (is_file($file)) { unlink($file); } @unlink($file . '.lock'); @unlink(gallery_archive_file($slug) . '.lock'); } /** * Append one image to a gallery under an exclusive lock, so parallel uploads * into the same gallery cannot overwrite each other's entries. * * Returns the new image count, or null if the gallery no longer exists — an * absent gallery must not be resurrected as a stub by a late upload. */ function gallery_append_image(string $slug, array $image): ?int { $missing = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($image, &$missing) { if ($g === []) { $missing = true; return null; // deleted mid-upload — do not write a stub file back } $g['images'][] = $image; return $g; }); if ($missing) { return null; } // The gallery's ZIP archive, if it has one, no longer matches its contents. archive_mark_dirty($slug, $gallery); return count($gallery['images'] ?? []); } /** All galleries, newest first. */ function galleries_all(): array { $out = []; foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) { // Skip the archive-build sidecars that live in the same directory. if (str_ends_with($file, '.archive.json')) { continue; } $g = json_read($file); if ($g !== []) { $out[] = $g; } } usort($out, fn($a, $b) => strcmp($b['created_at'] ?? '', $a['created_at'] ?? '')); return $out; } /** A gallery past its expiry date is treated as nonexistent for visitors. */ function gallery_is_expired(array $gallery): bool { $expires = $gallery['expires_at'] ?? null; if ($expires === null || $expires === '') { return false; } // The gallery stays visible through the whole expiry day. return date('Y-m-d') > $expires; } // --------------------------------------------------------------------------- // Upload resolution cap (per gallery) // --------------------------------------------------------------------------- /** * Named sizes offered in the gallery forms, largest first. Only the pixel value * is ever stored, so renaming a preset here cannot orphan existing galleries — * a gallery capped at 2560 simply starts reading as whatever that number is * called now, and a value matching no preset renders as bare pixels. */ const RESOLUTION_PRESETS = [ 'Ultra' => 4096, 'High' => 2560, 'Mid' => 1920, 'Low' => 1280, ]; const RESOLUTION_MIN = 320; const RESOLUTION_MAX = 12000; /** * Read a max_resolution choice from a submitted form: a preset's pixel value, a * custom number, or null for "Original" (no resize). Out-of-range custom values * are clamped rather than rejected — a typo becomes the nearest sane cap * instead of silently turning the resize off. */ function parse_max_resolution(array $post): ?int { $choice = trim((string)($post['max_resolution'] ?? '')); $value = $choice === 'custom' ? trim((string)($post['max_resolution_custom'] ?? '')) : $choice; if ($value === '' || !ctype_digit($value)) { return null; } return max(RESOLUTION_MIN, min(RESOLUTION_MAX, (int)$value)); } /** Human label for a cap: "High (2560 px)", "800 px", or "Original". */ function resolution_label(?int $px): string { if ($px === null) { return 'Original'; } $name = array_search($px, RESOLUTION_PRESETS, true); return $name === false ? "$px px" : "$name ($px px)"; }