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:
@@ -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>
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user