Procházet zdrojové kódy

Core library: bootstrap, flat-file storage, CSRF, auth, S3 SigV4 presigning

SigV4 query presigning verified against the official AWS example vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Medowar před 1 měsícem
rodič
revize
afebdde0a2
5 změnil soubory, kde provedl 575 přidání a 0 odebrání
  1. 116 0
      app/auth.php
  2. 79 0
      app/bootstrap.php
  3. 35 0
      app/csrf.php
  4. 197 0
      app/s3.php
  5. 148 0
      app/storage.php

+ 116 - 0
app/auth.php

@@ -0,0 +1,116 @@
+<?php
+/**
+ * Admin authentication: file-based credentials, session login,
+ * brute-force throttling, and online password change.
+ */
+
+declare(strict_types=1);
+
+const AUTH_MAX_FAILURES = 5;
+const AUTH_LOCK_SECONDS = 900; // 15 minutes
+
+function credentials_load(): array
+{
+    $file = CONFIG_DIR . '/credentials.php';
+    if (!is_file($file)) {
+        http_response_code(500);
+        exit('Missing config/credentials.php — copy config/credentials.sample.php.');
+    }
+    return require $file;
+}
+
+function auth_check(): bool
+{
+    session_boot();
+    return !empty($_SESSION['admin']);
+}
+
+/** Gatekeeper at the top of every admin page. */
+function auth_require(): void
+{
+    if (!auth_check()) {
+        redirect('login.php');
+    }
+}
+
+function auth_throttle_file(): string
+{
+    return DATA_DIR . '/login-throttle.json';
+}
+
+/** Seconds until login is allowed again, 0 if not locked. */
+function auth_locked_for(): int
+{
+    $t = json_read(auth_throttle_file(), ['failures' => 0, 'last' => 0]);
+    if (($t['failures'] ?? 0) < AUTH_MAX_FAILURES) {
+        return 0;
+    }
+    $remaining = ($t['last'] ?? 0) + AUTH_LOCK_SECONDS - time();
+    return max(0, $remaining);
+}
+
+function auth_attempt(string $username, string $password): bool
+{
+    session_boot();
+    if (auth_locked_for() > 0) {
+        return false;
+    }
+    $cred = credentials_load();
+    $ok = hash_equals($cred['username'], $username)
+        && password_verify($password, $cred['password_hash']);
+
+    if ($ok) {
+        if (is_file(auth_throttle_file())) {
+            @unlink(auth_throttle_file());
+        }
+        session_regenerate_id(true);
+        $_SESSION['admin'] = true;
+        return true;
+    }
+
+    $t = json_read(auth_throttle_file(), ['failures' => 0, 'last' => 0]);
+    // A stale lock window restarts the count.
+    if (time() - ($t['last'] ?? 0) > AUTH_LOCK_SECONDS) {
+        $t['failures'] = 0;
+    }
+    $t['failures'] = ($t['failures'] ?? 0) + 1;
+    $t['last'] = time();
+    json_write(auth_throttle_file(), $t);
+    return false;
+}
+
+function auth_logout(): void
+{
+    session_boot();
+    $_SESSION = [];
+    session_destroy();
+}
+
+/**
+ * Change the admin password: verifies the current one, then atomically
+ * rewrites config/credentials.php. Returns an error message or null on success.
+ */
+function auth_change_password(string $current, string $new): ?string
+{
+    $cred = credentials_load();
+    if (!password_verify($current, $cred['password_hash'])) {
+        return 'Current password is incorrect.';
+    }
+    if (strlen($new) < 8) {
+        return 'New password must be at least 8 characters.';
+    }
+    $cred['password_hash'] = password_hash($new, PASSWORD_DEFAULT);
+
+    $file = CONFIG_DIR . '/credentials.php';
+    $php = "<?php\n// Rewritten by the admin settings page on " . date('c') . "\n"
+         . "return " . var_export($cred, true) . ";\n";
+    $tmp = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
+    if (file_put_contents($tmp, $php, LOCK_EX) === false || !rename($tmp, $file)) {
+        @unlink($tmp);
+        return 'Could not write credentials file — check that config/ is writable.';
+    }
+    if (function_exists('opcache_invalidate')) {
+        @opcache_invalidate($file, true);
+    }
+    return null;
+}

+ 79 - 0
app/bootstrap.php

@@ -0,0 +1,79 @@
+<?php
+/**
+ * Application bootstrap. Every entry script in public/ includes this first.
+ */
+
+declare(strict_types=1);
+
+define('APP_ROOT', dirname(__DIR__));
+define('DATA_DIR', APP_ROOT . '/data');
+define('MEDIA_DIR', APP_ROOT . '/public/media');
+define('CONFIG_DIR', APP_ROOT . '/config');
+
+if (!is_file(CONFIG_DIR . '/config.php')) {
+    http_response_code(500);
+    exit('Missing config/config.php — copy config/config.sample.php and adjust it.');
+}
+
+$GLOBALS['config'] = require CONFIG_DIR . '/config.php';
+
+date_default_timezone_set(config('site.timezone', 'UTC'));
+
+require APP_ROOT . '/app/storage.php';
+require APP_ROOT . '/app/csrf.php';
+require APP_ROOT . '/app/auth.php';
+require APP_ROOT . '/app/s3.php';
+
+/**
+ * Read a config value by dot path, e.g. config('s3.bucket').
+ */
+function config(string $path, mixed $default = null): mixed
+{
+    $value = $GLOBALS['config'];
+    foreach (explode('.', $path) as $part) {
+        if (!is_array($value) || !array_key_exists($part, $value)) {
+            return $default;
+        }
+        $value = $value[$part];
+    }
+    return $value;
+}
+
+/** HTML-escape for output. */
+function e(?string $s): string
+{
+    return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
+}
+
+/** Start the session with hardened cookie settings (idempotent). */
+function session_boot(): void
+{
+    if (session_status() === PHP_SESSION_ACTIVE) {
+        return;
+    }
+    session_set_cookie_params([
+        'lifetime' => 0,
+        'path'     => '/',
+        'secure'   => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
+        'httponly' => true,
+        'samesite' => 'Lax',
+    ]);
+    session_name('fpsid');
+    session_start();
+}
+
+/** Redirect and stop. */
+function redirect(string $url): never
+{
+    header('Location: ' . $url);
+    exit;
+}
+
+/** Send a JSON response and stop (used by admin/api.php). */
+function json_response(array $payload, int $status = 200): never
+{
+    http_response_code($status);
+    header('Content-Type: application/json; charset=utf-8');
+    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    exit;
+}

+ 35 - 0
app/csrf.php

@@ -0,0 +1,35 @@
+<?php
+/**
+ * CSRF protection for all admin POST requests.
+ */
+
+declare(strict_types=1);
+
+function csrf_token(): string
+{
+    session_boot();
+    if (empty($_SESSION['csrf'])) {
+        $_SESSION['csrf'] = bin2hex(random_bytes(32));
+    }
+    return $_SESSION['csrf'];
+}
+
+/** Hidden input for HTML forms. */
+function csrf_field(): string
+{
+    return '<input type="hidden" name="_csrf" value="' . e(csrf_token()) . '">';
+}
+
+/**
+ * Verify the token from a form field or the X-CSRF-Token header (API calls).
+ * Ends the request on failure.
+ */
+function csrf_verify(): void
+{
+    session_boot();
+    $sent = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+    if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], (string)$sent)) {
+        http_response_code(419);
+        exit('Invalid or missing CSRF token. Go back, reload the page and try again.');
+    }
+}

+ 197 - 0
app/s3.php

@@ -0,0 +1,197 @@
+<?php
+/**
+ * Minimal S3 client for Hetzner Object Storage (or any S3-compatible store).
+ * Implements AWS Signature v4 in plain PHP — no SDK, no Composer.
+ *
+ * - Presigned GET  → visitors load gallery images directly from S3
+ * - Presigned PUT  → the admin browser uploads directly to S3
+ * - Signed DELETE  → server-side cleanup when images/galleries are removed
+ *
+ * Uses path-style URLs: https://<endpoint>/<bucket>/<key>
+ */
+
+declare(strict_types=1);
+
+/** Percent-encode an object key, keeping the "/" separators. */
+function s3_encode_key(string $key): string
+{
+    return implode('/', array_map('rawurlencode', explode('/', $key)));
+}
+
+function s3_host(): string
+{
+    return parse_url(config('s3.endpoint'), PHP_URL_HOST);
+}
+
+/** HMAC-SHA256 chain producing the SigV4 signing key. */
+function s3_signing_key(string $date): string
+{
+    $k = hash_hmac('sha256', $date, 'AWS4' . config('s3.secret_key'), true);
+    $k = hash_hmac('sha256', config('s3.region'), $k, true);
+    $k = hash_hmac('sha256', 's3', $k, true);
+    return hash_hmac('sha256', 'aws4_request', $k, true);
+}
+
+/**
+ * SigV4 query-string signing core. Separated from s3_presign() so the
+ * algorithm can be verified against the official AWS example vectors.
+ * Returns the full query string including X-Amz-Signature.
+ */
+function s3_presign_query(
+    string $method,
+    string $host,
+    string $canonicalUri,
+    string $accessKey,
+    string $secretKey,
+    string $region,
+    int $ttl,
+    string $amzDate
+): string {
+    $date = substr($amzDate, 0, 8);
+    $scope = $date . '/' . $region . '/s3/aws4_request';
+
+    $query = [
+        'X-Amz-Algorithm'     => 'AWS4-HMAC-SHA256',
+        'X-Amz-Credential'    => $accessKey . '/' . $scope,
+        'X-Amz-Date'          => $amzDate,
+        'X-Amz-Expires'       => (string)$ttl,
+        'X-Amz-SignedHeaders' => 'host',
+    ];
+    ksort($query);
+    $canonicalQuery = implode('&', array_map(
+        fn($k, $v) => rawurlencode($k) . '=' . rawurlencode($v),
+        array_keys($query),
+        $query
+    ));
+
+    $canonicalRequest = implode("\n", [
+        strtoupper($method),
+        $canonicalUri,
+        $canonicalQuery,
+        'host:' . $host,
+        '',
+        'host',
+        'UNSIGNED-PAYLOAD',
+    ]);
+
+    $stringToSign = implode("\n", [
+        'AWS4-HMAC-SHA256',
+        $amzDate,
+        $scope,
+        hash('sha256', $canonicalRequest),
+    ]);
+
+    $k = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true);
+    $k = hash_hmac('sha256', $region, $k, true);
+    $k = hash_hmac('sha256', 's3', $k, true);
+    $k = hash_hmac('sha256', 'aws4_request', $k, true);
+    $signature = hash_hmac('sha256', $stringToSign, $k);
+
+    return $canonicalQuery . '&X-Amz-Signature=' . $signature;
+}
+
+/**
+ * Build a presigned URL for GET or PUT on an object key.
+ * Only the Host header is signed, so the browser is free to set its own
+ * Content-Type on PUT.
+ */
+function s3_presign(string $method, string $key, ?int $ttl = null): string
+{
+    $ttl ??= (int)config('s3.url_ttl', 3600);
+    $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
+    $query = s3_presign_query(
+        $method,
+        s3_host(),
+        $canonicalUri,
+        config('s3.access_key'),
+        config('s3.secret_key'),
+        config('s3.region'),
+        $ttl,
+        gmdate('Ymd\THis\Z')
+    );
+    return config('s3.endpoint') . $canonicalUri . '?' . $query;
+}
+
+function s3_presign_get(string $key, ?int $ttl = null): string
+{
+    return s3_presign('GET', $key, $ttl);
+}
+
+function s3_presign_put(string $key, int $ttl = 900): string
+{
+    return s3_presign('PUT', $key, $ttl);
+}
+
+/**
+ * Server-side signed request (header auth). Used for DELETE.
+ * Returns [httpStatus, responseBody].
+ */
+function s3_request(string $method, string $key): array
+{
+    $host = s3_host();
+    $amzDate = gmdate('Ymd\THis\Z');
+    $date = substr($amzDate, 0, 8);
+    $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
+    $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
+    $payloadHash = hash('sha256', '');
+
+    $canonicalRequest = implode("\n", [
+        strtoupper($method),
+        $canonicalUri,
+        '', // no query string
+        'host:' . $host,
+        'x-amz-content-sha256:' . $payloadHash,
+        'x-amz-date:' . $amzDate,
+        '',
+        'host;x-amz-content-sha256;x-amz-date',
+        $payloadHash,
+    ]);
+
+    $stringToSign = implode("\n", [
+        'AWS4-HMAC-SHA256',
+        $amzDate,
+        $scope,
+        hash('sha256', $canonicalRequest),
+    ]);
+
+    $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
+    $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
+        . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
+        . ', Signature=' . $signature;
+
+    $ch = curl_init(config('s3.endpoint') . $canonicalUri);
+    curl_setopt_array($ch, [
+        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
+        CURLOPT_RETURNTRANSFER => true,
+        CURLOPT_TIMEOUT        => 30,
+        CURLOPT_HTTPHEADER     => [
+            'Authorization: ' . $authorization,
+            'x-amz-content-sha256: ' . $payloadHash,
+            'x-amz-date: ' . $amzDate,
+        ],
+    ]);
+    $body = curl_exec($ch);
+    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+    curl_close($ch);
+    return [$status, (string)$body];
+}
+
+/** Delete one object. S3 returns 204 for success and for already-gone keys. */
+function s3_delete(string $key): bool
+{
+    [$status] = s3_request('DELETE', $key);
+    return $status === 204 || $status === 200 || $status === 404;
+}
+
+/** Delete every S3 object referenced by a gallery (originals + thumbs). */
+function s3_delete_gallery_objects(array $gallery): void
+{
+    foreach ($gallery['images'] ?? [] as $img) {
+        if (!empty($img['key'])) {
+            s3_delete($img['key']);
+        }
+        if (!empty($img['thumb'])) {
+            s3_delete($img['thumb']);
+        }
+    }
+}

+ 148 - 0
app/storage.php

@@ -0,0 +1,148 @@
+<?php
+/**
+ * Flat-file JSON storage with locking, plus the site/gallery data accessors.
+ */
+
+declare(strict_types=1);
+
+/** Read a JSON file; returns $default if missing or unreadable. */
+function json_read(string $file, array $default = []): array
+{
+    if (!is_file($file)) {
+        return $default;
+    }
+    $fh = fopen($file, 'r');
+    if ($fh === false) {
+        return $default;
+    }
+    flock($fh, LOCK_SH);
+    $raw = stream_get_contents($fh);
+    flock($fh, LOCK_UN);
+    fclose($fh);
+    $data = json_decode((string)$raw, true);
+    return is_array($data) ? $data : $default;
+}
+
+/** Write a JSON file atomically (tmp file + rename) under an exclusive lock. */
+function json_write(string $file, array $data): void
+{
+    $dir = dirname($file);
+    if (!is_dir($dir)) {
+        mkdir($dir, 0755, true);
+    }
+    $tmp = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
+    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    if (file_put_contents($tmp, $json, LOCK_EX) === false) {
+        throw new RuntimeException("Cannot write $tmp");
+    }
+    if (!rename($tmp, $file)) {
+        @unlink($tmp);
+        throw new RuntimeException("Cannot replace $file");
+    }
+}
+
+/** 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;
+}
+
+/** Turn a title into a URL slug fragment ("Wedding Müller" → "wedding-mueller"). */
+function slugify(string $title): string
+{
+    $map = ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue', 'ß' => 'ss'];
+    $s = strtr($title, $map);
+    if (function_exists('iconv')) {
+        $s = (string)@iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
+    }
+    $s = strtolower($s);
+    $s = preg_replace('/[^a-z0-9]+/', '-', $s) ?? '';
+    $s = trim($s, '-');
+    return $s !== '' ? $s : 'gallery';
+}
+
+// ---------------------------------------------------------------------------
+// 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'    => [],
+    ]);
+}
+
+function site_save(array $site): void
+{
+    json_write(DATA_DIR . '/site.json', $site);
+}
+
+// ---------------------------------------------------------------------------
+// Galleries — one JSON file per gallery in data/galleries/
+// ---------------------------------------------------------------------------
+
+function gallery_file(string $slug): 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 . '.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
+{
+    $file = gallery_file($slug);
+    if (is_file($file)) {
+        unlink($file);
+    }
+}
+
+/** All galleries, newest first. */
+function galleries_all(): array
+{
+    $out = [];
+    foreach (glob(DATA_DIR . '/galleries/*.json') ?: [] as $file) {
+        $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;
+}