feat(core): Konfiguration, PDO-Datenbankschicht und Migrationen

SQLite als Standard (keine Einrichtung noetig, WAL aktiviert), optional
MySQL/MariaDB. Beide Treiber nutzen dieselben vorbereiteten Statements.

Die Migrationen laufen automatisch beim ersten Request; jede Migration
liefert das SQL je Treiber. Damit entfaellt ein manueller Schritt beim
Deployment ueber FTP.

data/ enthaelt eine eigene .htaccess als zweite Absicherung, falls das
Verzeichnis beim Hoster doch einmal im Webroot landet.
This commit is contained in:
2026-08-03 18:11:38 +02:00
parent b41dd1d3d7
commit 2c9fbdec87
7 changed files with 653 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App;
/**
* Konfiguration aus .env-Datei und Umgebungsvariablen.
*
* Reihenfolge: Werte aus der .env-Datei gewinnen, danach echte
* Umgebungsvariablen (getenv / $_ENV / $_SERVER), zuletzt der Default.
*/
final class Config
{
/** @var array<string,string> */
private array $values = [];
private function __construct(private readonly string $rootDir)
{
}
public static function load(string $rootDir, ?string $envFile = null): self
{
$config = new self(rtrim(str_replace('\\', '/', $rootDir), '/'));
$envFile ??= $config->rootDir . '/.env';
if (is_readable($envFile)) {
$config->values = self::parseEnvFile($envFile);
}
return $config;
}
/**
* @return array<string,string>
*/
private static function parseEnvFile(string $path): array
{
$result = [];
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Umschliessende Anfuehrungszeichen entfernen
if (strlen($value) >= 2) {
$first = $value[0];
if (($first === '"' || $first === "'") && str_ends_with($value, $first)) {
$value = substr($value, 1, -1);
}
}
if ($key !== '') {
$result[$key] = $value;
}
}
return $result;
}
public function get(string $key, ?string $default = null): ?string
{
if (array_key_exists($key, $this->values) && $this->values[$key] !== '') {
return $this->values[$key];
}
foreach ([$_ENV, $_SERVER] as $source) {
if (isset($source[$key]) && is_scalar($source[$key]) && (string) $source[$key] !== '') {
return (string) $source[$key];
}
}
$fromEnv = getenv($key);
if ($fromEnv !== false && $fromEnv !== '') {
return $fromEnv;
}
return $default;
}
public function getString(string $key, string $default = ''): string
{
return $this->get($key, $default) ?? $default;
}
public function getInt(string $key, int $default = 0): int
{
$value = $this->get($key);
return $value === null || !is_numeric($value) ? $default : (int) $value;
}
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key);
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Loest einen ggf. relativen Pfad gegen das Projekt-Stammverzeichnis auf.
*/
public function path(string $relative): string
{
$relative = str_replace('\\', '/', $relative);
$isAbsolute = str_starts_with($relative, '/')
|| (bool) preg_match('#^[A-Za-z]:/#', $relative);
return $isAbsolute ? $relative : $this->rootDir . '/' . ltrim($relative, '/');
}
/**
* Rueckfall-Basis-URL, wenn APP_BASE_URL nicht gesetzt ist.
* Wird von der Anwendung aus dem (geprueften) Host der Anfrage
* gesetzt - der Host-Header selbst wird hier bewusst nicht gelesen.
*/
private ?string $fallbackBaseUrl = null;
public function setFallbackBaseUrl(string $url): void
{
$url = trim($url);
if ($url !== '') {
$this->fallbackBaseUrl = rtrim($url, '/');
}
}
/**
* Basis-URL ohne abschliessenden Slash.
*
* Ist APP_BASE_URL nicht gesetzt, wird der von der Anwendung
* gepruefte Host verwendet. Ein erfundener Host-Header kann damit
* nicht in erzeugte Links oder QR-Codes gelangen.
*/
public function baseUrl(): string
{
$configured = $this->get('APP_BASE_URL');
if ($configured !== null) {
return rtrim($configured, '/');
}
return $this->fallbackBaseUrl ?? 'http://localhost';
}
/**
* IP-Adressen und CIDR-Bereiche, deren X-Forwarded-*-Header
* ausgewertet werden duerfen. Standard: keine.
*
* @return list<string>
*/
public function trustedProxies(): array
{
return \App\Support\IpRange::parseList($this->getString('TRUSTED_PROXIES', ''));
}
public function isDevelopment(): bool
{
return strtolower($this->getString('APP_ENV', 'production')) === 'development';
}
/**
* Ist ein Admin-Passwort hinterlegt (per .env)?
*/
public function adminPasswordHash(): ?string
{
$hash = $this->get('ADMIN_PASSWORD_HASH');
return $hash !== null && $hash !== '' ? $hash : null;
}
public function adminUser(): string
{
return $this->getString('ADMIN_USER', 'admin');
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
use RuntimeException;
/**
* Duenner PDO-Wrapper. Unterstuetzt SQLite (Standard) und MySQL/MariaDB.
* Alle Abfragen laufen ueber vorbereitete Statements.
*/
final class Database
{
private function __construct(
private readonly PDO $pdo,
private readonly string $driver,
) {
}
public static function fromConfig(Config $config): self
{
$driver = strtolower($config->getString('DB_DRIVER', 'sqlite'));
$sqlitePath = $config->getString('DB_SQLITE_PATH', 'data/database.sqlite');
return match ($driver) {
'sqlite' => self::sqlite($sqlitePath === ':memory:' ? ':memory:' : $config->path($sqlitePath)),
'mysql', 'mariadb' => self::mysql($config),
default => throw new RuntimeException(sprintf('Unbekannter DB_DRIVER "%s" (erlaubt: sqlite, mysql).', $driver)),
};
}
public static function sqlite(string $path): self
{
if ($path !== ':memory:') {
$dir = dirname($path);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException(sprintf('Datenbankverzeichnis "%s" konnte nicht angelegt werden.', $dir));
}
if (!is_writable($dir)) {
throw new RuntimeException(sprintf('Datenbankverzeichnis "%s" ist nicht beschreibbar.', $dir));
}
}
$pdo = new PDO('sqlite:' . $path, null, null, self::defaultOptions());
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA busy_timeout = 5000');
return new self($pdo, 'sqlite');
}
private static function mysql(Config $config): self
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config->getString('DB_HOST', '127.0.0.1'),
$config->getInt('DB_PORT', 3306),
$config->getString('DB_NAME', 'urlshort'),
$config->getString('DB_CHARSET', 'utf8mb4'),
);
try {
$pdo = new PDO(
$dsn,
$config->getString('DB_USER', 'root'),
$config->getString('DB_PASSWORD', ''),
self::defaultOptions(),
);
} catch (PDOException $e) {
throw new RuntimeException('Verbindung zur MySQL-Datenbank fehlgeschlagen: ' . $e->getMessage(), 0, $e);
}
return new self($pdo, 'mysql');
}
/**
* @return array<int,mixed>
*/
private static function defaultOptions(): array
{
return [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
}
public function pdo(): PDO
{
return $this->pdo;
}
public function driver(): string
{
return $this->driver;
}
/**
* @param array<string|int,mixed> $params
*/
public function execute(string $sql, array $params = []): \PDOStatement
{
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $statement;
}
/**
* @param array<string|int,mixed> $params
* @return array<string,mixed>|null
*/
public function fetchOne(string $sql, array $params = []): ?array
{
$row = $this->execute($sql, $params)->fetch();
return $row === false ? null : $row;
}
/**
* @param array<string|int,mixed> $params
* @return list<array<string,mixed>>
*/
public function fetchAll(string $sql, array $params = []): array
{
/** @var list<array<string,mixed>> $rows */
$rows = $this->execute($sql, $params)->fetchAll();
return $rows;
}
/**
* @param array<string|int,mixed> $params
*/
public function fetchValue(string $sql, array $params = []): mixed
{
$value = $this->execute($sql, $params)->fetchColumn();
return $value === false ? null : $value;
}
public function lastInsertId(): int
{
return (int) $this->pdo->lastInsertId();
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App;
/**
* Sehr einfache Migrationen: jede Migration ist eine PHP-Datei in
* /migrations, die ein Array ['sqlite' => [...SQL], 'mysql' => [...SQL]]
* zurueckgibt. Bereits ausgefuehrte Migrationen werden in der Tabelle
* "migrations" vermerkt. Laeuft automatisch beim ersten Request.
*/
final class Migrator
{
public function __construct(
private readonly Database $db,
private readonly string $migrationsDir,
) {
}
/**
* @return list<string> Namen der ausgefuehrten Migrationen
*/
public function migrate(): array
{
$this->ensureMigrationsTable();
$applied = [];
$already = $this->appliedMigrations();
foreach ($this->availableMigrations() as $name => $file) {
if (in_array($name, $already, true)) {
continue;
}
/** @var array<string,list<string>> $definition */
$definition = require $file;
$statements = $definition[$this->db->driver()] ?? $definition['sqlite'] ?? [];
foreach ($statements as $sql) {
$sql = trim($sql);
if ($sql !== '') {
$this->db->pdo()->exec($sql);
}
}
$this->db->execute(
'INSERT INTO migrations (name, applied_at) VALUES (?, ?)',
[$name, gmdate('Y-m-d H:i:s')],
);
$applied[] = $name;
}
return $applied;
}
private function ensureMigrationsTable(): void
{
$sql = $this->db->driver() === 'mysql'
? 'CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
: 'CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)';
$this->db->pdo()->exec($sql);
}
/**
* @return list<string>
*/
private function appliedMigrations(): array
{
$rows = $this->db->fetchAll('SELECT name FROM migrations');
return array_map(static fn (array $row): string => (string) $row['name'], $rows);
}
/**
* @return array<string,string> name => absoluter Pfad
*/
private function availableMigrations(): array
{
$files = glob($this->migrationsDir . '/*.php') ?: [];
sort($files, SORT_STRING);
$result = [];
foreach ($files as $file) {
$result[basename($file, '.php')] = $file;
}
return $result;
}
}