.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'); } /** * The gallery's visit counters. Deliberately a sidecar rather than fields in * the gallery file: every visitor writes it, and the gallery file — hundreds of * image entries — would be rewritten in full on each page view, in contention * with uploads and archive builds. */ function gallery_stats_file(string $slug): string { return gallery_path($slug, '.stats.json'); } 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'); @unlink(gallery_stats_file($slug)); @unlink(gallery_stats_file($slug) . '.lock'); } /** * Where a newly uploaded image belongs among the ones already stored. * * Uploads run several at a time, so they finish in an order set by file size * and network luck, not by the order the photographer picked them. Each job * therefore carries the batch it was selected in and its position within that * batch ($image['batch'] / $image['seq']), and lands next to its siblings * instead of wherever it happened to arrive. * * A batch occupies one contiguous run: its first arrival appends at the end, * and every later one inserts inside that run, which only shifts the runs after * it. So dropping a second selection while the first is still uploading keeps * the two apart, in the order they were dropped. * * Returns the insert position, or null to append — for an unknown batch, and * for images stored before this ordering existed (no batch at all). */ function gallery_image_position(array $images, array $image): ?int { $batch = $image['batch'] ?? null; if (!is_string($batch) || $batch === '') { return null; } $seq = (int)($image['seq'] ?? 0); $pos = null; foreach ($images as $i => $existing) { if (($existing['batch'] ?? null) !== $batch) { continue; } if ((int)($existing['seq'] ?? 0) > $seq) { return $i; // first sibling that belongs after us } $pos = $i + 1; } return $pos; } /** * Insert one image into a gallery under an exclusive lock, so parallel uploads * into the same gallery cannot overwrite each other's entries. * * Position comes from gallery_image_position(), so the stored order follows the * selection order rather than the order the uploads completed in. * * $topic is the topic id the image should land in, or null for none. The * special value GALLERY_GUEST_TOPIC is created on the fly if the gallery has no * guest topic yet — inside this function's lock, so two guests uploading at the * same moment cannot each append their own copy of it. * * 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, ?string $topic = null): ?int { $missing = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($image, $topic, &$missing) { if ($g === []) { $missing = true; return null; // deleted mid-upload — do not write a stub file back } if ($topic === GALLERY_GUEST_TOPIC) { $g = gallery_with_guest_topic($g); } // Silently drop a topic that no longer exists rather than storing a // dangling reference: the gallery may have been edited mid-upload. if ($topic !== null && isset(gallery_topic_map($g)[$topic])) { $image['topic'] = $topic; } $images = $g['images'] ?? []; $at = gallery_image_position($images, $image); if ($at === null) { $images[] = $image; } else { array_splice($images, $at, 0, [$image]); } $g['images'] = $images; 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'] ?? []); } // --------------------------------------------------------------------------- // Topics — optional named sections within one gallery // --------------------------------------------------------------------------- /** * Topics group a gallery's images into sections: the days of a trip, the stops * of a shoot. They are entirely optional — a gallery with no topics behaves, * renders and archives exactly as it did before they existed. * * The gallery record gains one key, and an image one optional key: * * 'topics' => [ ['id' => 't7k3f9a', 'name' => 'Day 1'], … ] // display order * 'images' => [ ['key' => …, 'topic' => 't7k3f9a'], … ] // absent = none * * The images array itself stays one flat list in upload order; the grouping is * *derived* by gallery_groups() wherever it is needed. That keeps the upload * ordering above (batch/seq) untouched, and means a gallery file written before * topics existed is already a valid one — nothing has to be backfilled for the * gallery to work. See app/migrate.php for the tidy-up pass. */ /** Id of the topic guest uploads land in. Reserved; never handed out by gallery_topic_add(). */ const GALLERY_GUEST_TOPIC = 'guest'; const GALLERY_GUEST_TOPIC_NAME = 'Guest uploads'; /** Ids are ours, but they arrive back from forms and the assign endpoint. */ function gallery_topic_id_valid(string $id): bool { return (bool)preg_match('/^[A-Za-z0-9_-]{1,32}$/', $id); } /** Trim a submitted topic name to something storable; '' means "reject". */ function gallery_topic_name(string $name): string { return substr(trim(preg_replace('/\s+/u', ' ', $name) ?? ''), 0, 80); } /** * A gallery's topics, normalised: well-formed entries only, duplicate ids * dropped, stored order preserved. Every reader goes through this, so a * hand-edited or half-migrated file cannot break a page. */ function gallery_topics(array $gallery): array { $out = []; $seen = []; foreach ($gallery['topics'] ?? [] as $topic) { if (!is_array($topic)) { continue; } $id = (string)($topic['id'] ?? ''); $name = gallery_topic_name((string)($topic['name'] ?? '')); if ($id === '' || $name === '' || isset($seen[$id]) || !gallery_topic_id_valid($id)) { continue; } $seen[$id] = true; $out[] = ['id' => $id, 'name' => $name]; } return $out; } /** id => name, for membership tests and label lookups. */ function gallery_topic_map(array $gallery): array { return array_column(gallery_topics($gallery), 'name', 'id'); } /** * A gallery's images grouped for display, in render order: the images with no * topic first, then each topic in its stored order. Within a group the images * keep their existing order, so uploads still land where batch/seq put them. * * An image whose topic id matches no existing topic reads as untopiced — a * dangling reference must never make a photo vanish from the gallery. * * $includeEmpty keeps topics that hold no images (and the untopiced group when * the gallery has topics at all): the admin editor needs them as drop targets, * the public view does not want to show empty headings. * * Returns [ ['topic' => null|['id'=>…,'name'=>…], 'images' => [...]], … ]. */ function gallery_groups(array $gallery, bool $includeEmpty = false): array { $topics = gallery_topics($gallery); $map = gallery_topic_map($gallery); $buckets = ['' => []]; foreach ($topics as $topic) { $buckets[$topic['id']] = []; } foreach ($gallery['images'] ?? [] as $image) { $id = (string)($image['topic'] ?? ''); $buckets[isset($map[$id]) ? $id : ''][] = $image; } $groups = []; if ($buckets[''] !== [] || ($includeEmpty && $topics !== [])) { $groups[] = ['topic' => null, 'images' => $buckets['']]; } foreach ($topics as $topic) { if ($buckets[$topic['id']] === [] && !$includeEmpty) { continue; } $groups[] = ['topic' => $topic, 'images' => $buckets[$topic['id']]]; } return $groups; } /** Whether topics play any part in this gallery — the pre-topics fast path. */ function gallery_uses_topics(array $gallery): bool { if (gallery_topics($gallery) !== []) { return true; } foreach ($gallery['images'] ?? [] as $image) { if (($image['topic'] ?? '') !== '') { return true; } } return false; } /** * The gallery with a guest topic guaranteed to exist. Called inside a lock by * gallery_append_image(); the admin may rename or reorder the topic afterwards, * but the id stays 'guest', so later guest uploads keep landing in it. Deleting * it simply means the next guest upload creates it again. */ function gallery_with_guest_topic(array $gallery): array { if (isset(gallery_topic_map($gallery)[GALLERY_GUEST_TOPIC])) { return $gallery; } $topics = gallery_topics($gallery); $topics[] = ['id' => GALLERY_GUEST_TOPIC, 'name' => GALLERY_GUEST_TOPIC_NAME]; $gallery['topics'] = $topics; return $gallery; } /** * Add a topic. Returns the stored entry, or null if the name was empty or the * gallery is gone. Ids are random rather than derived from the name, so * renaming a topic never has to touch the images pointing at it. */ function gallery_topic_add(string $slug, string $name): ?array { $name = gallery_topic_name($name); if ($name === '') { return null; } $added = null; json_update(gallery_file($slug), function (array $g) use ($name, &$added): ?array { if ($g === []) { return null; } $topics = gallery_topics($g); do { $id = 't' . random_token(6); } while (isset(gallery_topic_map($g)[$id])); $added = ['id' => $id, 'name' => $name]; $topics[] = $added; $g['topics'] = $topics; return $g; }); return $added; } /** Rename a topic in place. The archive folder is named after it, hence dirty. */ function gallery_topic_rename(string $slug, string $id, string $name): bool { $name = gallery_topic_name($name); if ($name === '' || !gallery_topic_id_valid($id)) { return false; } $changed = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $name, &$changed): ?array { $topics = gallery_topics($g); foreach ($topics as $i => $topic) { if ($topic['id'] === $id && $topic['name'] !== $name) { $topics[$i]['name'] = $name; $g['topics'] = $topics; $changed = true; return $g; } } return null; }); if ($changed) { archive_mark_dirty($slug, $gallery); } return $changed; } /** * Remove a topic. Its images are not deleted — they fall back to no topic, and * so reappear at the top of the gallery. */ function gallery_topic_delete(string $slug, string $id): bool { if (!gallery_topic_id_valid($id)) { return false; } $changed = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($id, &$changed): ?array { $topics = gallery_topics($g); $kept = array_values(array_filter($topics, fn(array $t): bool => $t['id'] !== $id)); if (count($kept) === count($topics)) { return null; } $g['topics'] = $kept; foreach ($g['images'] ?? [] as $i => $image) { if (($image['topic'] ?? '') === $id) { unset($g['images'][$i]['topic']); } } $changed = true; return $g; }); if ($changed) { archive_mark_dirty($slug, $gallery); } return $changed; } /** * Move a topic one place up or down in the display order. Mirrors the showreel * reordering in admin/showreel.php: a swap with the neighbour, no-op at the end. */ function gallery_topic_move(string $slug, string $id, string $dir): bool { if (!gallery_topic_id_valid($id)) { return false; } $changed = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $dir, &$changed): ?array { $topics = gallery_topics($g); $at = array_search($id, array_column($topics, 'id'), true); if ($at === false) { return null; } $to = $dir === 'up' ? $at - 1 : $at + 1; if ($to < 0 || $to >= count($topics)) { return null; } [$topics[$at], $topics[$to]] = [$topics[$to], $topics[$at]]; $g['topics'] = $topics; $changed = true; return $g; }); if ($changed) { archive_mark_dirty($slug, $gallery); } return $changed; } /** * Put one image into a topic, or back into none ($topicId null or ''). * * Matched by S3 key, the image record's de-facto identity. Under the same lock * as uploads, so assigning while an upload is in flight cannot lose either one. * Returns false for an unknown image or an unknown topic; a no-op assignment * (already in that topic) counts as success and skips the write. */ function gallery_assign_topic(string $slug, string $key, ?string $topicId): bool { $topicId = (string)$topicId; if ($topicId !== '' && !gallery_topic_id_valid($topicId)) { return false; } $ok = false; $changed = false; $gallery = json_update(gallery_file($slug), function (array $g) use ($key, $topicId, &$ok, &$changed): ?array { if ($topicId !== '' && !isset(gallery_topic_map($g)[$topicId])) { return null; } foreach ($g['images'] ?? [] as $i => $image) { if (($image['key'] ?? '') !== $key) { continue; } $ok = true; if ((string)($image['topic'] ?? '') === $topicId) { return null; // already there } if ($topicId === '') { unset($g['images'][$i]['topic']); } else { $g['images'][$i]['topic'] = $topicId; } $changed = true; return $g; } return null; }); if ($changed) { // The image now belongs in a different folder of the ZIP. archive_mark_dirty($slug, $gallery); } return $ok; } /** Counter name => the timestamp field recording when it last moved. */ const GALLERY_COUNTERS = [ 'views' => 'last_viewed_at', 'downloads' => 'last_download_at', ]; /** * Count one event on a gallery. Silently does nothing for an unknown slug or * counter, so a stray link cannot litter the data directory with stats for * galleries that never existed. */ function gallery_record_hit(string $slug, string $counter): void { try { if (!isset(GALLERY_COUNTERS[$counter]) || !is_file(gallery_file($slug))) { return; } } catch (InvalidArgumentException) { return; } json_update(gallery_stats_file($slug), function (array $stats) use ($counter) { $stats[$counter] = (int)($stats[$counter] ?? 0) + 1; $stats[GALLERY_COUNTERS[$counter]] = date('Y-m-d H:i:s'); return $stats; }); } /** One gallery opened by a visitor. */ function gallery_record_view(string $slug): void { gallery_record_hit($slug, 'views'); } /** One ZIP archive handed out to a visitor. */ function gallery_record_download(string $slug): void { gallery_record_hit($slug, 'downloads'); } /** * A gallery's stats, with every counter present. Galleries that were never * opened simply read as zero, with null timestamps. */ function gallery_stats(string $slug): array { try { $stored = json_read(gallery_stats_file($slug)); } catch (InvalidArgumentException) { $stored = []; } $out = []; foreach (GALLERY_COUNTERS as $counter => $timestamp) { $out[$counter] = (int)($stored[$counter] ?? 0); $out[$timestamp] = $stored[$timestamp] ?? null; } return $out; } /** All galleries, newest first. */ function galleries_all(): array { $out = []; foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) { // Skip the sidecars (archive build state, visit counter) that live in // the same directory and match the same glob. if (str_ends_with($file, '.archive.json') || str_ends_with($file, '.stats.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)"; }