diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/data/.htaccess b/data/.htaccess
new file mode 100644
index 0000000..74945f4
--- /dev/null
+++ b/data/.htaccess
@@ -0,0 +1,9 @@
+# Notbremse: Selbst wenn dieses Verzeichnis versehentlich im Webroot
+# landet, darf niemand die Datenbank oder hochgeladene Logos abrufen.
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/migrations/001_initial.php b/migrations/001_initial.php
new file mode 100644
index 0000000..535652b
--- /dev/null
+++ b/migrations/001_initial.php
@@ -0,0 +1,127 @@
+ [
+ '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',
+ ],
+];
diff --git a/migrations/002_domains.php b/migrations/002_domains.php
new file mode 100644
index 0000000..8cb975c
--- /dev/null
+++ b/migrations/002_domains.php
@@ -0,0 +1,78 @@
+ [
+ '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)',
+ ],
+];
diff --git a/src/Config.php b/src/Config.php
new file mode 100644
index 0000000..5c74000
--- /dev/null
+++ b/src/Config.php
@@ -0,0 +1,191 @@
+ */
+ 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
+ */
+ 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
+ */
+ 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');
+ }
+}
diff --git a/src/Database.php b/src/Database.php
new file mode 100644
index 0000000..52a00ff
--- /dev/null
+++ b/src/Database.php
@@ -0,0 +1,149 @@
+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
+ */
+ 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 $params
+ */
+ public function execute(string $sql, array $params = []): \PDOStatement
+ {
+ $statement = $this->pdo->prepare($sql);
+ $statement->execute($params);
+
+ return $statement;
+ }
+
+ /**
+ * @param array $params
+ * @return array|null
+ */
+ public function fetchOne(string $sql, array $params = []): ?array
+ {
+ $row = $this->execute($sql, $params)->fetch();
+
+ return $row === false ? null : $row;
+ }
+
+ /**
+ * @param array $params
+ * @return list>
+ */
+ public function fetchAll(string $sql, array $params = []): array
+ {
+ /** @var list> $rows */
+ $rows = $this->execute($sql, $params)->fetchAll();
+
+ return $rows;
+ }
+
+ /**
+ * @param array $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();
+ }
+}
diff --git a/src/Migrator.php b/src/Migrator.php
new file mode 100644
index 0000000..02ce64d
--- /dev/null
+++ b/src/Migrator.php
@@ -0,0 +1,99 @@
+ [...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 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> $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
+ */
+ 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 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;
+ }
+}