Files
kaffeekasse-saas/app/Services/SetupService.php
T
2026-06-17 16:45:14 +02:00

268 lines
9.1 KiB
PHP

<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class SetupService
{
public function __construct(private readonly string $rootPath)
{
}
public function isInstalled(): bool
{
return is_file($this->rootPath . '/storage/installed.lock');
}
public function install(array $input): void
{
$required = [
'app_url',
'db_host',
'db_port',
'db_name',
'db_user',
'admin_name',
'admin_email',
'admin_password',
];
foreach ($required as $field) {
if (trim((string) ($input[$field] ?? '')) === '') {
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
}
}
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.');
}
if (mb_strlen($input['admin_password']) < 12) {
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
}
$this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
// Prüfe verfügbare PDO-Treiber und verwende entsprechende Datenbank
$availableDrivers = PDO::getAvailableDrivers();
$useMySQL = in_array('mysql', $availableDrivers) && trim((string) $input['db_host']) !== 'sqlite';
if ($useMySQL) {
$pdo = $this->setupMySQLDatabase($input);
} else {
$pdo = $this->setupSQLiteDatabase();
}
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
$existing = $statement->fetchColumn();
if (!$existing) {
$insert = $pdo->prepare(
'INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, "platform_admin")'
);
$insert->execute([
'full_name' => trim((string) $input['admin_name']),
'email' => mb_strtolower((string) $input['admin_email']),
'password_hash' => secure_password_hash((string) $input['admin_password']),
]);
}
$envContent = $this->buildEnvFile($input, $useMySQL);
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
}
$lockContent = json_encode([
'installed_at' => gmdate(DATE_ATOM),
'app_url' => trim((string) $input['app_url']),
'database_type' => $useMySQL ? 'mysql' : 'sqlite',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
}
private function setupMySQLDatabase(array $input): PDO
{
$config = [
'host' => trim((string) $input['db_host']),
'port' => (int) $input['db_port'],
'name' => trim((string) $input['db_name']),
'user' => trim((string) $input['db_user']),
'pass' => (string) ($input['db_pass'] ?? ''),
'charset' => 'utf8mb4',
];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['name'],
$config['charset']
);
try {
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
if (!is_string($schema) || $schema === '') {
throw new RuntimeException('Das MySQL-Datenbankschema konnte nicht geladen werden.');
}
$pdo->exec($schema);
return $pdo;
} catch (\PDOException $e) {
throw new RuntimeException('MySQL-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage() . '. Bitte prüfen Sie Ihre Datenbankeinstellungen oder verwenden Sie SQLite als Alternative.');
}
}
private function setupSQLiteDatabase(): PDO
{
$dbPath = $this->rootPath . '/storage/database.sqlite';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) {
throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir);
}
try {
$pdo = new PDO(
'sqlite:' . $dbPath,
null,
null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
$pdo->exec('PRAGMA busy_timeout=5000');
$schema = file_get_contents($this->rootPath . '/database/schema.sqlite.sql');
if (!is_string($schema) || $schema === '') {
throw new RuntimeException('Das SQLite-Datenbankschema konnte nicht geladen werden.');
}
$pdo->exec($schema);
return $pdo;
} catch (\PDOException $e) {
throw new RuntimeException('SQLite-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage());
}
}
private function buildEnvFile(array $input, bool $useMySQL = true): string
{
$appUrl = $this->normalizeAppUrl((string) $input['app_url']);
$mailFrom = $this->resolveMailFrom($input, $appUrl);
$appKey = bin2hex(random_bytes(32));
$rfidSecret = bin2hex(random_bytes(24));
$lines = [
'APP_NAME="Kaffeekasse SaaS"',
'APP_ENV=production',
'APP_DEBUG=0',
'APP_URL=' . $appUrl,
'APP_TIMEZONE=Europe/Berlin',
'APP_KEY=' . $appKey,
'ALLOW_PUBLIC_REGISTRATION=1',
'RFID_INGEST_ENABLED=0',
'',
];
// Datenbankeinstellungen je nach verwendetem System
if ($useMySQL) {
$lines = array_merge($lines, [
'DB_HOST=' . trim((string) $input['db_host']),
'DB_PORT=' . (int) $input['db_port'],
'DB_NAME=' . trim((string) $input['db_name']),
'DB_USER=' . trim((string) $input['db_user']),
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
]);
} else {
// SQLite-Konfiguration
$lines = array_merge($lines, [
'DB_HOST=sqlite',
'DB_PORT=0',
'DB_NAME=' . $this->rootPath . '/storage/database.sqlite',
'DB_USER=',
'DB_PASS=',
]);
}
$lines = array_merge($lines, [
'',
'MAIL_FROM=' . $mailFrom,
'MAIL_REPLY_TO=' . $mailFrom,
'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "',
'MAIL_LOG_FALLBACK=1',
'MAIL_LOG_FILE=storage/logs/mail.log',
'',
'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
'PASSWORD_RESET_TTL_MINUTES=60',
'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"',
'',
'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5',
'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900',
'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900',
'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3',
'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600',
'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600',
'',
'RFID_SHARED_SECRET=' . $rfidSecret,
]);
return implode(PHP_EOL, $lines) . PHP_EOL;
}
private function normalizeAppUrl(string $appUrl): string
{
return rtrim(trim($appUrl), '/');
}
private function resolveMailFrom(array $input, string $appUrl): string
{
$mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? '')));
if ($mailFrom === '' || $mailFrom === 'noreply@example.com') {
return $this->defaultMailFrom($appUrl);
}
if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.');
}
return $mailFrom;
}
private function defaultMailFrom(string $appUrl): string
{
$host = parse_url($appUrl, PHP_URL_HOST);
if (!is_string($host)) {
return 'noreply@example.com';
}
$host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? '';
if ($host === '' || !str_contains($host, '.')) {
return 'noreply@example.com';
}
return 'noreply@' . $host;
}
}