mailwolt/app/Livewire/Ui/System/SettingsForm.php

374 lines
15 KiB
PHP

<?php
namespace App\Livewire\Ui\System;
use App\Models\BackupPolicy;
use App\Models\Setting;
use Illuminate\Support\Facades\Artisan;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Einstellungen · Mailwolt')]
class SettingsForm extends Component
{
// Allgemein
public string $instance_name = '';
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
public int $session_timeout = 120;
// Domains
public string $ui_domain = '';
public string $mail_domain = '';
public string $webmail_domain = '';
// Sicherheit
public int $rate_limit = 5;
public int $password_min = 10;
// Backup
public bool $backup_enabled = false;
public string $backup_preset = 'daily';
public string $backup_time = '03:00';
public string $backup_weekday = '1';
public string $backup_monthday = '1';
public string $backup_cron = '0 3 * * *';
public int $backup_retention = 7;
public bool $backup_include_db = true;
public bool $backup_include_maildirs = true;
public bool $backup_include_configs = true;
public string $backup_mail_dir = '';
protected function rules(): array
{
return [
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
'session_timeout' => 'required|integer|min:5|max:1440',
'rate_limit' => 'required|integer|min:1|max:100',
'password_min' => 'required|integer|min:6|max:128',
'backup_enabled' => 'boolean',
'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom',
'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/',
'backup_weekday' => 'required_if:backup_preset,weekly|integer|min:0|max:6',
'backup_monthday' => 'required_if:backup_preset,monthly|integer|min:1|max:28',
'backup_cron' => 'required_if:backup_preset,custom|string|max:64',
'backup_retention' => 'required|integer|min:1|max:365',
];
}
protected function domainRules(): array
{
$host = 'required|string|max:190|regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
return [
'ui_domain' => $host,
'mail_domain' => $host,
'webmail_domain'=> $host,
];
}
protected function domainMessages(): array
{
$fmt = 'Ungültige Domain — kein Schema (http://) und kein Slash am Ende erlaubt.';
$req = 'Dieses Feld darf nicht leer sein.';
return [
'ui_domain.required' => $req,
'mail_domain.required' => $req,
'webmail_domain.required' => $req,
'ui_domain.regex' => $fmt,
'mail_domain.regex' => $fmt,
'webmail_domain.regex' => $fmt,
];
}
public function mount(): void
{
$this->instance_name = (string) (config('app.name') ?? 'Mailwolt');
$this->locale = (string) Setting::get('locale', $this->locale);
$this->timezone = (string) Setting::get('timezone', $this->timezone);
$this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout);
$base = env('BASE_DOMAIN', '');
$persist = [];
$uiSetting = Setting::get('ui_domain', null);
if ($uiSetting !== null) {
$this->ui_domain = (string) $uiSetting;
} else {
$this->ui_domain = (string) env('APP_HOST', '');
$persist['ui_domain'] = $this->ui_domain;
}
$mailSetting = Setting::get('mail_domain', null);
if ($mailSetting !== null) {
$this->mail_domain = (string) $mailSetting;
} else {
$mtaSub = env('MTA_SUB', '');
$this->mail_domain = $mtaSub && $base ? "{$mtaSub}.{$base}" : '';
$persist['mail_domain'] = $this->mail_domain;
}
$webmailSetting = Setting::get('webmail_domain', null);
if ($webmailSetting !== null) {
$this->webmail_domain = (string) $webmailSetting;
} else {
$webmailSub = env('WEBMAIL_SUB', '');
$webmailDomain = env('WEBMAIL_DOMAIN', '');
$this->webmail_domain = $webmailDomain ?: ($webmailSub && $base ? "{$webmailSub}.{$base}" : '');
$persist['webmail_domain'] = $this->webmail_domain;
}
if ($persist) {
Setting::setMany($persist);
}
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
// Backup aus BackupPolicy laden
$policy = BackupPolicy::first();
if ($policy) {
$this->backup_enabled = $policy->enabled;
$this->backup_retention = $policy->retention_count;
$this->backup_cron = $policy->schedule_cron;
$this->backup_include_db = (bool) $policy->include_db;
$this->backup_include_maildirs = (bool) $policy->include_maildirs;
$this->backup_include_configs = (bool) $policy->include_configs;
$this->backup_mail_dir = (string) Setting::get('backup_mail_dir', '');
$this->parseCronToPreset($policy->schedule_cron);
}
}
public function save(): void
{
$this->validate();
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
'session_timeout' => $this->session_timeout,
'rate_limit' => $this->rate_limit,
'password_min' => $this->password_min,
]);
// Backup-Policy speichern
$cron = $this->buildCron();
BackupPolicy::updateOrCreate([], [
'enabled' => $this->backup_enabled,
'schedule_cron' => $cron,
'retention_count' => $this->backup_retention,
'include_db' => $this->backup_include_db,
'include_maildirs' => $this->backup_include_maildirs,
'include_configs' => $this->backup_include_configs,
]);
Setting::setMany(['backup_mail_dir' => $this->backup_mail_dir]);
$this->backup_cron = $cron;
$this->dispatch('toast', type: 'done', badge: 'Einstellungen',
title: 'Gespeichert',
text: 'Alle Einstellungen wurden übernommen.', duration: 4000);
}
public function saveDomains(): void
{
$this->validate($this->domainRules(), $this->domainMessages());
// Normalize: strip schema + trailing slashes
$this->ui_domain = $this->cleanDomain($this->ui_domain);
$this->mail_domain = $this->cleanDomain($this->mail_domain);
$this->webmail_domain = $this->cleanDomain($this->webmail_domain);
Setting::setMany([
'ui_domain' => $this->ui_domain,
'mail_domain' => $this->mail_domain,
'webmail_domain' => $this->webmail_domain,
]);
// Immer .env aktualisieren — unabhängig von DNS oder Nginx
$this->syncDomainsToEnv();
// Nginx-Konfiguration nur anwenden wenn alle Domains gesetzt sind
if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) {
$sslAuto = app()->isProduction();
$this->applyDomains($sslAuto);
}
$this->dispatch('toast', type: 'done', badge: 'Domains',
title: 'Domains gespeichert',
text: 'Konfiguration wurde übernommen.', duration: 4000);
}
private function cleanDomain(string $value): string
{
$value = trim($value);
$value = preg_replace('#^https?://#i', '', $value);
return rtrim($value, '/');
}
public function openSslModal(): void
{
if (! ($this->ui_domain && $this->mail_domain && $this->webmail_domain)) {
$this->dispatch('toast', type: 'warn', badge: 'SSL',
title: 'Domains fehlen',
text: 'Bitte erst alle drei Domains speichern.', duration: 5000);
return;
}
$this->dispatch('openModal', component: 'ui.system.modal.ssl-provision-modal');
}
#[\Livewire\Attributes\On('ssl:provision')]
public function provisionSsl(): void
{
$this->applyDomains(true);
}
public function updatedUiDomain(): void { $this->validateOnly('ui_domain', $this->domainRules(), $this->domainMessages()); }
public function updatedMailDomain(): void { $this->validateOnly('mail_domain', $this->domainRules(), $this->domainMessages()); }
public function updatedWebmailDomain(): void { $this->validateOnly('webmail_domain', $this->domainRules(), $this->domainMessages()); }
// Aktualisiert die Cron-Vorschau live wenn der User Felder ändert
public function updatedBackupPreset(): void { $this->backup_cron = $this->buildCron(); }
public function updatedBackupTime(): void { $this->backup_cron = $this->buildCron(); }
public function updatedBackupWeekday(): void { $this->backup_cron = $this->buildCron(); }
public function updatedBackupMonthday(): void{ $this->backup_cron = $this->buildCron(); }
private function buildCron(): string
{
[$h, $m] = array_pad(explode(':', $this->backup_time ?? '03:00'), 2, '00');
$h = (int)$h; $m = (int)$m;
return match($this->backup_preset) {
'hourly' => '0 * * * *',
'daily' => sprintf('%d %d * * *', $m, $h),
'weekly' => sprintf('%d %d * * %d', $m, $h, (int)$this->backup_weekday),
'monthly' => sprintf('%d %d %d * *', $m, $h, (int)$this->backup_monthday),
default => $this->backup_cron ?: '0 3 * * *',
};
}
private function parseCronToPreset(string $cron): void
{
$p = preg_split('/\s+/', trim($cron));
if (count($p) !== 5) { $this->backup_preset = 'custom'; return; }
[$min, $hour, $dom, $mon, $dow] = $p;
if ($cron === '0 * * * *') {
$this->backup_preset = 'hourly';
} elseif ($dom === '*' && $mon === '*' && $dow === '*' && is_numeric($hour)) {
$this->backup_preset = 'daily';
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
} elseif ($dom === '*' && $mon === '*' && is_numeric($dow) && is_numeric($hour)) {
$this->backup_preset = 'weekly';
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
$this->backup_weekday = $dow;
} elseif ($mon === '*' && $dow === '*' && is_numeric($dom) && is_numeric($hour)) {
$this->backup_preset = 'monthly';
$this->backup_time = sprintf('%02d:%02d', $hour, $min);
$this->backup_monthday = $dom;
} else {
$this->backup_preset = 'custom';
}
}
private function applyDomains(bool $sslAuto = false): void
{
// DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen
$unreachable = [];
foreach ([$this->ui_domain, $this->webmail_domain, $this->mail_domain] as $host) {
if (! checkdnsrr($host, 'A') && ! checkdnsrr($host, 'AAAA')) {
$unreachable[] = $host;
}
}
if ($unreachable) {
$this->dispatch('toast', type: 'warn', badge: 'DNS',
title: 'DNS noch nicht erreichbar',
text: 'Kein DNS-Eintrag für: ' . implode(', ', $unreachable) . '. Domains wurden trotzdem gespeichert — Nginx-Konfiguration bitte nach DNS-Setup erneut speichern.',
duration: 9000,
);
return;
}
$helper = '/usr/local/sbin/mailwolt-apply-domains';
$cmd = sprintf(
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1',
escapeshellarg($helper),
escapeshellarg($this->ui_domain),
escapeshellarg($this->webmail_domain),
escapeshellarg($this->mail_domain),
$sslAuto ? 1 : 0,
);
$output = shell_exec($cmd);
$ok = str_contains((string) $output, 'fertig');
\Illuminate\Support\Facades\Log::info('mailwolt-apply-domains', ['output' => $output]);
if ($ok) {
$this->dispatch('toast', type: 'done', badge: 'Nginx',
title: 'Nginx aktualisiert',
text: 'Nginx-Konfiguration wurde neu geladen.',
duration: 5000,
);
} else {
$this->dispatch('toast', type: 'warn', badge: 'Nginx',
title: 'Nginx-Konfiguration fehlgeschlagen',
text: 'Nginx konnte nicht neu geladen werden. Domains wurden trotzdem gespeichert. Siehe Laravel-Log.',
duration: 7000,
);
}
}
private function syncDomainsToEnv(): void
{
$base = rtrim((string) config('mailwolt.domain.base', ''), '.');
$sub = function (string $full) use ($base): string {
$full = strtolower(trim($full));
if ($base && str_ends_with($full, '.' . $base)) {
return substr($full, 0, -(strlen($base) + 1));
}
// Kein passender Base — ersten Label nehmen
$parts = explode('.', $full);
return count($parts) > 1 ? $parts[0] : '';
};
$this->writeEnv([
'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '',
'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '',
'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '',
'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '',
]);
Artisan::call('config:clear');
Artisan::call('route:clear');
}
private function writeEnv(array $values): void
{
$path = base_path('.env');
$content = file_get_contents($path);
foreach ($values as $key => $value) {
$escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value);
$line = $key . '=' . $escaped;
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
}
file_put_contents($path, $content);
}
public function render()
{
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
return view('livewire.ui.system.settings-form', compact('timezones'));
}
}