Zwei vorbestehende, bislang rote Pruefungen behoben.
1) PHP lief in UTC, MySQL in der System-Zeitzone (hier CEST, +2h). Ueberall,
wo ein in PHP berechneter Zeitstempel gegen MySQL NOW() verglichen wird,
liefen die Uhren dadurch gegeneinander:
- Hinweise mit kurzer Restlaufzeit galten sofort als abgelaufen
(notices: valid_from = NOW() aus MySQL, valid_until aus PHP date()).
- Auth-Token (Passwort-Reset, E-Mail-Verifikation) liefen bis zu 2h zu
frueh ab (expires_at aus PHP date(), Pruefung gegen NOW()).
bootstrap.php pinnt die PHP-Zeitzone jetzt deterministisch aus
APP_TIMEZONE (Standard Europe/Berlin), app_db_pdo() setzt die DB-Session
per numerischem Offset auf dieselbe Zeit. Damit stimmen beide Uhren
ueberein. Behebt check-m8-tenant-isolation (11/1 -> 12/0).
2) check-m3-settings-flow stammte aus M3 und lieferte nicht die spaeter
hinzugekommenen Pflichtfelder (pdf_row_height_px,
payment_reminder_interval_days). Dadurch schlug bereits das Update fehl und
alle Folge-Assertions kippten. Der Test sendet jetzt den vollstaendigen
Feldsatz wie das Einstellungsformular und prueft die beiden Felder mit.
Die Update-Funktion selbst war korrekt (7/6 -> 15/0).
Voller Regressionslauf gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 12/0, m3-auth 9/0, password-email 14/0, settings 15/0,
tenant-resolution 12/0, billing 10/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
5.0 KiB
PHP
179 lines
5.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
if (!defined('APP_ROOT')) {
|
|
define('APP_ROOT', dirname(__DIR__));
|
|
}
|
|
|
|
// Auf echtem Webhosting (PHP-FPM/mod_php) gibt es meist keine bequeme
|
|
// Moeglichkeit, Prozess-Umgebungsvariablen zu setzen wie beim lokalen
|
|
// `source .env.local` vor dem PHP-Dev-Server. env.local.php ist die
|
|
// Produktions-Entsprechung: eine nicht eingecheckte Datei mit putenv()-
|
|
// Aufrufen, siehe env.local.example.php. Existiert sie nicht (lokale
|
|
// Entwicklung), passiert hier nichts und getenv() liest wie bisher aus
|
|
// der Shell-Umgebung.
|
|
if (is_file(APP_ROOT . '/env.local.php')) {
|
|
require_once APP_ROOT . '/env.local.php';
|
|
}
|
|
|
|
function app_env(string $name, ?string $default = null): ?string
|
|
{
|
|
$value = getenv($name);
|
|
if ($value === false || $value === '') {
|
|
return $default;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
// PHP und MySQL muessen dieselbe Uhr sehen. Sonst laufen alle Stellen, die
|
|
// einen in PHP berechneten Zeitstempel gegen MySQL NOW() vergleichen,
|
|
// gegeneinander - z. B. Token-Ablaeufe (Passwort-Reset, E-Mail-Verifikation)
|
|
// und die Gueltigkeit von Hinweisen. Wir pinnen die PHP-Zeitzone
|
|
// deterministisch; app_db_pdo() setzt die DB-Session auf denselben Offset.
|
|
$appTimezone = app_env('APP_TIMEZONE', 'Europe/Berlin');
|
|
if (@date_default_timezone_set((string)$appTimezone) === false) {
|
|
date_default_timezone_set('UTC');
|
|
}
|
|
|
|
function app_is_dev(): bool
|
|
{
|
|
return app_env('APP_ENV', 'prod') === 'dev';
|
|
}
|
|
|
|
function app_is_https(): bool
|
|
{
|
|
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
|
|
}
|
|
|
|
/**
|
|
* Sends baseline security headers on every dynamic request. Deliberately
|
|
* does not set a Content-Security-Policy: the existing templates rely on
|
|
* inline style="" attributes throughout (banners, table cells, etc.), and
|
|
* locking that down would need a broader template pass. Runs once per
|
|
* request via the auto-invocation at the bottom of this file.
|
|
*/
|
|
function app_send_security_headers(): void
|
|
{
|
|
if (PHP_SAPI === 'cli' || headers_sent()) {
|
|
return;
|
|
}
|
|
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('X-Frame-Options: DENY');
|
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
|
|
|
if (app_is_https()) {
|
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The application's own primary hostname in production (e.g.
|
|
* app.kaffeeliste.de), configured via APP_HOST. Unset in dev, where
|
|
* index.php should keep behaving exactly as before (no host split).
|
|
*/
|
|
function app_primary_host(): ?string
|
|
{
|
|
return app_env('APP_HOST');
|
|
}
|
|
|
|
/**
|
|
* Builds a URL that always points at the app's own host, even when the
|
|
* current request came in on a different domain (e.g. the marketing
|
|
* domain linking to /login.php). Falls back to a relative path when
|
|
* APP_HOST isn't configured, so local dev is unaffected.
|
|
*/
|
|
function app_primary_url(string $path): string
|
|
{
|
|
$host = app_primary_host();
|
|
if ($host === null) {
|
|
return ltrim($path, '/');
|
|
}
|
|
|
|
$scheme = app_is_https() ? 'https' : 'http';
|
|
|
|
return $scheme . '://' . $host . '/' . ltrim($path, '/');
|
|
}
|
|
|
|
function app_start_session(): void
|
|
{
|
|
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
|
|
$sessionPath = app_env('APP_SESSION_PATH', APP_ROOT . '/var/sessions');
|
|
if ($sessionPath !== null && !is_dir($sessionPath)) {
|
|
@mkdir($sessionPath, 0700, true);
|
|
}
|
|
|
|
if ($sessionPath !== null && is_dir($sessionPath) && is_writable($sessionPath)) {
|
|
session_save_path($sessionPath);
|
|
}
|
|
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'domain' => '',
|
|
'secure' => app_is_https(),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
|
|
session_name(app_env('APP_SESSION_NAME', 'kaffeeliste_session') ?? 'kaffeeliste_session');
|
|
session_start();
|
|
}
|
|
|
|
function app_csrf_token(): string
|
|
{
|
|
app_start_session();
|
|
|
|
if (!isset($_SESSION) || !is_array($_SESSION)) {
|
|
$_SESSION = [];
|
|
}
|
|
|
|
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
function app_csrf_field(): string
|
|
{
|
|
return '<input type="hidden" name="csrf_token" value="'
|
|
. htmlspecialchars(app_csrf_token(), ENT_QUOTES, 'UTF-8')
|
|
. '">';
|
|
}
|
|
|
|
function app_verify_csrf(?string $token): bool
|
|
{
|
|
app_start_session();
|
|
|
|
if (!isset($_SESSION) || !is_array($_SESSION)) {
|
|
$_SESSION = [];
|
|
}
|
|
|
|
return is_string($token)
|
|
&& isset($_SESSION['csrf_token'])
|
|
&& is_string($_SESSION['csrf_token'])
|
|
&& hash_equals($_SESSION['csrf_token'], $token);
|
|
}
|
|
|
|
function app_require_csrf(): void
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
return;
|
|
}
|
|
|
|
if (!app_verify_csrf($_POST['csrf_token'] ?? null)) {
|
|
http_response_code(419);
|
|
die('CSRF validation failed.');
|
|
}
|
|
}
|
|
|
|
app_send_security_headers();
|