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
View File
+9
View File
@@ -0,0 +1,9 @@
# Notbremse: Selbst wenn dieses Verzeichnis versehentlich im Webroot
# landet, darf niemand die Datenbank oder hochgeladene Logos abrufen.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* Initiales Schema.
* Zeitstempel werden bewusst als UTC-Strings (Y-m-d H:i:s) gespeichert,
* damit SQLite und MySQL identisch behandelt werden koennen.
*/
return [
'sqlite' => [
'CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
target_url TEXT NOT NULL,
title TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
click_count INTEGER NOT NULL DEFAULT 0,
last_clicked_at TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)',
'CREATE INDEX IF NOT EXISTS idx_links_created_at ON links (created_at)',
'CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id INTEGER NOT NULL,
visited_at TEXT NOT NULL,
referrer_host TEXT NULL,
referrer_url TEXT NULL,
user_agent TEXT NULL,
device_type TEXT NOT NULL DEFAULT "unknown",
browser TEXT NOT NULL DEFAULT "unknown",
platform TEXT NOT NULL DEFAULT "unknown",
visitor_hash TEXT NULL,
FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
)',
'CREATE INDEX IF NOT EXISTS idx_visits_link_time ON visits (link_id, visited_at)',
'CREATE TABLE IF NOT EXISTS qr_designs (
link_id INTEGER PRIMARY KEY,
foreground_color TEXT NOT NULL DEFAULT "#000000",
background_color TEXT NOT NULL DEFAULT "#ffffff",
size INTEGER NOT NULL DEFAULT 512,
margin INTEGER NOT NULL DEFAULT 4,
error_correction TEXT NOT NULL DEFAULT "M",
logo_path TEXT NULL,
logo_scale INTEGER NOT NULL DEFAULT 22,
logo_punchout INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
)',
'CREATE TABLE IF NOT EXISTS settings (
key_name TEXT PRIMARY KEY,
value TEXT NULL,
updated_at TEXT NOT NULL
)',
'CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identifier TEXT NOT NULL,
attempted_at TEXT NOT NULL,
successful INTEGER NOT NULL DEFAULT 0
)',
'CREATE INDEX IF NOT EXISTS idx_login_attempts ON login_attempts (identifier, attempted_at)',
],
'mysql' => [
'CREATE TABLE IF NOT EXISTS links (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(190) NOT NULL,
target_url TEXT NOT NULL,
title VARCHAR(255) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
click_count INT NOT NULL DEFAULT 0,
last_clicked_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uniq_links_slug (slug),
KEY idx_links_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
'CREATE TABLE IF NOT EXISTS visits (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
link_id INT NOT NULL,
visited_at DATETIME NOT NULL,
referrer_host VARCHAR(255) NULL,
referrer_url TEXT NULL,
user_agent TEXT NULL,
device_type VARCHAR(20) NOT NULL DEFAULT "unknown",
browser VARCHAR(40) NOT NULL DEFAULT "unknown",
platform VARCHAR(40) NOT NULL DEFAULT "unknown",
visitor_hash VARCHAR(64) NULL,
KEY idx_visits_link_time (link_id, visited_at),
CONSTRAINT fk_visits_link FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
'CREATE TABLE IF NOT EXISTS qr_designs (
link_id INT PRIMARY KEY,
foreground_color VARCHAR(7) NOT NULL DEFAULT "#000000",
background_color VARCHAR(7) NOT NULL DEFAULT "#ffffff",
size INT NOT NULL DEFAULT 512,
margin INT NOT NULL DEFAULT 4,
error_correction VARCHAR(1) NOT NULL DEFAULT "M",
logo_path VARCHAR(255) NULL,
logo_scale INT NOT NULL DEFAULT 22,
logo_punchout TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
CONSTRAINT fk_qr_link FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
'CREATE TABLE IF NOT EXISTS settings (
key_name VARCHAR(190) PRIMARY KEY,
value TEXT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
'CREATE TABLE IF NOT EXISTS login_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(64) NOT NULL,
attempted_at DATETIME NOT NULL,
successful TINYINT(1) NOT NULL DEFAULT 0,
KEY idx_login_attempts (identifier, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
],
];
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Mehrere Kurzlink-Domains auf einer Installation.
*
* Ein Kurzlink gehoert ab hier zu genau einer Domain; Slugs muessen nur
* noch INNERHALB einer Domain eindeutig sein. "qr.example.de/messe" und
* "short.example.de/messe" sind damit zwei verschiedene Links.
*
* Bestandslinks bekommen die Kennung 0 ("noch nicht zugeordnet"). Beim
* ersten Zugriff legt die Anwendung aus APP_BASE_URL die Standarddomain
* an und traegt sie bei diesen Links nach (siehe DomainRepository).
* Ein Sentinel-Wert statt NULL, weil SQLite NULL-Werte in einem
* UNIQUE-Index als jeweils verschieden behandelt - die Eindeutigkeit
* waere sonst nicht durchgesetzt.
*/
return [
'sqlite' => [
'CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL UNIQUE,
base_url TEXT NOT NULL,
label TEXT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)',
// SQLite kann eine UNIQUE-Spalte nicht nachtraeglich aendern,
// deshalb wird die Tabelle neu aufgebaut.
'PRAGMA foreign_keys = OFF',
'CREATE TABLE links_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL DEFAULT 0,
slug TEXT NOT NULL,
target_url TEXT NOT NULL,
title TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
click_count INTEGER NOT NULL DEFAULT 0,
last_clicked_at TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (domain_id, slug)
)',
'INSERT INTO links_new
(id, domain_id, slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at)
SELECT id, 0, slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at
FROM links',
'DROP TABLE links',
'ALTER TABLE links_new RENAME TO links',
'CREATE INDEX IF NOT EXISTS idx_links_created_at ON links (created_at)',
'CREATE INDEX IF NOT EXISTS idx_links_domain ON links (domain_id)',
'PRAGMA foreign_keys = ON',
],
'mysql' => [
'CREATE TABLE IF NOT EXISTS domains (
id INT AUTO_INCREMENT PRIMARY KEY,
host VARCHAR(190) NOT NULL,
base_url VARCHAR(255) NOT NULL,
label VARCHAR(255) NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE KEY uniq_domains_host (host)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
'ALTER TABLE links ADD COLUMN domain_id INT NOT NULL DEFAULT 0',
'ALTER TABLE links DROP INDEX uniq_links_slug',
'ALTER TABLE links ADD UNIQUE KEY uniq_links_domain_slug (domain_id, slug)',
'ALTER TABLE links ADD INDEX idx_links_domain (domain_id)',
],
];
+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;
}
}