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; + } +}