268 lines
11 KiB
PHP
268 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Settings;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Setting;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Component;
|
|
use Throwable;
|
|
|
|
/**
|
|
* SMTP configuration card. The operator points the panel at an SMTP server so
|
|
* password-reset mails (and future notifications) actually leave the box. Values
|
|
* are stored as Setting rows and applied to runtime config by AppServiceProvider::boot().
|
|
*
|
|
* The SMTP password is the one secret here: it is stored ENCRYPTED at rest
|
|
* (Crypt::encryptString, APP_KEY) — Setting::put stores plaintext, so we encrypt the
|
|
* value ourselves — never rendered back to the browser, and never logged/echoed
|
|
* (any error from the test-send is shown without the password).
|
|
*/
|
|
class Email extends Component
|
|
{
|
|
public string $mail_host = '';
|
|
|
|
public int $mail_port = 587;
|
|
|
|
public string $mail_username = '';
|
|
|
|
/** New password typed in the form. Empty = keep the stored one (never pre-filled). */
|
|
public string $mail_password = '';
|
|
|
|
public string $mail_encryption = 'tls';
|
|
|
|
public string $mail_from_address = '';
|
|
|
|
public string $mail_from_name = '';
|
|
|
|
/** True once a password has been stored, so the field shows a "(set)" placeholder. */
|
|
public bool $passwordSet = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
|
$this->mail_host = Setting::get('mail_host', '') ?? '';
|
|
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
|
|
$this->mail_username = Setting::get('mail_username', '') ?? '';
|
|
$this->mail_encryption = Setting::get('mail_encryption', 'tls') ?? 'tls';
|
|
$this->mail_from_address = Setting::get('mail_from_address', '') ?? '';
|
|
$this->mail_from_name = Setting::get('mail_from_name', '') ?? '';
|
|
$this->passwordSet = (Setting::get('mail_password') ?? '') !== '';
|
|
}
|
|
|
|
protected function rules(): array
|
|
{
|
|
return [
|
|
'mail_host' => ['required', 'string', 'max:255'],
|
|
'mail_port' => ['required', 'integer', 'min:1', 'max:65535'],
|
|
'mail_username' => ['nullable', 'string', 'max:255'],
|
|
'mail_password' => ['nullable', 'string', 'max:255'],
|
|
'mail_encryption' => ['required', 'in:none,tls,ssl'],
|
|
'mail_from_address' => ['required', 'email', 'max:255'],
|
|
'mail_from_name' => ['required', 'string', 'max:255'],
|
|
];
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
|
$this->validate();
|
|
|
|
// Retarget guard: if the SMTP HOST or USERNAME changes and no new password was entered, DROP
|
|
// the stored password BEFORE the new host is written — so the old credential can never be
|
|
// sent to a new (possibly attacker-controlled) SMTP server; re-entry is then required. Same
|
|
// class as the alert Gotify token guard. uncached() reads the committed state (no stale cache).
|
|
$targetChanged = $this->mail_host !== (string) Setting::uncached('mail_host', '')
|
|
|| $this->mail_username !== (string) Setting::uncached('mail_username', '');
|
|
if ($targetChanged) {
|
|
// Drop the old password BEFORE any target field is written — regardless of whether a
|
|
// fresh one was typed — so no concurrent MailSettings::apply() can ever read the new
|
|
// host paired with the old password. A freshly typed password is re-written below.
|
|
Setting::forget('mail_password');
|
|
$this->passwordSet = false;
|
|
}
|
|
|
|
Setting::put('mail_host', $this->mail_host);
|
|
Setting::put('mail_port', (string) $this->mail_port);
|
|
Setting::put('mail_username', $this->mail_username);
|
|
Setting::put('mail_encryption', $this->mail_encryption);
|
|
Setting::put('mail_from_address', $this->mail_from_address);
|
|
Setting::put('mail_from_name', $this->mail_from_name);
|
|
|
|
// Only overwrite the stored password when a new one was typed; otherwise keep
|
|
// the existing (encrypted) value untouched. Store encrypted at rest.
|
|
if ($this->mail_password !== '') {
|
|
Setting::put('mail_password', Crypt::encryptString($this->mail_password));
|
|
$this->passwordSet = true;
|
|
}
|
|
|
|
$this->reset('mail_password');
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()->name,
|
|
'action' => 'mail.configure',
|
|
'target' => $this->mail_host,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
$this->dispatch('notify', message: __('mail.notify_saved'));
|
|
}
|
|
|
|
/**
|
|
* Fully clear the SMTP configuration — removes every mail_* setting (incl. the encrypted
|
|
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
|
|
*/
|
|
public function clearConfig(): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
|
|
|
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
|
|
Setting::forget($key);
|
|
}
|
|
|
|
$this->mail_host = '';
|
|
$this->mail_port = 587;
|
|
$this->mail_username = '';
|
|
$this->mail_password = '';
|
|
$this->mail_encryption = 'tls';
|
|
$this->mail_from_address = '';
|
|
$this->mail_from_name = '';
|
|
$this->passwordSet = false;
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()->name,
|
|
'action' => 'mail.reset',
|
|
'target' => '—',
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
$this->dispatch('notify', message: __('mail.notify_reset'));
|
|
}
|
|
|
|
/**
|
|
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
|
|
* from the saved settings. Never targets anyone else; never leaks the password — any
|
|
* transport error is surfaced as a message with the secret masked out.
|
|
*/
|
|
public function sendTest(): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
|
|
|
if (! $this->configured()) {
|
|
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
|
|
|
|
return;
|
|
}
|
|
|
|
// Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay.
|
|
// Per-user + auto-expiring: only ever limits the operator's own test button briefly.
|
|
$key = 'mail-test:'.Auth::id();
|
|
if (RateLimiter::tooManyAttempts($key, 3)) {
|
|
$this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
|
|
|
return;
|
|
}
|
|
RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user
|
|
|
|
// Retarget guard: never test-send the STORED password against a host/username the operator
|
|
// just changed in the form but hasn't saved — that could leak the old credential to a new
|
|
// (possibly hostile) server. Require the password to be re-entered for a changed target.
|
|
if ($this->mail_password === ''
|
|
&& ($this->mail_host !== (string) Setting::uncached('mail_host', '')
|
|
|| $this->mail_username !== (string) Setting::uncached('mail_username', ''))) {
|
|
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.test_retarget_password_required')]), level: 'error');
|
|
|
|
return;
|
|
}
|
|
|
|
$to = Auth::user()->email;
|
|
|
|
try {
|
|
$this->applyMailerConfig();
|
|
|
|
Mail::raw(__('mail.test_body'), function ($message) use ($to) {
|
|
$message->to($to)->subject(__('mail.test_subject'));
|
|
});
|
|
|
|
$this->dispatch('notify', message: __('mail.notify_test_ok', ['email' => $to]));
|
|
} catch (Throwable $e) {
|
|
$this->dispatch('notify', message: __('mail.notify_test_failed', [
|
|
'error' => $this->maskSecret(Str::limit($e->getMessage(), 160)),
|
|
]));
|
|
}
|
|
}
|
|
|
|
/** Host + from-address present — the minimum to actually send. */
|
|
public function configured(): bool
|
|
{
|
|
return $this->mail_host !== '' && $this->mail_from_address !== '';
|
|
}
|
|
|
|
/**
|
|
* Apply the saved SMTP settings to runtime config for the duration of this request,
|
|
* so the test-send uses exactly what the operator configured (decrypting the stored
|
|
* password). Mirrors AppServiceProvider's runtime override.
|
|
*/
|
|
private function applyMailerConfig(): void
|
|
{
|
|
// Prefer a FRESHLY typed password (test-before-save); fall back to the stored one only when
|
|
// the field is blank — and the sendTest() retarget guard has already ensured that fallback
|
|
// is used only when host+username are unchanged from what the stored password was set for.
|
|
$password = '';
|
|
if ($this->mail_password !== '') {
|
|
$password = $this->mail_password;
|
|
} else {
|
|
$stored = Setting::uncached('mail_password');
|
|
if ($stored !== null && $stored !== '') {
|
|
try {
|
|
$password = Crypt::decryptString($stored);
|
|
} catch (Throwable) {
|
|
$password = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
config([
|
|
'mail.default' => 'smtp',
|
|
'mail.mailers.smtp.host' => $this->mail_host,
|
|
'mail.mailers.smtp.port' => $this->mail_port,
|
|
'mail.mailers.smtp.username' => $this->mail_username ?: null,
|
|
'mail.mailers.smtp.password' => $password ?: null,
|
|
'mail.mailers.smtp.encryption' => $this->mail_encryption === 'none' ? null : $this->mail_encryption,
|
|
'mail.from.address' => $this->mail_from_address,
|
|
'mail.from.name' => $this->mail_from_name,
|
|
]);
|
|
|
|
// Drop any mailer Laravel already resolved this request so the new config takes effect.
|
|
Mail::purge('smtp');
|
|
}
|
|
|
|
/** Strip the stored SMTP password out of any string before it is shown/logged. */
|
|
private function maskSecret(string $text): string
|
|
{
|
|
$stored = Setting::get('mail_password');
|
|
if ($stored === null || $stored === '') {
|
|
return $text;
|
|
}
|
|
|
|
try {
|
|
$plain = Crypt::decryptString($stored);
|
|
} catch (Throwable) {
|
|
return $text;
|
|
}
|
|
|
|
return $plain !== '' ? str_replace($plain, '****', $text) : $text;
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.settings.email');
|
|
}
|
|
}
|