Hinzufügen der Seiten Seccrity und Domains

main
boban 2025-10-07 20:24:26 +02:00
parent 5760d69902
commit 7af7b2fb2b
159 changed files with 8843 additions and 384 deletions

View File

@ -0,0 +1,95 @@
<?php
namespace App\Console\Commands;
use App\Models\Domain;
use App\Models\TlsaRecord;
use App\Support\Hostnames;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class GenerateTlsaRecord extends Command
{
protected $signature = 'dns:tlsa
{domainId : ID der Domain in eurer domains-Tabelle}
{--host= : FQDN des MTA-Hosts (z.B. mailsrv012.domain.com)}
{--service=_25._tcp : TLSA Service-Präfix, Standard _25._tcp}
{--usage=3 : 3 = DANE-EE}
{--selector=1 : 1 = SPKI}
{--matching=1 : 1 = SHA-256}';
protected $description = 'Erzeugt/aktualisiert einen TLSA-Record und speichert ihn in tlsa_records.';
public function handle(): int
{
$domain = Domain::find($this->argument('domainId'));
if (!$domain) {
$this->error('Domain nicht gefunden.');
return self::FAILURE;
}
// Host bestimmen
$host = trim($this->option('host') ?: Hostnames::mta());
if (!str_contains($host, '.')) {
$this->error("Ungültiger Host: {$host}");
return self::FAILURE;
}
$service = $this->option('service') ?: '_25._tcp';
$usage = (int) $this->option('usage');
$selector = (int) $this->option('selector');
$matching = (int) $this->option('matching');
// Lets Encrypt Pfad (ggf. anpassen, falls anderes CA/Verzeichnis)
$certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem";
if (!is_file($certPath)) {
$this->error("Zertifikat nicht gefunden: {$certPath}");
$this->line('Tipp: LE deploy hook/renewal erst durchlaufen lassen oder Pfad anpassen.');
return self::FAILURE;
}
// Hash über SPKI (selector=1) + SHA-256 (matching=1)
$cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey"
. " | openssl pkey -pubin -outform DER"
. " | openssl dgst -sha256";
$proc = Process::fromShellCommandline($cmd);
$proc->run();
if (!$proc->isSuccessful()) {
$this->error('Fehler bei der Hash-Erzeugung (openssl).');
$this->line($proc->getErrorOutput());
return self::FAILURE;
}
$hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($proc->getOutput()));
$record = TlsaRecord::updateOrCreate(
[
'domain_id' => $domain->id,
'host' => $host,
'service' => $service,
],
[
'usage' => $usage,
'selector' => $selector,
'matching' => $matching,
'hash' => $hash,
'cert_path' => $certPath,
]
);
$this->info('✅ TLSA gespeichert');
$this->line(sprintf(
'%s.%s IN TLSA %d %d %d %s',
$record->service,
$record->host,
$record->usage,
$record->selector,
$record->matching,
$record->hash
));
return self::SUCCESS;
}
}

15
app/Enums/Role.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum Role: string
{
case Member = 'member';
case Admin = 'admin';
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

20
app/Events/CertPing.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class CertPing implements ShouldBroadcastNow
{
public function __construct(public string $message) {}
public function broadcastOn(): Channel
{ return new Channel('system.tasks'); }
public function broadcastAs(): string
{ return 'cert.ping'; }
public function broadcastWith(): array
{ return ['message' => $this->message]; }
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CertProvisionProgress implements ShouldBroadcastNow
{
public function __construct(
public string $taskKey, // z.B. "issue-cert:mxmail.nexlab.at"
public string $status, // queued|running|done|failed
public string $message = '',
public ?string $mode = null // letsencrypt|self-signed
) {}
public function broadcastOn(): Channel
{
return new Channel('tasks.'.$this->taskKey);
}
public function broadcastAs(): string
{
return 'cert.progress';
}
public function broadcastWith(): array
{
return [
'taskKey' => $this->taskKey,
'status' => $this->status,
'message' => $this->message,
'mode' => $this->mode,
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class CertStatusUpdated implements ShouldBroadcastNow
{
/**
* @param string $id Eindeutiger Task-Key (z.B. "task:issue-cert:10.10.70.58")
* @param string $state queued|running|done|failed
* @param string $message Menschlich lesbarer Status-Text
* @param int $progress 0..100
* @param array $meta frei: ['domain'=>..., 'mode'=>..., 'final'=>true, ...]
*/
public function __construct(
public string $id,
public string $state,
public string $message,
public int $progress = 0,
public array $meta = [],
) {}
public function broadcastOn(): Channel
{
return new Channel('system.tasks');
}
public function broadcastAs(): string
{
return 'cert.status';
}
public function broadcastWith(): array
{
return [
'id' => $this->id,
'state' => $this->state, // queued|running|done|failed
'message' => $this->message,
'progress' => $this->progress, // 0..100
'meta' => $this->meta, // beliebige Zusatzinfos
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class TaskUpdated implements ShouldBroadcastNow
{
public function __construct(
public string $taskId,
public int $userId,
public array $payload, // genau das JSON, das du in Redis speicherst
) {}
public function broadcastOn(): Channel
{
// Public channel simpel. (Wenn du willst, nutze Private Channels.)
return new Channel('system.tasks');
}
public function broadcastAs(): string
{
return 'task.updated';
}
public function broadcastWith(): array
{
return [
'taskId' => $this->taskId,
'userId' => $this->userId,
'payload' => $this->payload,
];
}
}

36
app/Events/Test.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class Test
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -5,3 +5,32 @@ if (! function_exists('settings')) {
return app(\App\Support\SettingsRepository::class)->get($key, $default);
}
}
if (!function_exists('domain_host')) {
function domain_host(string $sub = null): string
{
$base = env('BASE_DOMAIN', 'example.com');
return $sub ? "{$sub}.{$base}" : $base;
}
}
if (!function_exists('ui_host')) {
function ui_host(): string
{
return domain_host(env('UI_SUB', 'ui'));
}
}
if (!function_exists('webmail_host')) {
function webmail_host(): string
{
return domain_host(env('WEBMAIL_SUB', 'webmail'));
}
}
if (!function_exists('mta_host')) {
function mta_host(): string
{
return domain_host(env('MTA_SUB', 'mx'));
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class TaskFeedController extends Controller
{
public function active()
{
$user = Auth::user();
$setKey = "user:{$user->id}:tasks";
$taskKeys = Redis::smembers($setKey) ?: [];
$items = [];
foreach ($taskKeys as $k) {
// wir lesen aus dem Cache-Store "redis"
$payload = Cache::store('redis')->get($k);
if (!$payload) {
// verwaist -> aus dem Set räumen
Redis::srem($setKey, $k);
continue;
}
$items[] = array_merge(['id' => $k], $payload);
}
return response()->json(['items' => $items]);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Auth;
class TaskStatusController
{
public function index(Request $request): JsonResponse
{
$userId = (int) $request->user()->id; // du hast in Redis bereits "user:{id}:tasks"
$keySet = "user:{$userId}:tasks";
$taskKeys = Redis::smembers($keySet) ?: [];
$items = [];
foreach ($taskKeys as $key) {
$snap = Cache::store('redis')->get($key);
if ($snap) {
// erwartet: ['type','status','message','payload'=>['domain'=>..], ...]
$items[] = ['id' => $key] + $snap;
}
}
return response()->json([
'ok' => true,
'count' => count($items),
'items' => $items,
]);
}
public function active(Request $request)
{
$userId = Auth::id() ?? 0; // oder feste 3, wenn du noch ohne Login testest
$setKey = "user:{$userId}:tasks";
$items = [];
foreach (Redis::smembers($setKey) ?? [] as $taskKey) {
if ($payload = Cache::store('redis')->get($taskKey)) {
$items[] = ['id' => $taskKey] + $payload;
}
}
return response()->json(['items' => $items]);
}
// public function active(Request $request)
// {
// // Wenn du pro-User Toaster verwaltest:
// $userId = Auth::id() ?? 0;
//
// // Wir gehen davon aus, dass du eine Set-Liste der Keys pflegst,
// // z.B. "ui:toasts:users:{id}" und unter jedem $taskKey ein JSON-Objekt im Cache liegt.
// $setKey = "ui:toasts:users:{$userId}";
// $keys = Redis::smembers($setKey) ?? [];
//
// $items = [];
// foreach ($keys as $key) {
// $payload = Cache::store('redis')->get($key);
// if ($payload) {
// $items[] = array_merge(['id' => $key], $payload);
// }
// }
//
// // Immer JSON liefern kein Redirect, keine Blade-View
// return response()->json([
// 'items' => $items,
// ]);
// }
}

View File

@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class LoginPageController extends Controller
class LoginController extends Controller
{
public function show()
{

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class SignUpController extends Controller
{
public function show()
{
return view('auth.signup');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function show()
{
return view('pages.dashboard'); // enthält @livewire('login-form')
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\UI;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
// ggf. Prefetch für Blade, sonst alles via Livewire
return view('ui.dashboard.index');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\UI\Domain;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Services\DnsRecordService;
use Illuminate\Http\Request;
class DomainDnsController extends Controller
{
public function index()
{
return view('ui.domain.index'); // keine Logik hier
}
// public function show(Domain $domain, DnsRecordService $service)
// {
// return response()->json([
// 'domain' => $domain->domain,
// 'records' => $service->buildForDomain($domain),
// ]);
// }
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\UI\Security;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Response;
class RecoveryCodeDownloadController
{
public function download(Request $request)
{
$token = (string) $request->query('token');
$payload = Cache::pull("recovery:$token"); // pull = get + forget
abort_unless($payload, 410); // Gone / abgelaufen
abort_unless($payload['user_id'] === $request->user()->id, 403);
$email = (string) $request->user()->email;
$now = now()->toDateTimeString();
$lines = [];
$lines[] = 'MailWolt Recovery-Codes';
$lines[] = "Account: $email";
$lines[] = "Erzeugt: $now";
$lines[] = str_repeat('-', 34);
foreach ($payload['codes'] as $code) {
$lines[] = $code;
}
$lines[] = str_repeat('-', 34);
$lines[] = 'Bewahre diese Codes sicher offline auf. Jeder Code ist nur einmal gültig.';
$content = implode("\n", $lines);
$filename = 'mailwolt_recovery_codes_' . now()->format('Ymd_His') . '.txt';
return Response::streamDownload(
fn () => print($content),
$filename,
['Content-Type' => 'text/plain; charset=UTF-8']
);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\UI\Security;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class SecurityController extends Controller
{
public function index()
{
return view('ui.security.index');
}
public function ssl()
{
return view('ui.security.ssl');
}
public function fail2ban()
{
return view('ui.security.fail2ban');
}
public function rspamd()
{
return view('ui.security.rspamd');
}
public function tlsCiphers()
{
return view('ui.security.tls-ciphers');
}
public function auditLogs()
{
return view('ui.security.audit-logs');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\UI\System;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class SettingsController extends Controller
{
public function index()
{
return view('ui.system.settings');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SignupOpen
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! Setting::signupAllowed()) {
abort(404); // harte 404, keine Weiterleitung
}
return $next($request);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Events\CertProvisionProgress;
use App\Models\SystemTask;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -37,18 +38,34 @@ class ProvisionCertJob implements ShouldQueue
}
}
/** Einmalig alles machen: DB updaten, Cache spiegeln, Broadcast senden */
private function emit(SystemTask $task, string $status, string $message, ?string $mode = null, int $ttl = 30): void
{
$task->update(['status' => $status, 'message' => $message]);
$this->syncCache($task, $ttl);
// Live-Update an Frontend (Reverb/Echo)
broadcast(new CertProvisionProgress(
taskKey: $task->key,
status : $status, // queued|running|done|failed
message: $message,
mode : $mode // letsencrypt|self-signed
));
}
public function handle(): void
{
$task = SystemTask::where('key', $this->taskKey)->first();
if (!$task) return;
$mode = $this->useLetsEncrypt ? 'letsencrypt' : 'self-signed';
// running
$task->update(['status' => 'running', 'message' => 'Starte Zertifikat-Provisionierung…']);
$this->syncCache($task);
$this->emit($task, 'running', 'Starte Zertifikat-Provisionierung…', $mode);
if ($this->useLetsEncrypt) {
$task->update(['message' => 'Lets Encrypt wird ausgeführt…']);
$this->syncCache($task);
$this->emit($task, 'running', 'Lets Encrypt wird ausgeführt…', $mode);
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
@ -57,19 +74,22 @@ class ProvisionCertJob implements ShouldQueue
if ($exit !== 0) {
$out = trim(Artisan::output());
$task->update(['message' => 'LE fehlgeschlagen: '.($out ?: 'Unbekannter Fehler Fallback auf Self-Signed…')]);
$this->syncCache($task);
$this->emit(
$task,
'running',
'Lets Encrypt fehlgeschlagen: '.($out ?: 'Unbekannter Fehler') . ' Fallback auf Self-Signed…',
$mode
);
// Fallback: Self-Signed
// Fallback → self-signed
$mode = 'self-signed';
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
]);
}
} else {
$task->update(['message' => 'Self-Signed wird erstellt…']);
$this->syncCache($task);
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
$exit = Artisan::call('mailwolt:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
@ -79,52 +99,61 @@ class ProvisionCertJob implements ShouldQueue
$out = trim(Artisan::output());
if ($exit === 0) {
$task->update(['status' => 'done', 'message' => 'Zertifikat aktiv. '.$out]);
$this->syncCache($task, 10);
$msg = 'Zertifikat erfolgreich erstellt. '.$out;
$this->emit($task, 'done', $msg, $mode, ttl: 10);
} else {
$task->update(['status' => 'failed', 'message' => $out ?: 'Zertifikatserstellung fehlgeschlagen.']);
$this->syncCache($task, 30);
$msg = $out ?: 'Zertifikatserstellung fehlgeschlagen.';
$this->emit($task, 'failed', $msg, $mode, ttl: 30);
}
}
// public function handle(): void
// {
// $task = SystemTask::where('key', $this->taskKey)->first();
// if (!$task) return;
//
// // running
// $task->update(['status' => 'running', 'message' => 'Starte Zertifikat-Provisionierung…']);
// $this->syncCache($task);
//
// if ($this->useLetsEncrypt) {
// $task->update(['message' => 'Lets Encrypt wird ausgeführt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// 'domain' => $this->domain,
// '--email' => $this->email ?? '',
// ]);
//
// if ($exit !== 0) {
// $out = trim(Artisan::output());
// $task->update(['message' => 'LE fehlgeschlagen: '.($out ?: 'Unbekannter Fehler Fallback auf Self-Signed…')]);
// $this->syncCache($task);
//
// // Fallback: Self-Signed
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);
// }
// } else {
// $task->update(['message' => 'Self-Signed wird erstellt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('mailwolt:provision-cert', [
// 'domain' => $this->domain,
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);
// }
//
// $out = trim(Artisan::output());
//
// if ($exit === 0) {
// $task->update(['status' => 'done', 'message' => 'Zertifikat aktiv. '.$out]);
// $this->syncCache($task, 10);
// } else {
// $task->update(['status' => 'failed', 'message' => $out ?: 'Zertifikatserstellung fehlgeschlagen.']);
// $this->syncCache($task, 30);
// }
// }
}

View File

@ -0,0 +1,381 @@
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Finder\Finder;
class RunHealthChecks implements ShouldQueue
{
use Queueable;
public int $timeout = 10; // safety
public int $tries = 1;
public function handle(): void
{
try {
$services = [
$this->safe(fn() => $this->service('postfix'), ['name'=>'postfix']),
$this->safe(fn() => $this->service('dovecot'), ['name'=>'dovecot']),
$this->safe(fn() => $this->service('rspamd'), ['name'=>'rspamd']),
$this->safe(fn() => $this->tcp('127.0.0.1', 6379), ['name'=>'redis']),
$this->safe(fn() => $this->db(), ['name'=>'db']),
// $this->safe(fn() => $this->queueWorkers(), ['name'=>'queue']),
$this->safe(fn() => $this->tcp('127.0.0.1', 8080), ['name'=>'reverb']),
];
$meta = [
'app_version' => config('app.version', app()->version()),
'pending_migs' => $this->safe(fn() => $this->pendingMigrationsCount(), 0),
'cert_soon' => $this->safe(fn() => $this->certificatesDue(30), ['count'=>0,'nearest_days'=>null]),
'disk' => $this->safe(fn() => $this->diskUsage(), ['percent'=>null,'free_gb'=>null]),
'system' => $this->systemLoad(),
'updated_at' => now()->toIso8601String(),
];
Cache::put('health:services', array_values($services), 300);
Cache::put('health:meta', $meta, 300);
Cache::put('metrics:queues', [
'outgoing' => 19,
'incoming' => 5,
'today_ok' => 834,
'today_err'=> 12,
'trend' => [
'outgoing' => [2,1,0,4,3,5,4,0,0,0], // letzte 10 Zeitfenster
'incoming' => [1,0,0,1,0,2,1,0,0,0],
'ok' => [50,62,71,88,92,110,96,120,130,115],
'err' => [1,0,0,2,1,0,1,3,2,2],
],
], 120);
Cache::put('events:recent', $this->safe(fn() => $this->recentAlerts(), []), 300);
} catch (\Throwable $e) {
// Last-resort catch: never allow the job to fail hard
Log::error('RunHealthChecks fatal', ['ex' => $e]);
}
}
/** Wraps a probe; logs and returns fallback on error */
protected function safe(callable $fn, $fallback = null)
{
try {
return $fn();
} catch (\Throwable $e) {
Log::warning('Health probe failed', ['err' => $e->getMessage()]);
return $fallback;
}
}
protected function service(string $name): array
{
// works even if exit code != 0
$r = Process::run("systemctl is-active {$name}");
$raw = trim($r->output() ?: $r->errorOutput());
return ['name' => $name, 'ok' => ($raw === 'active'), 'raw' => $raw ?: 'unknown'];
}
protected function tcp(string $host, int $port): array
{
$ok = @fsockopen($host, $port, $errno, $errstr, 0.4) !== false;
return ['name'=>"$host:$port", 'ok'=>$ok, 'raw'=>$ok ? 'open' : ($errstr ?: 'closed')];
}
protected function db(): array
{
DB::select('select 1'); // will throw if broken
return ['name'=>'db', 'ok'=>true, 'raw'=>'ok'];
}
protected function queueWorkers(): array
{
$r = Process::run("systemctl is-active supervisor");
$raw = trim($r->output() ?: $r->errorOutput());
return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
}
protected function diskUsage(): array
{
$total = @disk_total_space('/') ?: 0;
$free = @disk_free_space('/') ?: 0;
if ($total <= 0) return ['percent'=>null,'free_gb'=>null];
$used = max(0, $total - $free);
return [
'percent' => (int) round($used / $total * 100),
'free_gb' => (int) round($free / 1024 / 1024 / 1024),
];
}
/** Safe pending migration count for database/migrations */
protected function pendingMigrationsCount(): int
{
// Compare migration files with repository entries
$files = collect(iterator_to_array(
Finder::create()->files()->in(database_path('migrations'))->name('*.php')
))->map(fn($f) => pathinfo($f->getFilename(), PATHINFO_FILENAME));
$ran = collect(app('migration.repository')->getRan());
return $files->diff($ran)->count();
}
protected function certificatesDue(int $days): array
{
// TODO: hook real cert source
return ['count'=>0, 'nearest_days'=>null];
}
protected function queueMetrics(): array
{
// TODO: replace with real counters
return ['outgoing'=>19, 'incoming'=>5, 'today_ok'=>834, 'today_err'=>12];
}
protected function recentAlerts(): array
{
// return [
// ['level'=>'warning','text'=>'TLS handshake retry from 1.2.3.4','at'=>now()->subMinutes(3)->toIso8601String()],
// ['level'=>'error','text'=>'Queue backlog high (outgoing>500)','at'=>now()->subMinutes(12)->toIso8601String()],
// ];
$events = [];
// Postfix example (letzte 15 Min)
$r = \Illuminate\Support\Facades\Process::run(
'journalctl -u postfix --since "15 min ago" -o short-iso -n 200'
);
foreach (explode("\n", trim($r->output())) as $line) {
if ($line === '') continue;
// Beispiele für Patterns
if (preg_match('/NOQUEUE: reject/i', $line)) {
$events[] = [
'level' => 'warning',
'text' => 'Postfix reject detected',
'at' => $this->extractIsoTime($line),
];
}
if (preg_match('/timeout|lost connection/i', $line)) {
$events[] = [
'level' => 'warning',
'text' => 'Postfix connection issue',
'at' => $this->extractIsoTime($line),
];
}
}
// Rspamd example
$r2 = \Illuminate\Support\Facades\Process::run(
'journalctl -u rspamd --since "15 min ago" -o short-iso -n 200'
);
foreach (explode("\n", trim($r2->output())) as $line) {
if (preg_match('/greylist|ratelimit/i', $line)) {
$events[] = [
'level' => 'info',
'text' => 'Rspamd rate/greylist notice',
'at' => $this->extractIsoTime($line),
];
}
if (preg_match('/critical|error/i', $line)) {
$events[] = [
'level' => 'error',
'text' => 'Rspamd error',
'at' => $this->extractIsoTime($line),
];
}
}
// Queue-Backlog Signal (optional)
$q = Cache::get('metrics:queues', []);
if (($q['outgoing'] ?? 0) > 500) {
$events[] = [
'level' => 'error',
'text' => 'Queue backlog high (outgoing>500)',
'at' => now()->toIso8601String(),
];
}
// Auf 510 Einträge begrenzen, nach Zeit sortieren
usort($events, fn($a,$b) => strcmp($b['at'] ?? '', $a['at'] ?? ''));
return array_slice($events, 0, 5);
}
// Hilfsfunktion: Zeit aus journalctl-Zeile holen (oder now())
protected function extractIsoTime(string $line): string
{
// journalctl -o short-iso: beginnt mit "2025-10-04T18:33:21+0200 ..."
if (preg_match('/^\s*([0-9T:\-+]+)\s/', $line, $m)) {
try { return \Carbon\Carbon::parse($m[1])->toIso8601String(); } catch (\Throwable $e) {}
}
return now()->toIso8601String();
}
protected function systemLoad(): array
{
// Load (1/5/15)
$load = function_exists('sys_getloadavg') ? (array) sys_getloadavg() : [null, null, null];
// RAM aus /proc/meminfo
$mem = ['total_gb'=>null,'used_gb'=>null,'free_gb'=>null,'percent'=>null];
if (is_readable('/proc/meminfo')) {
$info = [];
foreach (file('/proc/meminfo') as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) {
$info[$m[1]] = (int) $m[2]; // kB
}
}
if (!empty($info['MemTotal']) && isset($info['MemAvailable'])) {
$total = $info['MemTotal'] * 1024;
$avail = $info['MemAvailable'] * 1024;
$used = max(0, $total - $avail);
$mem = [
'total_gb' => round($total/1024/1024/1024, 1),
'used_gb' => round($used /1024/1024/1024, 1),
'free_gb' => round($avail/1024/1024/1024, 1),
'percent' => $total ? (int) round($used/$total*100) : null,
];
}
}
// Core-Anzahl (für Last-Schätzung & Info)
$cores = $this->cpuCores();
// CPU-Prozent (schnelle 200ms-Probe über /proc/stat)
$cpuPercent = $this->cpuPercentSample(200); // kann null sein, wenn nicht lesbar
// Uptime
$uptime = $this->uptimeInfo(); // ['seconds'=>int|null, 'human'=>string|null]
return [
'cpu_load_1' => $load[0] ?? null,
'cpu_load_5' => $load[1] ?? null,
'cpu_load_15' => $load[2] ?? null,
// hilft der Livewire-Klasse beim Schätzen (falls cpu_percent null ist)
'cores' => $cores,
// direkt nutzbar wird bevorzugt angezeigt
'cpu_percent' => $cpuPercent,
// RAM Block (wie bisher, nur vollständiger)
'ram' => $mem,
// Uptime in zwei Formen
'uptime_seconds'=> $uptime['seconds'],
'uptime_human' => $uptime['human'],
];
}
/** Anzahl CPU-Kerne robust ermitteln */
protected function cpuCores(): ?int
{
// 1) nproc
$n = @trim((string) @shell_exec('nproc 2>/dev/null'));
if (ctype_digit($n) && (int)$n > 0) return (int)$n;
// 2) /proc/cpuinfo
if (is_readable('/proc/cpuinfo')) {
$cnt = preg_match_all('/^processor\s*:\s*\d+/mi', file_get_contents('/proc/cpuinfo'));
if ($cnt > 0) return $cnt;
}
return null;
}
/**
* CPU-Auslastung in % per 2-Punkt-Messung über /proc/stat.
* $ms: Messdauer in Millisekunden.
*/
protected function cpuPercentSample(int $ms = 200): ?int
{
$a = $this->readProcStatTotals();
if (!$a) return null;
usleep(max(1, $ms) * 1000);
$b = $this->readProcStatTotals();
if (!$b) return null;
$idleDelta = $b['idle'] - $a['idle'];
$totalDelta = $b['total'] - $a['total'];
if ($totalDelta <= 0) return null;
$usage = 100 * (1 - ($idleDelta / $totalDelta));
return (int) round(max(0, min(100, $usage)));
}
/** Totals aus /proc/stat (user,nice,system,idle,iowait,irq,softirq,steal,guest,guest_nice) */
protected function readProcStatTotals(): ?array
{
if (!is_readable('/proc/stat')) return null;
$line = strtok(file('/proc/stat')[0] ?? '', "\n");
if (!str_starts_with($line, 'cpu ')) return null;
$parts = preg_split('/\s+/', trim($line));
// cpu user nice system idle iowait irq softirq steal guest guest_nice
$vals = array_map('floatval', array_slice($parts, 1));
$idle = ($vals[3] ?? 0) + ($vals[4] ?? 0);
$total = array_sum($vals);
return ['idle' => $idle, 'total' => $total];
}
/** Uptime aus /proc/uptime: Sekunden + menschenlesbar */
protected function uptimeInfo(): array
{
$sec = null;
if (is_readable('/proc/uptime')) {
$first = trim(explode(' ', trim(file_get_contents('/proc/uptime')))[0] ?? '');
if (is_numeric($first)) $sec = (int) round((float) $first);
}
return [
'seconds' => $sec,
'human' => $sec !== null ? $this->fmtSecondsHuman($sec) : null,
];
}
protected function fmtSecondsHuman(int $s): string
{
$d = intdiv($s, 86400); $s %= 86400;
$h = intdiv($s, 3600); $s %= 3600;
$m = intdiv($s, 60);
if ($d > 0) return "{$d}d {$h}h";
if ($h > 0) return "{$h}h {$m}m";
return "{$m}m";
}
// protected function systemLoad(): array
// {
// // 1, 5, 15 Minuten Load averages
// $load = function_exists('sys_getloadavg') ? sys_getloadavg() : [null,null,null];
//
// // RAM aus /proc/meminfo (Linux)
// $mem = ['total'=>null,'free'=>null,'used'=>null,'percent'=>null];
// if (is_readable('/proc/meminfo')) {
// $info = [];
// foreach (file('/proc/meminfo') as $line) {
// if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) {
// $info[$m[1]] = (int)$m[2]; // kB
// }
// }
// if (!empty($info['MemTotal']) && !empty($info['MemAvailable'])) {
// $total = $info['MemTotal'] * 1024;
// $avail = $info['MemAvailable'] * 1024;
// $used = $total - $avail;
// $mem = [
// 'total_gb' => round($total/1024/1024/1024,1),
// 'used_gb' => round($used/1024/1024/1024,1),
// 'free_gb' => round($avail/1024/1024/1024,1),
// 'percent' => $total ? round($used/$total*100) : null,
// ];
// }
// }
//
// return [
// 'cpu_load_1' => $load[0],
// 'cpu_load_5' => $load[1],
// 'cpu_load_15' => $load[2],
// 'ram' => $mem,
// ];
// }
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Jobs;
use App\Events\CertStatusUpdated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Queueable as QueueQueueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class SimulateCertIssue implements ShouldQueue
{
use Dispatchable, Queueable, SerializesModels;
public function __construct(
public string $domain,
public string $taskKey // z.B. "task:issue-cert:10.10.70.58"
) {}
public function handle(): void
{
// queued
event(new CertStatusUpdated(
id: $this->taskKey,
state: 'queued',
message: 'Erstellung gestartet…',
progress: 5,
meta: ['domain' => $this->domain, 'mode' => 'self-signed']
));
sleep(1);
// running CSR
event(new CertStatusUpdated(
id: $this->taskKey,
state: 'running',
message: 'CSR wird erzeugt…',
progress: 20
));
sleep(1);
// running Schlüssel
event(new CertStatusUpdated(
id: $this->taskKey,
state: 'running',
message: 'Schlüssel wird erstellt…',
progress: 55
));
sleep(1);
// running signieren
event(new CertStatusUpdated(
id: $this->taskKey,
state: 'running',
message: 'Self-Signed wird signiert…',
progress: 85
));
sleep(1);
// done (FINAL)
event(new CertStatusUpdated(
id: $this->taskKey,
state: 'done',
message: "Zertifikat für {$this->domain} erstellt.",
progress: 100,
meta: ['final' => true, 'domain' => $this->domain]
));
// --- Aufräumen (verhindert doppelten Toast nach Reload) ---
// 1) Task-Detail entfernen
Cache::store('redis')->forget($this->taskKey);
// (Falls du zusätzlich unter "mailwolt_cache:{$this->taskKey}" speicherst, dann auch das:)
Cache::store('redis')->forget("mailwolt_cache:{$this->taskKey}");
// 2) User-Task-Set bereinigen (so hast du es gespeichert)
if (auth()->id()) {
Redis::srem('user:'.auth()->id().':tasks', $this->taskKey);
}
}
}

View File

@ -11,6 +11,8 @@ class LoginForm extends Component
public string $name = '';
public string $password = '';
public ?string $error = null;
public bool $show = false;
public function login()
{
@ -24,7 +26,6 @@ class LoginForm extends Component
$field = filter_var($this->name, FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
// Login-Versuch
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
request()->session()->regenerate();
return redirect()->intended(route('setup.wizard')) ;

View File

@ -0,0 +1,62 @@
<?php
namespace App\Livewire\Auth;
use App\Enums\Role;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Component;
class SignupForm extends Component
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public bool $accept = false;
public string $successMessage = '';
public function rules(): array
{
return [
'name' => ['required','string','max:120'],
'email' => ['required','string','lowercase','email','max:190','unique:users,email'],
'password' => ['required', 'confirmed', Password::min(4)],
'accept' => ['accepted'],
];
}
public function register()
{
$this->validate();
$isFirstUser = User::count() === 0;
User::create([
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
'role' => Setting::signupAllowed() ? Role::Admin : Role::Member,
]);
if ($isFirstUser) {
Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']);
}
$this->reset(['name','email','password','password_confirmation', 'accept']);
$this->dispatch('toast',
state: 'done',
badge: 'Signup',
domain: 'Registrierung erfolgreich',
message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. <i class="ph ph-arrow-right" ></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
duration: -1,
);
}
public function render()
{
return view('livewire.auth.signup-form');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Livewire;
use App\Events\CertPing;
use App\Jobs\SimulateCertIssue;
use App\Support\ToastBus; // <- neu
use Illuminate\Support\Facades\Auth; // für auth()->id()
use Livewire\Component;
class PingButton extends Component
{
public function ping()
{
event(new CertPing('Ping von Livewire-Button 🚀'));
// kleines Feedback
$this->dispatch('toast', [
'state' => 'done',
'badge' => 'Broadcast',
'domain' => 'Event gesendet',
'message' => 'Sieh in der Browser-Konsole nach.',
'duration' => 3000,
]);
}
public function simulateSelfSigned(string $domain = '10.10.70.58')
{
$taskKey = 'task:issue-cert:'.$domain;
// Optionaler „Start“-Toast vor Redirect
$this->dispatch('toast', state: 'queued', badge: 'ZERTIFIKAT',
domain: $domain, message: 'Erstellung gestartet…', pos: 'bottom-right', duration: -1
);
\App\Jobs\SimulateCertIssue::dispatch($domain, $taskKey);
// return redirect()->route('dashboard');
}
// public function simulateSelfSigned(string $domain = '10.10.70.58')
// {
// $taskKey = 'issue-cert:' . $domain;
// $userId = Auth::id() ?? 0;
//
// // (optional aber nice): Sofort in Redis persistieren + WS feuern,
// // damit der Toast schon vor dem ersten Job-Schritt sichtbar ist.
// ToastBus::put($userId, $taskKey, [
// 'title' => 'Zertifikat',
// 'badge' => 'Self-signed',
// 'message' => 'Erstellung gestartet…',
// 'state' => 'queued',
// 'progress' => 5,
// ]);
//
// // zusätzlich lokales UI-Feedback (falls du es magst)
// $this->dispatch('toast', [
// 'state' => 'queued',
// 'badge' => 'Zertifikat',
// 'domain' => $domain,
// 'message' => 'Erstellung gestartet…',
// 'pos' => 'bottom-right',
// 'duration' => -1, // offen lassen, bis "done/failed" kommt
// ]);
//
// // JOB mit neuer Signatur starten
// SimulateCertIssue::dispatch(
// userId: $userId,
// domain: $domain,
// taskKey: $taskKey
// );
//
// // Dashboard kann per "Initial-Fetch" sofort den Task laden
// session()->flash('task_key', $taskKey);
//
// // weiterleiten; die Echtzeit-Updates laufen unabhängig per WS
// return redirect()->route('dashboard');
// }
public function render()
{
return view('livewire.ping-button');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Ui\Dashboard;
use Livewire\Component;
class DomainsPanel extends Component
{
public array $domains = [
['name'=>'mail.example.com', 'status'=>'1:249 ausstehend', 'icon'=>'ph-envelope-simple', 'cert_days'=>8],
['name'=>'beispiel.de', 'status'=>'834 gesendet', 'icon'=>'ph-globe', 'cert_days'=>24],
['name'=>'user.test', 'status'=>'Ohne Aktivität', 'icon'=>'ph-at', 'cert_days'=>3],
];
protected function certBadge(?int $days): array
{
if ($days === null) {
return [
'class' => 'bg-white/8 text-white/50 border-white/10',
'icon' => 'ph-shield-warning-duotone',
'label' => '—',
'title' => 'Gültigkeit unbekannt',
];
}
if ($days <= 5) {
return [
'class' => 'bg-rose-500/10 text-rose-300 border-rose-400/30',
'icon' => 'ph-shield-x-duotone',
'label' => $days.'d',
'title' => "Zertifikat sehr kritisch (≤5 Tage)",
];
}
if ($days <= 20) {
return [
'class' => 'bg-amber-400/10 text-amber-300 border-amber-300/30',
'icon' => 'ph-shield-warning-duotone',
'label' => $days.'d',
'title' => "Zertifikat bald fällig (≤20 Tage)",
];
}
return [
'class' => 'bg-emerald-500/10 text-emerald-300 border-emerald-400/30',
'icon' => 'ph-shield-check-duotone',
'label' => $days.'d',
'title' => "Zertifikat ok (>20 Tage)",
];
}
public function getDomainsWithBadgesProperty(): array
{
return array_map(function ($d) {
$b = $this->certBadge($d['cert_days'] ?? null);
return $d + [
'cert_class' => $b['class'],
'cert_icon' => $b['icon'],
'cert_label' => $b['label'],
'cert_title' => $b['title'],
];
}, $this->domains);
}
public function render()
{
return view('livewire.ui.dashboard.domains-panel');
}
}

View File

@ -0,0 +1,292 @@
<?php
namespace App\Livewire\Ui\Dashboard;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class HealthCard extends Component
{
// Rohdaten
public array $services = [];
public array $meta = [];
// UI: Balken/Segmente
public int $barSegments = 24;
public array $cpuSeg = [];
public array $ramSeg = [];
// UI: Load & Uptime
public string $loadBadgeText = '';
public array $loadDots = [];
public array $uptimeChips = [];
public string $uptimeIcon = 'ph ph-clock';
// UI: Services kompakt
public array $servicesCompact = [];
// Storage
public ?int $diskUsedGb = null;
public ?int $diskTotalGb = null;
public ?int $diskPercent = null;
public ?int $diskFreeGb = null;
public array $diskSegments = [];
public int $diskInnerSize = 160; // grauer Kreis
public int $diskSegOuterRadius = 92; // Abstand der Ticks
public array $diskCenterText = ['percent'=>'%','label'=>'Speicher belegt'];
// Anzeige oben
public ?string $ramSummary = null;
public ?string $loadText = null;
public ?string $uptimeText = null;
public ?int $cpuPercent = null;
public ?int $ramPercent = null;
public ?string $updatedAtHuman = null;
public function mount(): void
{
$this->loadData();
}
public function loadData(): void
{
$this->services = Cache::get('health:services', []);
$this->meta = Cache::get('health:meta', []);
$this->hydrateSystem();
$this->hydrateDisk();
$this->hydrateUpdatedAt();
$this->decorateServicesCompact();
$this->decorateDisk();
}
public function render()
{
return view('livewire.ui.dashboard.health-card');
}
/* ---------------- Aufbereitung ---------------- */
protected function hydrateSystem(): void
{
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
// RAM %
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
}
// RAM Summary "used / total"
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
// Load (1/5/15)
$load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
$load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
$load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
if (is_array($loadMixed)) {
$vals = array_values($loadMixed);
$load1 ??= $this->numFloat($vals[0] ?? null);
$load5 ??= $this->numFloat($vals[1] ?? null);
$load15 ??= $this->numFloat($vals[2] ?? null);
}
$this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
// CPU %
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
if ($this->cpuPercent === null && $load1 !== null) {
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
$this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
}
// Uptime
$this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
if (!$this->uptimeText) {
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
}
// Segment-Balken
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
$this->ramSeg = $this->buildSegments($this->ramPercent);
// Load: Dots (1/5/15 relativ zu Kernen)
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
$ratio1 = $load1 !== null ? $load1 / $cores : null;
$ratio5 = $load5 !== null ? $load5 / $cores : null;
$ratio15 = $load15 !== null ? $load15 / $cores : null;
$this->loadBadgeText = $this->loadText ?? '';
$this->loadDots = [
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
];
// Uptime Chips
$chips = [];
if ($this->uptimeText) {
$d = $h = $m = null;
if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
}
$this->uptimeChips = $chips ?: [['v'=>'','u'=>'']];
}
protected function hydrateDisk(): void
{
$disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
$this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
$this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
// total/used berechnen, falls nicht geliefert
if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
$p = max(0, min(100, $this->diskPercent)) / 100;
$estTotal = (int) round($this->diskFreeGb / (1 - $p));
$this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
}
if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
$u = $this->diskTotalGb - $this->diskFreeGb;
$this->diskUsedGb = $u >= 0 ? $u : null;
}
}
protected function hydrateUpdatedAt(): void
{
$updated = $this->meta['updated_at'] ?? null;
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '';
} catch (\Throwable) { $this->updatedAtHuman = ''; }
}
protected function decorateServicesCompact(): void
{
$nameMap = [
'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
'127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
'127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
];
$this->servicesCompact = collect($this->services)
->map(function ($srv) use ($nameMap) {
$key = (string)($srv['name'] ?? '');
$ok = (bool) ($srv['ok'] ?? false);
$label = $nameMap[$key]['label'] ?? ucfirst($key);
$hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
return [
'label' => $label,
'hint' => $hint,
'ok' => $ok,
'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
'pillText' => $ok ? 'ok' : 'down',
'pillClass' => $ok
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
];
})
->values()
->all();
}
protected function decorateDisk(): void
{
$percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
$this->diskCenterText = [
'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '%',
'label' => 'Speicher belegt',
];
$count = 48;
$activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
$step = 360 / $count;
$activeClass = match (true) {
$percent >= 90 => 'bg-rose-400',
$percent >= 70 => 'bg-amber-300',
default => 'bg-emerald-400',
};
$this->diskSegments = [];
for ($i = 0; $i < $count; $i++) {
$angle = ($i * $step) - 90; // Start bei 12 Uhr
$this->diskSegments[] = [
'angle' => $angle,
'class' => $i < $activeN ? $activeClass : 'bg-white/16',
];
}
}
/* ---------------- Helpers ---------------- */
protected function pick(array $arr, array $keys)
{
foreach ($keys as $k) {
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
return $arr[$k];
}
}
return null;
}
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
{
if ($l1 !== null || $l5 !== null || $l15 !== null) {
$fmt = fn($x) => $x === null ? '' : number_format($x, 2);
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
}
return $fallback ?: null;
}
protected function secondsToHuman(int $s): string
{
$d = intdiv($s, 86400); $s %= 86400;
$h = intdiv($s, 3600); $s %= 3600;
$m = intdiv($s, 60);
if ($d > 0) return "{$d}d {$h}h";
if ($h > 0) return "{$h}h {$m}m";
return "{$m}m";
}
protected function toneByPercent(?int $p): string {
if ($p === null) return 'white';
if ($p >= 90) return 'rose';
if ($p >= 70) return 'amber';
return 'emerald';
}
protected function buildSegments(?int $percent): array {
$n = max(6, $this->barSegments);
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
$tone = $this->toneByPercent($percent);
$fillCls = match($tone) {
'rose' => 'bg-rose-500/80',
'amber' => 'bg-amber-400/80',
'emerald'=> 'bg-emerald-500/80',
default => 'bg-white/20',
};
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
}
protected function loadDotClass(?float $ratio): string {
if ($ratio === null) return 'bg-white/25';
if ($ratio >= 0.9) return 'bg-rose-500';
if ($ratio >= 0.7) return 'bg-amber-400';
return 'bg-emerald-400';
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Livewire\Ui\Dashboard;
use Livewire\Component;
class RecentLoginsTable extends Component
{
public array $rows = [
['user'=>'max.mustermann','ip'=>'182.163.1.123','time'=>'03.10.2023, 13:37'],
['user'=>'sabine','ip'=>'10.4.23.8','time'=>'03.10.2023, 12:03'],
['user'=>'admin','ip'=>'84.122.16.46','time'=>'03.10.2023, 11:34'],
];
public function render()
{
return view('livewire.ui.dashboard.recent-logins-table');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Ui\Dashboard;
use App\Models\Domain;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class TopBar extends Component
{
// Ausgabe-Props (Blade zeigt nur an)
public int $domainsCount = 0;
public int $certExpiring = 0; // Zertifikate <30 Tage
public int $warningsCount = 0; // Events
public int $pendingMigs = 0; // Updates -> offene Migrationen
public string $appVersion = '';
public bool $hasUpdate = false; // z.B. wenn remote Version > local (optional)
// Klassen für Farben (im Blade nur verwenden)
public string $domainsBadgeClass = 'bg-emerald-500/20 text-emerald-300';
public string $warningsBadgeClass = 'bg-emerald-500/20 text-emerald-300';
public string $updatesBadgeClass = 'bg-emerald-500/20 text-emerald-300';
public ?string $ipv4 = null;
public ?string $ipv6 = null;
public function mount(): void
{
// Domains + Zertifikate (passe an deinen Storage an)
$this->domainsCount = Domain::count(); // oder aus Cache/Repo
// Beispiel: Domain::select('cert_expires_at','cert_issuer')...
// -> hier simple Annahme: im Cache 'domains:certs' liegt [{domain, days_left, type}]
$certs = Cache::get('domains:certs', []); // Struktur: [['days_left'=>int,'type'=>'letsencrypt|selfsigned'], ...]
$this->certExpiring = collect($certs)->where('days_left', '<=', 30)->count();
// Warnungen
$events = Cache::get('events:recent', []);
$this->warningsCount = is_array($events) ? min(99, count($events)) : 0;
// Updates / Migrations
$meta = Cache::get('health:meta', []);
$this->pendingMigs = (int)($meta['pending_migs'] ?? 0);
$this->appVersion = (string)($meta['app_version'] ?? '');
$this->hasUpdate = $this->pendingMigs > 0; // oder echte Versionprüfung
// IPv4
$this->ipv4 = trim(shell_exec("hostname -I | awk '{print $1}'")) ?: '';
// IPv6 (optional)
$this->ipv6 = trim(shell_exec("hostname -I | awk '{print $2}'")) ?: '';
// Farben berechnen (nur einmal hier)
$this->domainsBadgeClass = $this->certExpiring === 0 ? 'bg-emerald-500/20 text-emerald-300'
: ($this->certExpiring <= 2 ? 'bg-amber-400/20 text-amber-300'
: 'bg-rose-500/20 text-rose-300');
$this->warningsBadgeClass = $this->warningsCount > 0 ? 'bg-rose-500/20 text-rose-300'
: 'bg-emerald-500/20 text-emerald-300';
$this->updatesBadgeClass = $this->hasUpdate ? 'bg-amber-400/20 text-amber-300'
: 'bg-emerald-500/20 text-emerald-300';
}
public function render()
{
return view('livewire.ui.dashboard.top-bar');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Ui\Domain;
use App\Models\Domain;
use App\Services\DnsRecordService;
use Livewire\Component;
class DomainDnsList extends Component
{
public Domain $domain;
public array $records = [];
// public function mount(int $domainId): void
// {
// $this->domain = Domain::findOrFail($domainId);
// $this->records = app(DnsRecordService::class)->buildForDomain($this->domain);
// }
public function openDnsModal(int $domainId): void
{
// wire-elements-modal: Modal öffnen und Parameter übergeben
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: [
'domainId' => $domainId,
]);
}
public function render()
{
return view('livewire.ui.domain.domain-dns-list', [
'domains' => Domain::orderBy('domain')->get(),
]);
}
}

View File

@ -0,0 +1,232 @@
<?php
namespace App\Livewire\Ui\Domain\Modal;
use App\Models\DkimKey;
use App\Models\Domain;
use App\Services\DnsRecordService;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use LivewireUI\Modal\ModalComponent;
class DomainDnsModal extends ModalComponent
{
public int $domainId;
public string $domainName = '';
public string $base = '';
public string $ttl = '3600';
public array $recordColors = [];
/** @var array<int,array<string,string|int|null>> */
public array $static = [];
/** @var array<int,array<string,string|int|null>> */
public array $dynamic = [];
public static function modalMaxWidth(): string { return '6xl'; }
public function mount(int $domainId): void
{
$this->recordColors = [
'A' => 'bg-cyan-500/20 text-cyan-300',
'AAAA' => 'bg-blue-500/20 text-blue-300',
'MX' => 'bg-emerald-500/20 text-emerald-300',
'CNAME' => 'bg-indigo-500/20 text-indigo-300',
'PTR' => 'bg-amber-500/20 text-amber-300',
'TXT' => 'bg-violet-500/20 text-violet-300',
'SRV' => 'bg-rose-500/20 text-rose-300',
'TLSA' => 'bg-red-500/20 text-red-300',
];
$d = Domain::findOrFail($domainId);
$this->domainName = $d->domain;
$ipv4 = $this->detectIPv4();
$ipv6 = $this->detectIPv6(); // kann null sein
$this->base = env('BASE_DOMAIN', 'example.com');
$mta = env('MTA_SUB', 'mx').'.'.$this->base; // mx.example.com
// --- Statische Infrastruktur (für alle Domains gleich) ---
$this->static = [
['type'=>'A','name'=>$mta,'value'=>$ipv4],
['type'=>'PTR','name'=>$this->ptrFromIPv4($ipv4),'value'=>$mta],
];
if ($ipv6) {
$this->static[] = ['type'=>'AAAA','name'=>$mta,'value'=>$ipv6];
$this->static[] = ['type'=>'PTR', 'name'=>$this->ptrFromIPv6($ipv6),'value'=>$mta];
}
if ($tlsa = config('mailwolt.tlsa')) {
$this->static[] = ['type'=>'TLSA','name'=>"_25._tcp.$mta",'value'=>$tlsa];
}
$this->static[] = ['type'=>'MX','name'=>$this->domainName,'value'=>"10 $mta."];
// --- Domain-spezifisch ---
$spf = 'v=spf1 mx a -all';
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
$dkim = DB::table('dkim_keys')
->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
$selector = $dkim ? $dkim->selector : 'mwl1';
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt),'v=')
? 'v=DKIM1; k=rsa; p='.$dkim->public_key_txt
: ($dkim->public_key_txt ?? 'v=DKIM1; k=rsa; p=');
$this->dynamic = [
['type'=>'TXT','name'=>$this->domainName, 'value'=>$spf, 'helpLabel'=>'SPF Record Syntax','helpUrl'=>'http://www.open-spf.org/SPF_Record_Syntax/'],
['type'=>'TXT','name'=>"_dmarc.{$this->domainName}", 'value'=>$dmarc, 'helpLabel'=>'DMARC Assistant','helpUrl'=>'https://www.kitterman.com/dmarc/assistant.html'],
['type'=>'TXT','name'=>$dkimHost, 'value'=>$dkimTxt, 'helpLabel'=>'DKIM Inspector','helpUrl'=>'https://dkimvalidator.com/'],
];
}
private function detectIPv4(): string
{
// robust & ohne env
$out = @shell_exec("ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'");
$ip = trim((string)$out);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
$ip = trim($_SERVER['SERVER_ADDR'] ?? '');
return $ip ?: '203.0.113.10'; // Fallback Demo
}
private function detectIPv6(): ?string
{
$out = @shell_exec("ip -6 route get 2001:4860:4860::8888 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'");
$ip = trim((string)$out);
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? $ip : null;
}
private function ptrFromIPv4(string $ip): string
{
$p = array_reverse(explode('.', $ip));
return implode('.', $p).'.in-addr.arpa';
}
private function ptrFromIPv6(string $ip): string
{
$expanded = strtolower(inet_ntop(inet_pton($ip)));
$hex = str_replace(':', '', $expanded);
return implode('.', array_reverse(str_split($hex))).'.ip6.arpa';
}
public function render()
{
return view('livewire.ui.domain.modal.domain-dns-modal');
}
// public int $domainId;
// public Domain $domain;
//
// public array $records = [];
//
// public function mount(int $domainId): void
// {
// $this->domainId = $domainId;
// $this->domain = Domain::findOrFail($domainId);
//
// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen:
// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP');
// $mxHost = config('mailwolt.mx_fqdn', 'mx.' . $this->domain->domain);
// $selector = optional(
// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
// )->selector ?? 'mwl1';
//
// $dkimTxt = optional(
// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
// )->public_key_txt ?? 'DKIM_PUBLIC_KEY';
//
// $this->records = [
// [
// 'type' => 'A',
// 'host' => $this->domain->domain,
// 'value' => $serverIp,
// 'ttl' => 3600,
// ],
// [
// 'type' => 'MX',
// 'host' => $this->domain->domain,
// 'value' => "10 {$mxHost}.",
// 'ttl' => 3600,
// ],
// [
// 'type' => 'TXT',
// 'host' => $this->domain->domain,
// 'value' => 'v=spf1 mx a -all',
// 'ttl' => 3600,
// ],
// [
// 'type' => 'TXT',
// 'host' => "{$selector}._domainkey." . $this->domain->domain,
// // komplette, fertige TXT-Payload (aus deiner dkim_keys.public_key_txt Spalte)
// 'value' => "v=DKIM1; k=rsa; p={$dkimTxt}",
// 'ttl' => 3600,
// ],
// [
// 'type' => 'TXT',
// 'host' => "_dmarc." . $this->domain->domain,
// 'value' => "v=DMARC1; p=none; rua=mailto:dmarc@" . $this->domain->domain . "; pct=100",
// 'ttl' => 3600,
// ],
// ];
// }
//
// public static function modalMaxWidth(): string
// {
// return '4xl'; // schön breit
// }
//
// public function render()
// {
// return view('livewire.ui.domain.modal.domain-dns-modal');
// }
// public Domain $domain;
//
// public static function modalMaxWidth(): string
// {
// return '3xl';
// }
//
// public function mount(int $domainId): void
// {
// $this->domain = Domain::with(['dkimKeys' /* falls Relationen existieren */])->findOrFail($domainId);
// }
//
// public function render()
// {
// // Hier kannst du die Records vorbereiten (SPF/DMARC/DKIM)
// $records = [
// [
// 'type' => 'TXT',
// 'host' => $this->domain->domain,
// 'value'=> $this->domain->spf_record ?? 'v=spf1 mx a -all',
// 'ttl' => 3600,
// ],
// [
// 'type' => 'TXT',
// 'host' => '_dmarc.' . $this->domain->domain,
// 'value'=> $this->domain->dmarc_record ?? 'v=DMARC1; p=none; rua=mailto:dmarc@' . $this->domain->domain . '; pct=100',
// 'ttl' => 3600,
// ],
// // DKIM (falls vorhanden)
// // Beispiel: nimm den aktiven Key oder den ersten
// ];
//
// $dkim = optional($this->domain->dkimKeys()->where('is_active', true)->first() ?? $this->domain->dkimKeys()->first());
// if ($dkim && $dkim->selector) {
// $records[] = [
// 'type' => 'TXT',
// 'host' => "{$dkim->selector}._domainkey." . $this->domain->domain,
// 'value'=> "v=DKIM1; k=rsa; p={$dkim->public_key_txt}",
// 'ttl' => 3600,
// ];
// }
//
// return view('livewire.ui.domain.modal.domain-dns-modal', [
// 'records' => $records,
// ]);
// }
// public function render()
// {
// return view('livewire.ui.domain.modal.domain-dns-modal');
// }
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Livewire\Ui\Security;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\Rule;
use Livewire\Component;
class AccountSecurityForm extends Component
{
// “Company Policy” hier nur Platzhalter; speicherst du später global
public bool $require2fa = false;
public bool $allowTotp = true;
public bool $allowMail = true;
public string $name = '';
public string $username = '';
public string $maskedEmail = ''; // nur Anzeige links
public string $emailInput = ''; // editierbar rechts
// aktueller Benutzer
public bool $userHas2fa = false;
public bool $totpActive = false;
public bool $email2faActive = false;
// Profil
public string $email = '';
// Passwort
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
// E-Mail-2FA
public string $email_current = '';
public string $email_new = '';
protected $listeners = [
'totp-enabled' => 'onTotpEnabled',
'totp-disabled' => 'onTotpDisabled',
'email2fa-enabled' => 'onEmail2faEnabled',
'email2fa-disabled' => 'onEmail2faDisabled',
];
public function mount(): void
{
$u = Auth::user();
$this->name = (string)($u->name ?? '');
$this->username = (string)($u->username ?? '');
$this->email = (string)($u->email ?? '');
$this->maskedEmail = $this->maskEmail($u->email ?? '');
$this->emailInput = (string)($u->email ?? '');
$this->totpActive = (bool) optional($u->twoFactorMethod('totp'))->enabled;
$this->email2faActive = (bool) optional($u->twoFactorMethod('email'))->enabled;
$this->userHas2fa = $u->twoFactorEnabled();
}
protected function rules(): array
{
$userId = Auth::id();
return [
'require2fa' => ['boolean'],
'allowTotp' => ['boolean'],
'allowMail' => ['boolean'],
'username' => ['required','string','min:3','max:80'],
'email' => [
'required','email', Rule::unique('users','email')->ignore($userId)
],
'current_password' => ['nullable','string'],
'new_password' => ['nullable', Password::min(10)->mixedCase()->numbers()],
'new_password_confirmation' => ['same:new_password'],
'email_current' => ['required','email','in:'.(auth()->user()->email ?? '')],
'email_new' => ['required','email','different:email_current','max:255','unique:users,email'],
];
}
/* ===== Aktionen ===== */
public function save2faPolicy(): void
{
$this->validate([
'require2fa' => ['boolean'],
'allowTotp' => ['boolean'],
'allowMail' => ['boolean'],
]);
// TODO: In einer Settings-Tabelle persistieren
$this->dispatch('toast', body: '2FA-Richtlinie gespeichert.');
}
// public function toggleUser2fa(): void
// {
// // “Globaler” Toggle wenn nichts aktiv, öffnet der Nutzer die Modals.
// // Hier nur eine UX-Rückmeldung:
// if (! $this->userHas2fa) {
// $this->dispatch('toast', body: 'Wähle und richte zuerst eine Methode ein (TOTP oder E-Mail).');
// return;
// }
//
// // Wenn mindestens 1 Methode aktiv ist → komplett aus
// $u = Auth::user();
// $u->twoFactorMethods()->update(['enabled' => false, 'confirmed_at' => null, 'secret' => null]);
// $this->totpActive = $this->email2faActive = $this->userHas2fa = false;
//
// $this->dispatch('toast', body: '2FA deaktiviert.');
// }
protected function maskEmail(string $email): string {
if (!str_contains($email, '@')) return $email;
[$l, $d] = explode('@', $email, 2);
$l = strlen($l) <= 2 ? substr($l, 0, 1).'…' : substr($l,0,1).str_repeat('•', max(1, strlen($l)-3)).substr($l,-2);
$d = preg_replace('/(^.).*(\..{1,4}$)/', '$1…$2', $d);
return "{$l}@{$d}";
}
public function saveProfile(): void
{
$this->validate([
'username' => ['required','string','min:3','max:80'],
'email' => ['required','email', Rule::unique('users','email')->ignore(Auth::id())],
]);
$u = Auth::user();
$u->name = $this->username;
$u->email = $this->email;
$u->save();
$this->dispatch('toast', body: 'Profil gespeichert.');
}
public function changePassword(): void
{
if ($this->new_password === '') {
$this->dispatch('toast', body: 'Kein neues Passwort eingegeben.');
return;
}
$this->validate([
'current_password' => ['required','string'],
'new_password' => ['required', Password::min(10)->mixedCase()->numbers()],
'new_password_confirmation' => ['required','same:new_password'],
]);
$u = Auth::user();
if (! Hash::check($this->current_password, $u->password)) {
$this->addError('current_password','Aktuelles Passwort ist falsch.');
return;
}
$u->password = Hash::make($this->new_password);
$u->save();
$this->reset(['current_password','new_password','new_password_confirmation']);
$this->dispatch('toast', body: 'Passwort aktualisiert.');
}
public function changeEmail(): void
{
$this->validate(['email_current','email_new']);
$user = auth()->user();
$old = $user->email;
// 1) neue E-Mail in „pending“ Tabelle/Spalte ablegen (z. B. users.email_pending + token)
// 2) Verifizierungs-Mail an $this->email_new senden (mit Token)
// 3) Anzeigen/Toast
$this->dispatch('toast', body: 'Bestätigungslink an die neue E-Mail gesendet.');
// Felder leeren
$this->reset(['email_current', 'email_new', 'email_new_confirmation']);
}
public function onTotpEnabled() { $this->totpActive = true; $this->userHas2fa = true; }
public function onTotpDisabled() { $this->totpActive = false; $this->userHas2fa = $this->email2faActive; }
public function onEmail2faEnabled() { $this->email2faActive = true; $this->userHas2fa = true; }
public function onEmail2faDisabled(){ $this->email2faActive = false; $this->userHas2fa = $this->totpActive; }
public function render()
{
return view('livewire.ui.security.account-security-form');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
class AuditLogsTable extends Component
{
public function render()
{
return view('livewire.ui.security.audit-logs-table');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Ui\Security;
use Illuminate\Validation\Rule;
use Livewire\Component;
class Auth2faForm extends Component
{
/** 2FA global erzwingen (Benutzer müssen 2FA einrichten) */
public bool $enforce = false;
public bool $allow_totp = false; // <- vorher true
public bool $allow_email = false; // <- vorher true
protected function rules(): array
{
return [
'enforce' => ['boolean'],
'allow_totp' => ['boolean'],
'allow_email' => ['boolean'],
];
}
public function save(): void
{
$this->validate();
// TODO: Werte persistieren (DB/Settings)
// settings()->put('security.2fa', [
// 'enforce' => $this->enforce,
// 'allow_totp' => $this->allow_totp,
// 'allow_email' => $this->allow_email,
// ]);
$this->dispatch('toast', body: '2FA-Einstellungen gespeichert.');
}
public function render()
{
return view('livewire.ui.security.auth2fa-form');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
class Fail2BanForm extends Component
{
public function render()
{
return view('livewire.ui.security.fail2-ban-form');
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Ui\Security\Modal;
use App\Livewire\Ui\Security\Notification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use LivewireUI\Modal\ModalComponent;
class Email2faSetupModal extends ModalComponent
{
public string $code = '';
public bool $alreadyActive = false;
public int $cooldown = 0; // Sek. bis zum nächsten Versand
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$u = Auth::user();
$this->alreadyActive = (bool) ($u->two_factor_email_enabled ?? false);
}
public function sendMail(): void
{
if ($this->cooldown > 0) return;
$u = Auth::user();
$pin = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// 10 Minuten gültig (Cache-Key pro User)
Cache::put("email-2fa:setup:{$u->id}", password_hash($pin, PASSWORD_DEFAULT), now()->addMinutes(10));
// sehr einfache Notification ersetze durch Mailables/Markdown:
Notification::route('mail', $u->email)->notify(new \App\Notifications\PlainTextNotification(
subject: 'Dein E-Mail-2FA Code',
lines: [
"Dein Bestätigungscode lautet: **{$pin}**",
'Der Code ist 10 Minuten gültig.',
],
));
$this->cooldown = 30;
$this->dispatch('toast', body: 'Code gesendet.');
$this->dispatch('tick-down'); // optionaler JS-Timer
}
public function verifyAndEnable(): void
{
$u = Auth::user();
$hash = Cache::get("email-2fa:setup:{$u->id}");
if (!$hash || !password_verify(preg_replace('/\D/', '', $this->code), $hash)) {
$this->dispatch('toast', body: 'Code ungültig oder abgelaufen.');
return;
}
$u->two_factor_email_enabled = true; // bool Spalte auf users
$u->save();
Cache::forget("email-2fa:setup:{$u->id}");
$this->dispatch('email2fa-enabled');
$this->dispatch('toast', body: 'E-Mail-2FA aktiviert.');
$this->dispatch('closeModal');
}
public function disable(): void
{
$u = Auth::user();
$u->two_factor_email_enabled = false;
$u->save();
$this->dispatch('email2fa-disabled');
$this->dispatch('toast', body: 'E-Mail-2FA deaktiviert.');
$this->dispatch('closeModal');
}
public function render()
{
return view('livewire.ui.security.modal.email2fa-setup-modal');
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire\Ui\Security\Modal;
use App\Models\TwoFactorRecoveryCode;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
class RecoveryCodesModal extends ModalComponent
{
/** Plain Codes werden nur direkt nach Generierung gezeigt */
public array $plainCodes = [];
/** Zeigt an, dass in der DB bereits Codes existieren */
public bool $hasExisting = false;
/** Ob dieser View-Zyklus gerade neue Codes erzeugt hat (Download erlaubt) */
public bool $justGenerated = false;
/** Anzahl Codes & Länge */
public int $count = 10; // wie viele Codes
public int $length = 10; // wie lang (ohne Leerzeichen/Trenner)
public static function modalMaxWidth(): string
{
// Stelle sicher, dass Tailwind die Klasse kennt (safelisten, falls nötig):
// 'sm:max-w-md' / 'md:max-w-xl' etc. wir bleiben hier beim „md“ Preset
return 'md';
}
public function mount(): void
{
$user = Auth::user();
$this->hasExisting = TwoFactorRecoveryCode::where('user_id', $user->id)->exists();
// Wichtig: $plainCodes bleiben leer, außer nach generate().
// So werden bereits existierende Codes nie erneut gezeigt.
$this->plainCodes = [];
$this->justGenerated = false;
}
/** Erzeugt/rotiert Codes und zeigt sie EINMALIG an */
public function generate(): void
{
$user = Auth::user();
// Alte Codes verwerfen/überschreiben
TwoFactorRecoveryCode::where('user_id', $user->id)->delete();
// Neue Codes erzeugen (nur hier im Speicher im Klartext zeigen)
$new = [];
for ($i = 0; $i < $this->count; $i++) {
// 10 zufällige Großbuchstaben/Ziffern
$raw = strtoupper(Str::random($this->length));
$new[] = $raw;
TwoFactorRecoveryCode::create([
'user_id' => $user->id,
'code_hash' => hash('sha256', $raw),
'used_at' => null,
]);
}
// Einmalige Anzeige
$this->plainCodes = $new;
$this->hasExisting = true;
$this->justGenerated = true;
}
/** Optional: E-Mail leicht maskieren für die Dateiüberschrift */
protected function maskEmail(?string $email): string
{
if (!$email || !str_contains($email, '@')) return '—';
[$name, $domain] = explode('@', $email, 2);
$nameMasked = Str::substr($name, 0, 1) . str_repeat('•', max(1, strlen($name) - 2)) . Str::substr($name, -1);
$domainMasked = preg_replace('/(^.).*(?=\.)/u', '$1•••', $domain); // z.B. g•••gle.com
return "{$nameMasked}@{$domainMasked}";
}
/** Formatiert die TXT-Datei hübsch */
protected function buildTxtContent(array $codes): string
{
$app = config('app.name', 'App');
$user = Auth::user();
$who = $this->maskEmail($user->email ?? '');
$now = Carbon::now()->toDateTimeString();
// Codes als "ABCDE-FGHIJ" + Nummerierung 01), 02), …
$lines = [];
foreach (array_values($codes) as $i => $raw) {
// in 5er Blöcke mit Bindestrich
$pretty = trim(chunk_split(strtoupper($raw), 5, '-'), '-');
$nr = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT);
$lines[] = "{$nr}) {$pretty}";
}
$body = implode(PHP_EOL, $lines);
$header = <<<TXT
{$app} Recovery Codes
Benutzer: {$who}
Erstellt: {$now}
Hinweise:
Jeder Code kann genau einmal verwendet werden.
Bewahre diese Datei offline & sicher auf (z. B. ausdrucken).
Teile diese Codes niemals mit Dritten.
Codes:
{$body}
TXT;
return $header;
}
/** Nur direkt nach Generierung möglich schöner Download-Content + Dateiname */
public function downloadTxt()
{
if (!$this->justGenerated || empty($this->plainCodes)) {
$this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.');
return null;
}
$app = Str::slug(config('app.name', 'app'));
$date = now()->format('Y-m-d_His');
$filename = "{$app}-recovery-codes_{$date}.txt";
$content = $this->buildTxtContent($this->plainCodes);
return response()->streamDownload(function () use ($content) {
echo $content . PHP_EOL; // final newline
}, $filename, [
'Content-Type' => 'text/plain; charset=UTF-8',
]);
}
/** Nur direkt nach Generierung möglich */
// public function downloadTxt()
// {
// if (!$this->justGenerated || empty($this->plainCodes)) {
// // nichts zurückgeben => Livewire bleibt im Modal
// $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.');
// return null;
// }
//
// $filename = 'recovery-codes.txt';
// $content = implode(PHP_EOL, $this->plainCodes);
//
// return response()->streamDownload(function () use ($content) {
// echo $content;
// }, $filename);
// }
public function render()
{
return view('livewire.ui.security.modal.recovery-codes-modal');
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Ui\Security\Modal;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Vectorface\GoogleAuthenticator;
class TotpSetupModal extends ModalComponent
{
public string $secret;
public string $otp = '';
public string $qrPng; // PNG Data-URI
public bool $alreadyActive = false;
// << Wichtig: je Modal eigene Breite >>
public static function modalMaxWidth(): string
{
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
return 'xl'; // kompakt für TOTP
}
public function mount(): void
{
$user = Auth::user();
$ga = new GoogleAuthenticator();
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
$this->secret = $user->totp_secret ?: $ga->createSecret();
$issuer = config('app.name', 'MailWolt');
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
}
public function verifyAndEnable(string $code): void
{
$code = preg_replace('/\D/', '', $code ?? '');
if (strlen($code) !== 6) {
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
return;
}
$ga = new GoogleAuthenticator();
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
if (!$ok) {
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
return;
}
$user = Auth::user();
$user->totp_secret = $this->secret;
$user->two_factor_enabled = true;
$user->save();
$this->dispatch('totp-enabled');
$this->dispatch('toast', body: 'TOTP aktiviert.');
$this->dispatch('closeModal');
}
public function disable(): void
{
$user = Auth::user();
$user->totp_secret = null;
$user->two_factor_enabled = false;
$user->save();
$this->dispatch('totp-disabled');
$this->dispatch('toast', body: 'TOTP deaktiviert.');
$this->dispatch('closeModal');
}
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
public function changePassword() { /* validate & set */ }
public function changeEmail() { /* validate, send verify link, etc. */ }
public function openRecovery() { /* optional modal or page */ }
public function logoutOthers() { /* … */ }
public function logoutSession(string $id) { /* … */ }
public function render()
{
return view('livewire.ui.security.modal.totp-setup-modal');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
class RspamdForm extends Component
{
public function render()
{
return view('livewire.ui.security.rspamd-form');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
class SslCertificatesTable extends Component
{
public function render()
{
return view('livewire.ui.security.ssl-certificates-table');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
class TlsCiphersForm extends Component
{
public function render()
{
return view('livewire.ui.security.tls-ciphers-form');
}
}

View File

@ -0,0 +1,294 @@
<?php
namespace App\Livewire\Ui\System;
use DateTimeImmutable;
use Illuminate\Validation\Rule;
use Livewire\Component;
class DomainsSslForm extends Component
{
/* ========= Basis & Hosts ========= */
public string $base_domain = 'example.com';
// nur Subdomain-Teile (ohne Punkte/Protokoll)
public string $ui_sub = 'mail';
public string $webmail_sub = 'webmail';
public string $mta_sub = 'mx';
/* ========= TLS / Redirect ========= */
public bool $force_https = true;
public bool $hsts = true;
public bool $auto_renew = true;
/* ========= ACME ========= */
public string $acme_contact = 'admin@example.com';
public string $acme_env = 'staging'; // staging|production
public string $acme_challenge = 'http01'; // http01|dns01
public array $acme_envs = [
['value' => 'staging', 'label' => 'Lets Encrypt (Staging)'],
['value' => 'production', 'label' => 'Lets Encrypt (Production)'],
];
public array $acme_challenges = [
['value' => 'http01', 'label' => 'HTTP-01 (empfohlen)'],
['value' => 'dns01', 'label' => 'DNS-01 (Wildcard/komplex)'],
];
/* ========= MTA-STS ========= */
public bool $mta_sts_enabled = false;
public string $mta_sts_mode = 'enforce'; // testing|enforce|none
public int $mta_sts_max_age = 180; // Tage (Empfehlung: 180)
public array $mta_sts_mx = ['*.example.com']; // neue Liste der mx-Ziele (mind. eins)
// public bool $mta_sts_include_subdomains = false;
// public string $mta_sts_serve_as = 'static'; // static|route
/* ========= Zertifikatsliste (Demo) ========= */
public array $hosts = [
['id' => 1, 'host' => 'mail.example.com', 'status' => 'ok', 'expires_at' => '2025-12-01'],
['id' => 2, 'host' => 'webmail.example.com', 'status' => 'expiring', 'expires_at' => '2025-10-20'],
['id' => 3, 'host' => 'mx.example.com', 'status' => 'missing', 'expires_at' => null],
];
/* ========= Computed (Previews) ========= */
public function getUiHostProperty(): string
{
return "{$this->ui_sub}.{$this->base_domain}";
}
public function getWebmailHostProperty(): string
{
return "{$this->webmail_sub}.{$this->base_domain}";
}
public function getMtaHostProperty(): string
{
return "{$this->mta_sub}.{$this->base_domain}";
}
public function getMtaStsTxtNameProperty(): string
{
return "_mta-sts.{$this->base_domain}";
}
public function getMtaStsTxtValueProperty(): string
{
// Beim tatsächlichen Schreiben bitte echten Timestamp/Version setzen.
return 'v=STSv1; id=YYYYMMDD';
}
/* ========= Validation ========= */
protected function rules(): array
{
return [
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'force_https' => ['boolean'],
'hsts' => ['boolean'],
'auto_renew' => ['boolean'],
'acme_contact' => ['required','email'],
'acme_env' => ['required', Rule::in(['staging','production'])],
'acme_challenge' => ['required', Rule::in(['http01','dns01'])],
'mta_sts_enabled' => ['boolean'],
'mta_sts_mode' => ['required', Rule::in(['testing','enforce','none'])],
'mta_sts_max_age' => ['integer','min:1','max:31536000'],
'mta_sts_mx' => ['array','min:1'],
'mta_sts_mx.*' => ['required','string','min:1'], // einfache Prüfung; optional regex auf Hostnamen
// 'mta_sts_include_subdomains' => ['boolean'],
// 'mta_sts_serve_as' => ['required', Rule::in(['static','route'])],
];
}
/* ========= Lifecycle ========= */
public function updated($prop): void
{
// live normalisieren (keine Leerzeichen, keine Protokolle, nur [a-z0-9-])
if (in_array($prop, ['base_domain','ui_sub','webmail_sub','mta_sub'], true)) {
$this->base_domain = $this->normalizeDomain($this->base_domain);
$this->ui_sub = $this->sanitizeLabel($this->ui_sub);
$this->webmail_sub = $this->sanitizeLabel($this->webmail_sub);
$this->mta_sub = $this->sanitizeLabel($this->mta_sub);
}
}
protected function loadMtaStsFromFileIfPossible(): void
{
$file = public_path('.well-known/mta-sts.txt');
if (!is_file($file)) return;
$txt = file_get_contents($file);
if (!$txt) return;
$lines = preg_split('/\r\n|\r|\n/', $txt);
$mx = [];
foreach ($lines as $line) {
[$k,$v] = array_pad(array_map('trim', explode(':', $line, 2)), 2, null);
if (!$k) continue;
if (strcasecmp($k,'version') === 0) { /* ignore */ }
if (strcasecmp($k,'mode') === 0 && $v) $this->mta_sts_mode = strtolower($v);
if (strcasecmp($k,'mx') === 0 && $v) $mx[] = $v;
if (strcasecmp($k,'max_age') === 0 && is_numeric($v)) {
$days = max(1, (int)round(((int)$v)/86400));
$this->mta_sts_max_age = $days;
}
}
if ($mx) $this->mta_sts_mx = $mx;
$this->mta_sts_enabled = true;
}
/* ========= Actions ========= */
public function saveDomains(): void
{
$this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']);
// TODO: persist
$this->dispatch('toast', body: 'Domains gespeichert.');
}
public function saveTls(): void
{
$this->validate(['force_https','hsts','auto_renew']);
// TODO: persist
$this->dispatch('toast', body: 'TLS/Redirect gespeichert.');
}
public function saveAcme(): void
{
$this->validate(['acme_contact','acme_env','acme_challenge','auto_renew']);
// TODO: persist (Kontakt, Env, Challenge, ggf. Auto-Renew Flag)
$this->dispatch('toast', body: 'ACME-Einstellungen gespeichert.');
}
public function saveMtaSts(): void
{
$this->validate([
'mta_sts_enabled','mta_sts_mode','mta_sts_max_age','mta_sts_mx','mta_sts_mx.*'
]);
// TODO: Settings persistieren (z.B. in einer settings-Tabelle)
// Datei erzeugen/löschen
$wellKnownDir = public_path('.well-known');
if (!is_dir($wellKnownDir)) {
@mkdir($wellKnownDir, 0755, true);
}
$file = $wellKnownDir.'/mta-sts.txt';
if (!$this->mta_sts_enabled) {
// Policy deaktiviert → Datei entfernen, falls vorhanden
if (is_file($file)) @unlink($file);
$this->dispatch('toast', body: 'MTA-STS deaktiviert und Datei entfernt.');
return;
}
$seconds = $this->mta_sts_max_age * 86400;
$mxLines = collect($this->mta_sts_mx)
->filter(fn($v) => trim($v) !== '')
->map(fn($v) => "mx: ".trim($v))
->implode("\n");
// Policy-Text (Plaintext)
$text = "version: STSv1\n".
"mode: {$this->mta_sts_mode}\n".
"{$mxLines}\n".
"max_age: {$seconds}\n";
file_put_contents($file, $text);
$this->dispatch('toast', body: 'MTA-STS gespeichert & Datei aktualisiert.');
}
public function addMx(): void
{
$suggest = '*.' . $this->base_domain;
$this->mta_sts_mx[] = $suggest;
}
public function removeMx(int $index): void
{
if (isset($this->mta_sts_mx[$index])) {
array_splice($this->mta_sts_mx, $index, 1);
}
if (count($this->mta_sts_mx) === 0) {
$this->mta_sts_mx = ['*.' . $this->base_domain];
}
}
public function requestCertificate(int $hostId): void
{
// TODO: ACME ausstellen für Host-ID
$this->dispatch('toast', body: 'Zertifikat wird angefordert …');
}
public function renewCertificate(int $hostId): void
{
// TODO
$this->dispatch('toast', body: 'Zertifikat wird erneuert …');
}
public function revokeCertificate(int $hostId): void
{
// TODO
$this->dispatch('toast', body: 'Zertifikat wird widerrufen …');
}
/* ========= Helpers ========= */
protected function normalizeDomain(string $d): string
{
$d = strtolower(trim($d));
$d = preg_replace('/^https?:\/\//', '', $d);
return rtrim($d, '.');
}
protected function sanitizeLabel(string $s): string
{
return strtolower(preg_replace('/[^a-z0-9-]/i', '', $s ?? ''));
}
public function daysLeft(?string $iso): ?int
{
if (!$iso) return null;
try {
$d = (new DateTimeImmutable($iso))->setTime(0,0);
$now = (new DateTimeImmutable('today'));
return (int)$now->diff($d)->format('%r%a');
} catch (\Throwable) {
return null;
}
}
public function statusBadge(array $row): array
{
$days = $this->daysLeft($row['expires_at']);
if ($row['status'] === 'missing') {
return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => 'kein Zertifikat'];
}
if ($days === null) {
return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => '—'];
}
if ($days <= 5) return ['class' => 'bg-rose-500/10 text-rose-300 border-rose-400/30', 'text' => "{$days}d"];
if ($days <= 20) return ['class' => 'bg-amber-400/10 text-amber-300 border-amber-300/30','text' => "{$days}d"];
return ['class' => 'bg-emerald-500/10 text-emerald-300 border-emerald-400/30','text' => "{$days}d"];
}
public function render()
{
return view('livewire.ui.system.domains-ssl-form');
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
class GeneralForm extends Component
{
public string $instance_name = 'MailWolt'; // readonly Anzeige
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
public int $session_timeout = 120; // Minuten
// Beispieldaten später aus Config/DB füllen
public array $locales = [
['value' => 'de', 'label' => 'Deutsch'],
['value' => 'en', 'label' => 'English'],
];
public array $timezones = [
'Europe/Berlin','UTC','Europe/Vienna','Europe/Zurich','America/New_York','Asia/Tokyo'
];
protected function rules(): array
{
return [
'locale' => ['required', Rule::in(collect($this->locales)->pluck('value')->all())],
'timezone' => ['required', Rule::in($this->timezones)],
'session_timeout' => ['required','integer','min:5','max:1440'],
];
}
public function save(): void
{
$this->validate();
// TODO: persist to settings storage (DB/Config)
// e.g. Settings::set('app.locale', $this->locale);
// Settings::set('app.timezone', $this->timezone);
// Settings::set('session.timeout', $this->session_timeout);
session()->flash('saved', true);
$this->dispatch('toast', body: 'Einstellungen gespeichert.');
}
public function render()
{
return view('livewire.ui.system.general-form');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
class SecurityForm extends Component
{
public function render()
{
return view('livewire.ui.system.security-form');
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\Setting;
use Livewire\Component;
class SettingsForm extends Component
{
// Tab-Steuerung (optional per Alpine, hier aber auch als Prop)
public string $tab = 'general';
// Allgemein
public string $instance_name = ''; // readonly
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
public ?int $session_timeout = 120; // Minuten
// Domains & SSL
public string $ui_domain = '';
public string $mail_domain = '';
public string $webmail_domain = '';
public bool $ssl_auto = true;
// Sicherheit
public bool $twofa_enabled = false;
public ?int $rate_limit = 5; // Versuche / Minute
public ?int $password_min = 10;
protected function rules(): array
{
return [
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
'session_timeout' => 'nullable|integer|min:5|max:1440',
'ui_domain' => 'nullable|string|max:190',
'mail_domain' => 'nullable|string|max:190',
'webmail_domain' => 'nullable|string|max:190',
'ssl_auto' => 'boolean',
'twofa_enabled' => 'boolean',
'rate_limit' => 'nullable|integer|min:1|max:100',
'password_min' => 'nullable|integer|min:6|max:128',
];
}
public function mount(): void
{
// Anzeige-Name der Instanz lies was du hast (z.B. config('app.name'))
$this->instance_name = (string) (config('app.name') ?? 'MailWolt');
// Laden aus unserem einfachen Store
$this->locale = Setting::get('locale', $this->locale);
$this->timezone = Setting::get('timezone', $this->timezone);
$this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout);
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
$this->mail_domain = Setting::get('mail_domain', $this->mail_domain);
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
$this->ssl_auto = (bool) Setting::get('ssl_auto', $this->ssl_auto);
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
}
public function save(): void
{
$this->validate();
Setting::put('locale', $this->locale);
Setting::put('timezone', $this->timezone);
Setting::put('session_timeout', $this->session_timeout);
Setting::put('ui_domain', $this->ui_domain);
Setting::put('mail_domain', $this->mail_domain);
Setting::put('webmail_domain', $this->webmail_domain);
Setting::put('ssl_auto', $this->ssl_auto);
Setting::put('twofa_enabled', $this->twofa_enabled);
Setting::put('rate_limit', $this->rate_limit);
Setting::put('password_min', $this->password_min);
$this->dispatch('toast', type: 'success', message: 'Einstellungen gespeichert'); // optional
}
public function render()
{
return view('livewire.ui.system.settings-form');
}
}

View File

@ -15,4 +15,9 @@ class DkimKey extends Model
public function domain(): BelongsTo {
return $this->belongsTo(Domain::class);
}
public function asTxtValue(): string {
return 'v=DKIM1; k=rsa; p=' . $this->public_key_txt;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DmarcRecord extends Model
{
protected $fillable = ['domain_id','policy','rua','ruf','pct','record_txt','is_active'];
protected $casts = ['pct' => 'int','is_active' => 'boolean'];
public function domain(): BelongsTo { return $this->belongsTo(Domain::class); }
public function renderTxt(): string {
$parts = ['v=DMARC1', "p={$this->policy}", "pct={$this->pct}"];
if ($this->rua) $parts[] = "rua={$this->rua}";
if ($this->ruf) $parts[] = "ruf={$this->ruf}";
return implode('; ', $parts);
}
}

View File

@ -7,18 +7,32 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Domain extends Model
{
protected $fillable = ['domain','is_active'];
protected $fillable = ['domain','is_active','is_system'];
protected $casts = ['is_active' => 'bool'];
public function mailboxes(): HasMany {
return $this->hasMany(MailUser::class);
}
public function aliases(): HasMany {
public function aliases(): HasMany
{
return $this->hasMany(MailAlias::class);
}
public function dkimKeys(): HasMany {
public function users(): HasMany
{
return $this->hasMany(MailUser::class);
}
public function dkimKeys(): HasMany
{
return $this->hasMany(DkimKey::class);
}
public function spf(): HasMany
{
return $this->hasMany(SpfRecord::class);
}
public function dmarc(): HasMany
{
return $this->hasMany(DmarcRecord::class);
}
}

View File

@ -7,16 +7,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MailUser extends Model
{
protected $table = 'mail_users';
// protected $table = 'mail_users';
protected $fillable = [
'domain_id','localpart','email','password_hash',
'is_active','must_change_pw','quota_mb','last_login_at',
'is_active','must_change_pw','quota_mb','is_system'
];
protected $hidden = ['password_hash'];
protected $casts = [
'is_active'=>'bool',
'is_system' => 'boolean',
'must_change_pw'=>'bool',
'quota_mb'=>'int',
'last_login_at'=>'datetime',
@ -26,7 +27,6 @@ class MailUser extends Model
return $this->belongsTo(Domain::class);
}
// Komfort: Passwort setzen → bcrypt (BLF-CRYPT)
public function setPasswordAttribute(string $plain): void {
// optional: allow 'password' virtual attribute
$this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT);
@ -34,6 +34,9 @@ class MailUser extends Model
// Scopes
public function scopeActive($q) { return $q->where('is_active', true); }
public function scopeSystem($q) { return $q->where('is_system', true); }
public function scopeByEmail($q, string $email) { return $q->where('email', $email); }
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Setting extends Model
{
@ -22,4 +23,11 @@ class Setting extends Model
$val = is_array($value) ? json_encode($value, JSON_UNESCAPED_SLASHES) : (string)$value;
static::query()->updateOrCreate(['key'=>$key], ['value'=>$val]);
}
public static function signupAllowed()
{
$value = self::where('key', 'signup_enabled')->value('value');
return is_null($value) || (int) $value === 1;
}
}

15
app/Models/SpfRecord.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SpfRecord extends Model
{
protected $fillable = ['domain_id','record_txt','is_active'];
protected $casts = ['is_active' => 'boolean'];
public function domain(): BelongsTo { return $this->belongsTo(Domain::class); }
}

19
app/Models/TlsaRecord.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TlsaRecord extends Model
{
protected $fillable = [
'domain_id','service','host','usage','selector','matching','hash','cert_path',
];
public function domain() { return $this->belongsTo(Domain::class); }
public function getDnsStringAttribute(): string
{
return "{$this->service}.{$this->host}. IN TLSA {$this->usage} {$this->selector} {$this->matching} {$this->hash}";
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TwoFactorMethod extends Model
{
protected $fillable = ['user_id','method','secret','recovery_codes','enabled','confirmed_at'];
protected $casts = [
'recovery_codes' => 'array',
'enabled' => 'bool',
'confirmed_at' => 'datetime',
];
public function user() { return $this->belongsTo(User::class); }
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TwoFactorRecoveryCode extends Model
{
use HasFactory;
protected $table = 'two_factor_recovery_codes';
protected $fillable = [
'user_id',
'code_hash',
'used_at',
];
protected $casts = [
'used_at' => 'datetime',
];
// === Beziehungen ===
public function user()
{
return $this->belongsTo(User::class);
}
// === Logik ===
/**
* Prüft, ob der eingegebene Code gültig ist (noch nicht benutzt & hash-match)
*/
public static function verifyAndConsume(int $userId, string $inputCode): bool
{
$codes = self::where('user_id', $userId)
->whereNull('used_at')
->get();
foreach ($codes as $code) {
if (password_verify($inputCode, $code->code_hash)) {
$code->update(['used_at' => now()]);
return true;
}
}
return false;
}
/**
* Prüft, ob der Code für den Benutzer existiert (ohne ihn zu verbrauchen)
*/
public static function checkValid(int $userId, string $inputCode): bool
{
return self::where('user_id', $userId)
->whereNull('used_at')
->get()
->contains(fn($c) => password_verify($inputCode, $c->code_hash));
}
/**
* Generiert neue Recovery-Codes (löscht alte unbenutzte)
*/
public static function generateNewSet(int $userId, int $count = 10): array
{
self::where('user_id', $userId)->delete();
$plainCodes = [];
for ($i = 0; $i < $count; $i++) {
$plain = strtoupper(str()->random(10));
self::create([
'user_id' => $userId,
'code_hash' => password_hash($plain, PASSWORD_DEFAULT),
]);
$plainCodes[] = $plain;
}
return $plainCodes;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\Role;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -31,11 +32,28 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'is_active' => 'boolean',
'must_change_pw' => 'boolean',
'role' => Role::class,
];
/** Quick helper: check if user is admin */
public function isAdmin(): bool
{
return $this->role === 'admin';
return $this->role === Role::Admin;
}
public function twoFactorMethods()
{
return $this->hasMany(\App\Models\TwoFactorMethod::class);
}
public function twoFactorEnabled(): bool
{
return $this->twoFactorMethods()->where('enabled', true)->exists();
}
public function twoFactorMethod(string $method): ?\App\Models\TwoFactorMethod
{
return $this->twoFactorMethods()->where('method', $method)->first();
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\Domain;
class DnsRecordService
{
public function buildForDomain(Domain $domain): array
{
// Quelle der Hostnamen: ENV (du hast BASE_DOMAIN, UI_SUB, WEBMAIL_SUB, MTA_SUB)
$baseDomain = env('BASE_DOMAIN', 'example.com');
$uiHost = ($u = env('UI_SUB', 'ui')) ? "$u.$baseDomain" : $baseDomain;
$webmail = ($w = env('WEBMAIL_SUB','webmail')) ? "$w.$baseDomain" : $baseDomain;
$mtaHost = ($m = env('MTA_SUB','mx')) ? "$m.$baseDomain" : $baseDomain;
$records = [];
// A/AAAA nur als Anzeigehilfe (optional)
$records[] = [
'type' => 'A',
'name' => $domain->domain,
'value' => 'DEINE.SERVER.IP', // optional: aus Installer einsetzen
'ttl' => 3600,
];
// MX
$records[] = [
'type' => 'MX',
'name' => $domain->domain,
'value' => "10 $mtaHost.",
'ttl' => 3600,
];
// SPF
$records[] = [
'type' => 'TXT',
'name' => $domain->domain,
'value' => 'v=spf1 mx a -all',
'ttl' => 3600,
];
// DKIM (nimm den neuesten aktiven Key, falls vorhanden)
/** @var DkimKey|null $dkim */
$dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
if ($dkim) {
$records[] = [
'type' => 'TXT',
'name' => "{$dkim->selector}._domainkey.{$domain->domain}",
'value' => "v=DKIM1; k=rsa; p={$dkim->public_key_txt}",
'ttl' => 3600,
];
}
// DMARC (Default p=none in UI änderbar)
$records[] = [
'type' => 'TXT',
'name' => "_dmarc.{$domain->domain}",
'value' => "v=DMARC1; p=none; rua=mailto:dmarc@{$domain->domain}; pct=100",
'ttl' => 3600,
];
// Optional: Webmail/UI CNAMEs
$records[] = ['type'=>'CNAME','name'=>"webmail.{$domain->domain}",'value'=>"$webmail.",'ttl'=>3600];
$records[] = ['type'=>'CNAME','name'=>"ui.{$domain->domain}", 'value'=>"$uiHost.", 'ttl'=>3600];
return $records;
}
}

19
app/Support/Hostnames.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Support;
final class Hostnames
{
public static function base(): string
{
return trim(env('BASE_DOMAIN', 'example.com'));
}
public static function mta(): string
{
$sub = trim(env('MTA_SUB', 'mx'), '.');
$base = self::base();
return $sub === '' ? $base : "{$sub}.{$base}";
}
}

78
app/Support/ToastBus.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace App\Support;
use App\Events\TaskUpdated;
use Illuminate\Support\Facades\Redis;
class ToastBus
{
/** Sekunden bis Auto-Cleanup */
const TTL = 1800; // 30 min
public static function put(int $userId, string $taskId, array $data): void
{
$payload = array_merge([
'id' => $taskId,
'title' => null,
'message' => null,
'badge' => null,
'state' => 'queued', // queued|running|done|failed
'position' => 'bottom-right',
'updated_at' => time(),
], $data);
// Task-JSON
Redis::setex("task:$taskId", self::TTL, json_encode($payload));
// User-Set
Redis::sadd("user:$userId:tasks", $taskId);
Redis::expire("user:$userId:tasks", self::TTL);
// WS raus
event(new TaskUpdated($taskId, $userId, $payload));
}
public static function done(int $userId, string $taskId, string $message = 'Fertig.'): void
{
self::update($userId, $taskId, ['state' => 'done', 'message' => $message]);
}
public static function fail(int $userId, string $taskId, string $message = 'Fehlgeschlagen.'): void
{
self::update($userId, $taskId, ['state' => 'failed', 'message' => $message]);
}
public static function update(int $userId, string $taskId, array $patch): void
{
$key = "task:$taskId";
$raw = Redis::get($key);
$base = $raw ? json_decode($raw, true) : ['id' => $taskId];
$payload = array_merge($base, $patch, ['updated_at' => time()]);
Redis::setex($key, self::TTL, json_encode($payload));
Redis::sadd("user:$userId:tasks", $taskId);
Redis::expire("user:$userId:tasks", self::TTL);
event(new TaskUpdated($taskId, $userId, $payload));
}
/** Initiale Tasks eines Users lesen */
public static function listForUser(int $userId): array
{
$ids = Redis::smembers("user:$userId:tasks") ?: [];
$items = [];
foreach ($ids as $id) {
$raw = Redis::get("task:$id");
if ($raw) $items[] = json_decode($raw, true);
}
// optional: nach updated_at sortiert
usort($items, fn($a,$b) => ($b['updated_at']??0) <=> ($a['updated_at']??0));
return $items;
}
/** Optional: Client bestätigt (entfernen, aber Task-Key leben lassen bis TTL) */
public static function ack(int $userId, string $taskId): void
{
Redis::srem("user:$userId:tasks", $taskId);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\View\Components\Partials;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Header extends Component
{
/**
* Create a new component instance.
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
$header = config('ui.header');
return view('components.partials.header', compact('header'));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\View\Components\Partials;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Sidebar extends Component
{
/**
* Create a new component instance.
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
$menu = config('menu.sidebar');
return view('components.partials.sidebar', compact('menu'));
}
}

View File

@ -7,12 +7,14 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'ensure.setup' => \App\Http\Middleware\EnsureSetupCompleted::class,
'signup.open' => \App\Http\Middleware\SignupOpen::class,
]);
})

View File

@ -10,7 +10,10 @@
"laravel/framework": "^12.0",
"laravel/reverb": "^1.6",
"laravel/tinker": "^2.10.1",
"livewire/livewire": "^3.6"
"livewire/livewire": "^3.6",
"vectorface/googleauthenticator": "^3.4",
"wire-elements/modal": "^2.0",
"ext-openssl": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",

602
composer.lock generated
View File

@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cf63d9c3e0078c5b2ed8141614d6429a",
"content-hash": "b5113b6186747bf4e181997c30721794",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
"reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || 11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
},
"time": "2024-10-01T13:55:55+00:00"
},
{
"name": "brick/math",
"version": "0.14.0",
@ -265,6 +319,56 @@
],
"time": "2025-01-03T16:18:33+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -639,6 +743,78 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "endroid/qr-code",
"version": "6.0.9",
"source": {
"type": "git",
"url": "https://github.com/endroid/qr-code.git",
"reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a",
"reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"php": "^8.2"
},
"require-dev": {
"endroid/quality": "dev-main",
"ext-gd": "*",
"khanamiryan/qrcode-detector-decoder": "^2.0.2",
"setasign/fpdf": "^1.8.2"
},
"suggest": {
"ext-gd": "Enables you to write PNG images",
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
"setasign/fpdf": "Enables you to use the PDF writer"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Endroid\\QrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeroen van den Enden",
"email": "info@endroid.nl"
}
],
"description": "Endroid QR Code",
"homepage": "https://github.com/endroid/qr-code",
"keywords": [
"code",
"endroid",
"php",
"qr",
"qrcode"
],
"support": {
"issues": "https://github.com/endroid/qr-code/issues",
"source": "https://github.com/endroid/qr-code/tree/6.0.9"
},
"funding": [
{
"url": "https://github.com/endroid",
"type": "github"
}
],
"time": "2025-07-13T19:59:45+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",
@ -1232,16 +1408,16 @@
},
{
"name": "laravel/framework",
"version": "v12.31.1",
"version": "v12.32.5",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "281b711710c245dd8275d73132e92635be3094df"
"reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/281b711710c245dd8275d73132e92635be3094df",
"reference": "281b711710c245dd8275d73132e92635be3094df",
"url": "https://api.github.com/repos/laravel/framework/zipball/77b2740391cd2a825ba59d6fada45e9b8b9bcc5a",
"reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a",
"shasum": ""
},
"require": {
@ -1269,7 +1445,6 @@
"monolog/monolog": "^3.0",
"nesbot/carbon": "^3.8.4",
"nunomaduro/termwind": "^2.0",
"phiki/phiki": "^2.0.0",
"php": "^8.2",
"psr/container": "^1.1.1|^2.0.1",
"psr/log": "^1.0|^2.0|^3.0",
@ -1448,7 +1623,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-09-23T15:33:04+00:00"
"time": "2025-09-30T17:39:22+00:00"
},
{
"name": "laravel/prompts",
@ -2851,16 +3026,16 @@
},
{
"name": "paragonie/sodium_compat",
"version": "v2.2.0",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/sodium_compat.git",
"reference": "9c3535883f1b60b5d26aeae5914bbec61132ad7f"
"reference": "547e2dc4d45107440e76c17ab5a46e4252460158"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/9c3535883f1b60b5d26aeae5914bbec61132ad7f",
"reference": "9c3535883f1b60b5d26aeae5914bbec61132ad7f",
"url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/547e2dc4d45107440e76c17ab5a46e4252460158",
"reference": "547e2dc4d45107440e76c17ab5a46e4252460158",
"shasum": ""
},
"require": {
@ -2941,80 +3116,9 @@
],
"support": {
"issues": "https://github.com/paragonie/sodium_compat/issues",
"source": "https://github.com/paragonie/sodium_compat/tree/v2.2.0"
"source": "https://github.com/paragonie/sodium_compat/tree/v2.4.0"
},
"time": "2025-09-21T18:27:14+00:00"
},
{
"name": "phiki/phiki",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/phikiphp/phiki.git",
"reference": "160785c50c01077780ab217e5808f00ab8f05a13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/160785c50c01077780ab217e5808f00ab8f05a13",
"reference": "160785c50c01077780ab217e5808f00ab8f05a13",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/commonmark": "^2.5.3",
"php": "^8.2",
"psr/simple-cache": "^3.0"
},
"require-dev": {
"illuminate/support": "^11.45",
"laravel/pint": "^1.18.1",
"orchestra/testbench": "^9.15",
"pestphp/pest": "^3.5.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^7.1.6"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Phiki\\Adapters\\Laravel\\PhikiServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Phiki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Chandler",
"email": "support@ryangjchandler.co.uk",
"homepage": "https://ryangjchandler.co.uk",
"role": "Developer"
}
],
"description": "Syntax highlighting using TextMate grammars in PHP.",
"support": {
"issues": "https://github.com/phikiphp/phiki/issues",
"source": "https://github.com/phikiphp/phiki/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/sponsors/ryangjchandler",
"type": "github"
},
{
"url": "https://buymeacoffee.com/ryangjchandler",
"type": "other"
}
],
"time": "2025-09-20T17:21:02+00:00"
"time": "2025-10-06T08:47:40+00:00"
},
{
"name": "phpoption/phpoption",
@ -4429,6 +4533,67 @@
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
"shasum": ""
},
"require": {
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.5",
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
"pestphp/pest": "^1.23|^2.1|^3.1",
"phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
"phpunit/phpunit": "^9.5.24|^10.5|^11.5",
"spatie/pest-plugin-test-time": "^1.1|^2.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\LaravelPackageTools\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Tools for creating Laravel packages",
"homepage": "https://github.com/spatie/laravel-package-tools",
"keywords": [
"laravel-package-tools",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-07-17T15:46:43+00:00"
},
{
"name": "symfony/clock",
"version": "v7.3.0",
@ -4505,16 +4670,16 @@
},
{
"name": "symfony/console",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7"
"reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
"reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
"url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
"reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
"shasum": ""
},
"require": {
@ -4579,7 +4744,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.3.3"
"source": "https://github.com/symfony/console/tree/v7.3.4"
},
"funding": [
{
@ -4599,7 +4764,7 @@
"type": "tidelift"
}
],
"time": "2025-08-25T06:35:40+00:00"
"time": "2025-09-22T15:31:00+00:00"
},
{
"name": "symfony/css-selector",
@ -4735,16 +4900,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.3.2",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3"
"reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
"reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
"reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
"shasum": ""
},
"require": {
@ -4792,7 +4957,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.3.2"
"source": "https://github.com/symfony/error-handler/tree/v7.3.4"
},
"funding": [
{
@ -4812,7 +4977,7 @@
"type": "tidelift"
}
],
"time": "2025-07-07T08:17:57+00:00"
"time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/event-dispatcher",
@ -5044,16 +5209,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00"
"reference": "c061c7c18918b1b64268771aad04b40be41dd2e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00",
"reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6",
"reference": "c061c7c18918b1b64268771aad04b40be41dd2e6",
"shasum": ""
},
"require": {
@ -5103,7 +5268,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.3.3"
"source": "https://github.com/symfony/http-foundation/tree/v7.3.4"
},
"funding": [
{
@ -5123,20 +5288,20 @@
"type": "tidelift"
}
],
"time": "2025-08-20T08:04:18+00:00"
"time": "2025-09-16T08:38:17+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b"
"reference": "b796dffea7821f035047235e076b60ca2446e3cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b",
"reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf",
"reference": "b796dffea7821f035047235e076b60ca2446e3cf",
"shasum": ""
},
"require": {
@ -5221,7 +5386,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.3.3"
"source": "https://github.com/symfony/http-kernel/tree/v7.3.4"
},
"funding": [
{
@ -5241,20 +5406,20 @@
"type": "tidelift"
}
],
"time": "2025-08-29T08:23:45+00:00"
"time": "2025-09-27T12:32:17+00:00"
},
{
"name": "symfony/mailer",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575"
"reference": "ab97ef2f7acf0216955f5845484235113047a31d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575",
"reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575",
"url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d",
"reference": "ab97ef2f7acf0216955f5845484235113047a31d",
"shasum": ""
},
"require": {
@ -5305,7 +5470,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/mailer/tree/v7.3.3"
"source": "https://github.com/symfony/mailer/tree/v7.3.4"
},
"funding": [
{
@ -5325,20 +5490,20 @@
"type": "tidelift"
}
],
"time": "2025-08-13T11:49:31+00:00"
"time": "2025-09-17T05:51:54+00:00"
},
{
"name": "symfony/mime",
"version": "v7.3.2",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
"url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
"shasum": ""
},
"require": {
@ -5393,7 +5558,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.3.2"
"source": "https://github.com/symfony/mime/tree/v7.3.4"
},
"funding": [
{
@ -5413,7 +5578,7 @@
"type": "tidelift"
}
],
"time": "2025-07-15T13:41:35+00:00"
"time": "2025-09-16T08:38:17+00:00"
},
{
"name": "symfony/polyfill-ctype",
@ -6246,16 +6411,16 @@
},
{
"name": "symfony/process",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "32241012d521e2e8a9d713adb0812bb773b907f1"
"reference": "f24f8f316367b30810810d4eb30c543d7003ff3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1",
"reference": "32241012d521e2e8a9d713adb0812bb773b907f1",
"url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b",
"reference": "f24f8f316367b30810810d4eb30c543d7003ff3b",
"shasum": ""
},
"require": {
@ -6287,7 +6452,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.3.3"
"source": "https://github.com/symfony/process/tree/v7.3.4"
},
"funding": [
{
@ -6307,20 +6472,20 @@
"type": "tidelift"
}
],
"time": "2025-08-18T09:42:54+00:00"
"time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/routing",
"version": "v7.3.2",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4"
"reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
"reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
"url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c",
"reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c",
"shasum": ""
},
"require": {
@ -6372,7 +6537,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.3.2"
"source": "https://github.com/symfony/routing/tree/v7.3.4"
},
"funding": [
{
@ -6392,7 +6557,7 @@
"type": "tidelift"
}
],
"time": "2025-07-15T11:36:08+00:00"
"time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/service-contracts",
@ -6479,16 +6644,16 @@
},
{
"name": "symfony/string",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c"
"reference": "f96476035142921000338bad71e5247fbc138872"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
"reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
"url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872",
"reference": "f96476035142921000338bad71e5247fbc138872",
"shasum": ""
},
"require": {
@ -6503,7 +6668,6 @@
},
"require-dev": {
"symfony/emoji": "^7.1",
"symfony/error-handler": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/intl": "^6.4|^7.0",
"symfony/translation-contracts": "^2.5|^3.0",
@ -6546,7 +6710,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.3.3"
"source": "https://github.com/symfony/string/tree/v7.3.4"
},
"funding": [
{
@ -6566,20 +6730,20 @@
"type": "tidelift"
}
],
"time": "2025-08-25T06:35:40+00:00"
"time": "2025-09-11T14:36:48+00:00"
},
{
"name": "symfony/translation",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "e0837b4cbcef63c754d89a4806575cada743a38d"
"reference": "ec25870502d0c7072d086e8ffba1420c85965174"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d",
"reference": "e0837b4cbcef63c754d89a4806575cada743a38d",
"url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174",
"reference": "ec25870502d0c7072d086e8ffba1420c85965174",
"shasum": ""
},
"require": {
@ -6646,7 +6810,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v7.3.3"
"source": "https://github.com/symfony/translation/tree/v7.3.4"
},
"funding": [
{
@ -6666,7 +6830,7 @@
"type": "tidelift"
}
],
"time": "2025-08-01T21:02:37+00:00"
"time": "2025-09-07T11:39:36+00:00"
},
{
"name": "symfony/translation-contracts",
@ -6822,16 +6986,16 @@
},
{
"name": "symfony/var-dumper",
"version": "v7.3.3",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f"
"reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
"reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
"reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
"shasum": ""
},
"require": {
@ -6885,7 +7049,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.3.3"
"source": "https://github.com/symfony/var-dumper/tree/v7.3.4"
},
"funding": [
{
@ -6905,7 +7069,7 @@
"type": "tidelift"
}
],
"time": "2025-08-13T11:49:31+00:00"
"time": "2025-09-11T10:12:26+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@ -6962,6 +7126,63 @@
},
"time": "2024-12-21T16:25:41+00:00"
},
{
"name": "vectorface/googleauthenticator",
"version": "v3.4",
"source": {
"type": "git",
"url": "https://github.com/Vectorface/GoogleAuthenticator.git",
"reference": "48f5571e1496a6421e7975d9884860cb3cc918d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Vectorface/GoogleAuthenticator/zipball/48f5571e1496a6421e7975d9884860cb3cc918d8",
"reference": "48f5571e1496a6421e7975d9884860cb3cc918d8",
"shasum": ""
},
"require": {
"endroid/qr-code": "^6.0.0",
"php": ">=8.2"
},
"require-dev": {
"phpunit/phpunit": "^9"
},
"type": "library",
"autoload": {
"psr-4": {
"Vectorface\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Michael Kliewe",
"email": "info@phpgangsta.de",
"homepage": "http://www.phpgangsta.de/",
"role": "Developer"
},
{
"name": "Francis Lavoie",
"email": "francis@vectorface.com",
"homepage": "http://vectorface.com/",
"role": "Developer"
}
],
"description": "Google Authenticator 2-factor authentication",
"keywords": [
"googleauthenticator",
"rfc6238",
"totp"
],
"support": {
"issues": "https://github.com/Vectorface/GoogleAuthenticator/issues",
"source": "https://github.com/Vectorface/GoogleAuthenticator"
},
"time": "2025-07-23T17:32:36+00:00"
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.2",
@ -7177,6 +7398,70 @@
"source": "https://github.com/webmozarts/assert/tree/1.11.0"
},
"time": "2022-06-03T18:03:27+00:00"
},
{
"name": "wire-elements/modal",
"version": "2.0.13",
"source": {
"type": "git",
"url": "https://github.com/wire-elements/modal.git",
"reference": "65d9db80a0befaa38ae99a47a26818e784aa7101"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wire-elements/modal/zipball/65d9db80a0befaa38ae99a47a26818e784aa7101",
"reference": "65d9db80a0befaa38ae99a47a26818e784aa7101",
"shasum": ""
},
"require": {
"livewire/livewire": "^3.2.3",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.9"
},
"require-dev": {
"orchestra/testbench": "^8.5",
"phpunit/phpunit": "^9.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LivewireUI\\Modal\\LivewireModalServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LivewireUI\\Modal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Philo Hermans",
"email": "me@philohermans.com"
}
],
"description": "Laravel Livewire modal component",
"keywords": [
"laravel",
"livewire",
"modal"
],
"support": {
"issues": "https://github.com/wire-elements/modal/issues",
"source": "https://github.com/wire-elements/modal/tree/2.0.13"
},
"funding": [
{
"url": "https://github.com/PhiloNL",
"type": "github"
}
],
"time": "2025-02-20T13:07:12+00:00"
}
],
"packages-dev": [
@ -8270,16 +8555,16 @@
},
{
"name": "phpunit/phpunit",
"version": "11.5.41",
"version": "11.5.42",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "b42782bcb947d2c197aea42ce9714ee2d974b283"
"reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b42782bcb947d2c197aea42ce9714ee2d974b283",
"reference": "b42782bcb947d2c197aea42ce9714ee2d974b283",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c",
"reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c",
"shasum": ""
},
"require": {
@ -8351,7 +8636,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.41"
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.42"
},
"funding": [
{
@ -8375,7 +8660,7 @@
"type": "tidelift"
}
],
"time": "2025-09-24T06:32:10+00:00"
"time": "2025-09-28T12:09:13+00:00"
},
{
"name": "sebastian/cli-parser",
@ -9548,7 +9833,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
"php": "^8.2",
"ext-openssl": "*"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"

View File

@ -123,4 +123,6 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
'version' => env('APP_VERSION', '1.0.0')
];

View File

@ -111,5 +111,4 @@ return [
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View File

@ -15,7 +15,7 @@ return [
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
'default' => env('BROADCAST_DRIVER', 'null'),
/*
|--------------------------------------------------------------------------
@ -44,19 +44,50 @@ return [
// 'client_options' => [
// // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
// ],
// ],
// 'reverb' => [
// 'driver' => 'reverb',
// 'key' => env('REVERB_APP_KEY'),
// 'secret' => env('REVERB_APP_SECRET'),
// 'app_id' => env('REVERB_APP_ID'),
// 'options' => [
// 'host' => env('REVERB_HOST', '127.0.0.1'),
// 'port' => (int) env('REVERB_PORT', 443), // <- egal, wird für HTTP-Client überschrieben
// 'scheme' => env('REVERB_SCHEME', 'https'), // <- dito
// 'path' => env('REVERB_PATH', '/ws'), // <- WICHTIG: hier /ws!
// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL),
//
//// 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'),
//// 'port' => (int) env('REVERB_SERVER_PORT', 8080),
//// 'scheme' => env('REVERB_SERVER_SCHEME', 'http'),
//// 'path' => env('REVERB_SERVER_PATH', '/ws'),
//// 'useTLS' => env('REVERB_SERVER_SCHEME', 'http') === 'https',
//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL),
//
//// 'host' => env('REVERB_HOST'),
//// 'port' => (int) env('REVERB_PORT', 443),
//// 'scheme' => env('REVERB_SCHEME', 'http'),
//// 'path' => env('REVERB_PATH', '/ws'),
//// 'useTLS' => env('REVERB_SCHEME', 'http') === 'https',
//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL),
// ],
// ],
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => (int) env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'path' => env('REVERB_PATH', '/ws'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
// *** Wichtig: interner HTTP-Endpunkt des Reverb-Servers ***
'host' => env('REVERB_SERVER_HOST', '127.0.0.1'),
'port' => (int) env('REVERB_SERVER_PORT', 8080),
'scheme' => 'http',
'path' => '', // *** KEIN /ws hier! ***
'useTLS' => false,
'verify' => false, // kein TLS → egal, zur Sicherheit false
],
],

63
config/menu/sidebar.php Normal file
View File

@ -0,0 +1,63 @@
<?php
// config/menu/sidebar.php
return [
[
'label' => 'Übersicht', 'icon' => 'ph-gauge', 'items' => [
['label' => 'Dashboard', 'route' => 'ui.dashboard', 'icon' => 'ph-house'],
],
],
[
'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [
['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'],
['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'],
['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'],
['label' => 'Filter', 'route' => 'ui.mail.filters.index'],
['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'],
['label' => 'Queues', 'route' => 'ui.mail.queues.index'],
],
],
[
'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [
['label' => 'Übersicht', 'route' => 'ui.domains.index'],
['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'],
['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'],
],
],
[
'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [
['label' => 'Allgemein', 'route' => 'ui.webmail.settings'],
['label' => 'Plugins', 'route' => 'ui.webmail.plugins'],
],
],
[
'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [
['label' => 'Benutzer', 'route' => 'ui.users.index'],
['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'],
['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'],
],
],
[
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
],
],
[
'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [
['label' => 'Einstellungen', 'route' => 'ui.system.settings'],
['label' => 'Dienste & Status', 'route' => 'ui.system.services'],
['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'],
['label' => 'Logs', 'route' => 'ui.system.logs'],
['label' => 'Speicher', 'route' => 'ui.system.storage'],
['label' => 'Über', 'route' => 'ui.system.about'],
],
],
[
'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [
['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'],
['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'],
['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'],
],
],
];

View File

@ -26,16 +26,49 @@ return [
|
*/
// 'servers' => [
// 'reverb' => [
// 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'),
// 'port' => env('REVERB_SERVER_PORT', 8080),
// 'path' => env('REVERB_SERVER_PATH', '/ws'),
// 'scheme' => env('REVERB_SERVER_SCHEME', 'http'),
// 'key' => env('REVERB_APP_KEY'),
// 'hostname' => env('REVERB_HOST'),
// 'options' => [
// 'tls' => ['verify' => false],
//// 'tls' => [],
// ],
// 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
// 'scaling' => [
// 'enabled' => env('REVERB_SCALING_ENABLED', false),
// 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
// 'server' => [
// 'url' => env('REDIS_URL'),
// 'host' => env('REDIS_HOST', '127.0.0.1'),
// 'port' => env('REDIS_PORT', '6379'),
// 'username' => env('REDIS_USERNAME'),
// 'password' => env('REDIS_PASSWORD'),
// 'database' => env('REDIS_DB', '0'),
// 'timeout' => env('REDIS_TIMEOUT', 60),
// ],
// ],
// 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
// 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
// ],
//
// ],
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '127.0.0.1'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', '/ws'),
'scheme' => env('REVERB_SERVER_SCHEME', 'https'),
'key' => env('REVERB_APP_KEY'),
'hostname' => env('REVERB_HOST'),
// *** Wo der artisan-Server bindet ***
'host' => env('REVERB_SERVER_HOST', '127.0.0.1'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''), // WS-Pfad
'scheme' => env('REVERB_SERVER_SCHEME', 'http'),
'key' => env('REVERB_APP_KEY'),
'hostname' => env('REVERB_HOST'), // optional für Logs
'options' => [
'tls' => [],
'tls' => ['verify' => false], // wir nutzen http → irrelevant
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
@ -54,7 +87,6 @@ return [
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
@ -68,29 +100,56 @@ return [
|
*/
// 'apps' => [
//
// 'provider' => 'config',
//
// 'apps' => [
// [
// 'key' => env('REVERB_APP_KEY'),
// 'secret' => env('REVERB_APP_SECRET'),
// 'app_id' => env('REVERB_APP_ID'),
// 'options' => [
// 'host' => env('REVERB_HOST', '127.0.0.1'),
// 'port' => (int) env('REVERB_PORT', 443),
// 'scheme' => env('REVERB_SCHEME', 'https'),
// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
//
//// 'host' => env('REVERB_HOST'),
//// 'port' => env('REVERB_PORT', 443),
//// 'scheme' => env('REVERB_SCHEME', 'https'),
//// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL),
// ],
// 'allowed_origins' => ['*'],
// 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
// 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
// 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
// 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
// ],
// ],
//
// ],
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'apps' => [[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
// *** Öffentlicher WS-Endpunkt (über Nginx) nur Info/Signatur ***
'options' => [
'host' => env('REVERB_HOST', '127.0.0.1'),
'port' => (int) env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
]],
],
];

63
config/ui-menu.php Normal file
View File

@ -0,0 +1,63 @@
<?php
// config/ui_menu.php
return [
[
'label' => 'Übersicht', 'icon' => 'ph-gauge', 'items' => [
['label' => 'Dashboard', 'route' => 'ui.dashboard', 'icon' => 'ph-house'],
],
],
[
'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [
['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'],
['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'],
['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'],
['label' => 'Filter', 'route' => 'ui.mail.filters.index'],
['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'],
['label' => 'Queues', 'route' => 'ui.mail.queues.index'],
],
],
[
'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [
['label' => 'Übersicht', 'route' => 'ui.domains.index'],
['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'],
['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'],
],
],
[
'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [
['label' => 'Allgemein', 'route' => 'ui.webmail.settings'],
['label' => 'Plugins', 'route' => 'ui.webmail.plugins'],
],
],
[
'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [
['label' => 'Benutzer', 'route' => 'ui.users.index'],
['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'],
['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'],
],
],
[
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
],
],
[
'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [
['label' => 'Einstellungen', 'route' => 'ui.settings.index'],
['label' => 'Dienste & Status', 'route' => 'ui.system.services'],
['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'],
['label' => 'Logs', 'route' => 'ui.system.logs'],
['label' => 'Speicher', 'route' => 'ui.system.storage'],
['label' => 'Über', 'route' => 'ui.system.about'],
],
],
[
'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [
['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'],
['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'],
['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'],
],
],
];

44
config/ui/header.php Normal file
View File

@ -0,0 +1,44 @@
<?php
return [
[
'title' => 'Notification',
'icon' => 'icons.icon-notification',
'image' => false,
'route' => 'dashboard',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Avatar',
'icon' => 'icons.icon-team',
'image' => true,
'route' => 'dashboard',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
'sub' => [
[
'title' => 'Security',
'icon' => 'icons.icon-security',
'route' => 'logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Team',
'icon' => 'icons.icon-team',
'route' => 'logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Settings',
'icon' => 'icons.icon-settings',
'route' => 'logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
[
'title' => 'Logout',
'icon' => 'icons.icon-logout',
'route' => 'logout',
'roles' => ['super_admin', 'admin', 'employee', 'user'],
],
]
],
];

View File

@ -0,0 +1,52 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Include CSS
|--------------------------------------------------------------------------
|
| The modal uses TailwindCSS, if you don't use TailwindCSS you will need
| to set this parameter to true. This includes the modern-normalize css.
|
*/
'include_css' => false,
/*
|--------------------------------------------------------------------------
| Include JS
|--------------------------------------------------------------------------
|
| Livewire UI will inject the required Javascript in your blade template.
| If you want to bundle the required Javascript you can set this to false
| and add `require('vendor/wire-elements/modal/resources/js/modal');`
| to your script bundler like webpack.
|
*/
'include_js' => true,
/*
|--------------------------------------------------------------------------
| Modal Component Defaults
|--------------------------------------------------------------------------
|
| Configure the default properties for a modal component.
|
| Supported modal_max_width
| 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl'
*/
'component_defaults' => [
'modal_max_width' => '2xl',
'close_modal_on_click_away' => true,
'close_modal_on_escape' => true,
'close_modal_on_escape_is_forceful' => true,
'dispatch_close_event' => false,
'destroy_on_close' => false,
],
];

View File

@ -13,14 +13,17 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable();
$table->string('username')->unique();
$table->string('name')->unique();
$table->string('username')->nullable()->unique();
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_active')->default(true)->index();
$table->boolean('must_change_pw')->default(true)->index();
$table->string('role', 32)->default('admin')->index(); // z.B. admin|user
$table->boolean('two_factor_enabled')->default(false);
$table->boolean('two_factor_email_enabled')->default(false);
$table->string('totp_secret')->nullable();
$table->string('role', 32)->default('admin')->index();
$table->rememberToken();
$table->timestamps();

View File

@ -15,6 +15,7 @@ return new class extends Migration
$table->id();
$table->string('domain', 191)->unique();
$table->boolean('is_active')->default(true)->index();
$table->boolean('is_system')->default(false);
$table->timestamps();
});
}

View File

@ -13,17 +13,25 @@ return new class extends Migration
{
Schema::create('mail_users', function (Blueprint $table) {
$table->id();
// legt FK + Index an
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
$table->string('localpart', 191);
$table->string('email', 191)->unique(); // z.B. user@example.com
$table->string('password_hash'); // für Dovecot: BLF-CRYPT (bcrypt)
$table->string('email', 191)->unique('mail_users_email_unique'); // genau EIN Unique-Index
$table->string('password_hash')->nullable(); // system-Accounts dürfen null sein
$table->boolean('is_system')->default(false)->index(); // oft nach Systemkonten filtern
$table->boolean('is_active')->default(true)->index();
$table->boolean('must_change_pw')->default(true)->index();
$table->unsignedInteger('quota_mb')->default(0); // 0 = unbegrenzt
$table->unsignedInteger('quota_mb')->default(0); // 0 = unlimited
$table->timestamp('last_login_at')->nullable();
$table->timestamps();
$table->index(['domain_id', 'localpart']);
// schneller Lookup pro Domain + Localpart UND verhindert Dubletten je Domain
$table->unique(['domain_id', 'localpart'], 'mail_users_domain_localpart_unique');
});
}

View File

@ -13,14 +13,15 @@ return new class extends Migration
{
Schema::create('dkim_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
$table->string('selector', 64)->default('mail')->index();
$table->longText('private_key_pem'); // oder Pfad, wenn du lieber Dateien nutzt
$table->longText('public_key_txt'); // nur der Key-Body für DNS
$table->boolean('is_active')->default(true)->index();
$table->unsignedBigInteger('domain_id');
$table->string('selector', 64);
$table->longText('private_key_pem');
$table->longText('public_key_txt');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['domain_id','selector']);
$table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete();
});
}

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('two_factor_methods', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('method', ['totp','email']); // weitere später möglich
$table->string('secret')->nullable(); // z.B. TOTP-Secret (verschlüsselt)
$table->json('recovery_codes')->nullable();
$table->boolean('enabled')->default(false);
$table->timestamp('confirmed_at')->nullable();
$table->timestamps();
$table->unique(['user_id','method']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('two_factor_methods');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('two_factor_recovery_codes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('code_hash', 255);
$table->timestamp('used_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('two_factor_recovery_codes');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('spf_records', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('domain_id');
$table->string('record_txt', 255); // z.B. "v=spf1 mx a ip4:1.2.3.4 -all"
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('spf_records');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('dmarc_records', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('domain_id');
$table->enum('policy', ['none','quarantine','reject'])->default('none');
$table->string('rua')->nullable(); // "mailto:dmarc@domain.tld"
$table->string('ruf')->nullable();
$table->unsignedInteger('pct')->default(100);
$table->string('record_txt', 255)->nullable(); // vorgerendert, optional
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('dmarc_records');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tlsa_records', function (Blueprint $table) {
$table->id();
$table->id();
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
$table->string('service')->default('_25._tcp');
$table->string('host');
$table->tinyInteger('usage')->default(3);
$table->tinyInteger('selector')->default(1);
$table->tinyInteger('matching')->default(1);
$table->string('hash', 128);
$table->string('cert_path')->nullable();
$table->timestamps();
$table->unique(['domain_id','service','host'], 'tlsa_domain_service_host_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tlsa_records');
}
};

View File

@ -0,0 +1,124 @@
<?php
namespace Database\Seeders;
use App\Models\DkimKey;
use App\Models\DmarcRecord;
use App\Models\Domain;
use App\Models\MailUser;
use App\Models\SpfRecord;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SystemDomainSeeder extends Seeder
{
public function run(): void
{
$base = config('app.base_domain', env('BASE_DOMAIN', 'example.com'));
if (!$base || $base === 'example.com') {
$this->command->warn("BASE_DOMAIN ist 'example.com' Seeder überspringt produktive Einträge.");
return;
}
$systemSub = env('SYSTEM_SUB', 'system');
$base = "{$systemSub}.{$base}";
// Domain anlegen/holen
$domain = Domain::firstOrCreate(
['domain' => $base],
['is_active' => true, 'is_system' => true]
);
// System Absender (no-reply) ohne Passwort (kein Login)
MailUser::firstOrCreate(
['email' => "no-reply@{$base}"],
[
'domain_id' => $domain->id,
'localpart' => 'no-reply',
'password_hash' => null,
'is_active' => true,
'is_system' => true,
'must_change_pw' => false,
'quota_mb' => 0,
]
);
// DKIM Key erzeugen, falls keiner aktiv existiert
if (! $domain->dkimKeys()->where('is_active', true)->exists()) {
[$privPem, $pubTxt] = $this->generateDkimKeyPair();
$selector = 'mwl1'; // frei wählbar, z. B. rotierend später
DkimKey::create([
'domain_id' => $domain->id,
'selector' => $selector,
'private_key_pem'=> $privPem,
'public_key_txt' => $pubTxt,
'is_active' => true,
]);
$this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}");
}
// SPF einfachen Default bauen
$serverIp = env('SERVER_IP'); // optional vom Installer rein schreiben
$parts = ['v=spf1','mx','a'];
if ($serverIp) $parts[] = "ip4:{$serverIp}";
$parts[] = '-all';
$spf = implode(' ', $parts);
SpfRecord::firstOrCreate(
['domain_id' => $domain->id, 'record_txt' => $spf],
['is_active' => true]
);
// DMARC vorsichtig starten (p=none)
$rua = "mailto:dmarc@{$base}";
$dmarc = DmarcRecord::firstOrCreate(
['domain_id' => $domain->id, 'policy' => 'none'],
['rua' => $rua, 'pct' => 100, 'record_txt' => "v=DMARC1; p=none; rua={$rua}; pct=100", 'is_active' => true]
);
$this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM eingetragen.");
$this->command->line("DNS-Hinweise:");
$this->printDnsHints($domain);
}
/** @return array{0:string privatePem,1:string publicTxt} */
private function generateDkimKeyPair(): array
{
$res = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($res, $privateKeyPem);
$details = openssl_pkey_get_details($res);
// $details['key'] ist PEM, wir brauchen Base64 ohne Header/Footers
$pubDer = $details['key'];
// Public PEM zu "p=" Wert (reines Base64) normalisieren
$pubTxt = trim(preg_replace('/-----(BEGIN|END) PUBLIC KEY-----|\s+/', '', $pubDer));
return [$privateKeyPem, $pubTxt];
}
private function printDnsHints(Domain $domain): void
{
$base = $domain->domain;
$dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
if ($dkim) {
$this->command->line(" • DKIM TXT @ {$dkim->selector}._domainkey.{$base}");
$this->command->line(" v=DKIM1; k=rsa; p={$dkim->public_key_txt}");
}
$spf = $domain->spf()->where('is_active', true)->latest()->first();
if ($spf) {
$this->command->line(" • SPF TXT @ {$base}");
$this->command->line(" {$spf->record_txt}");
}
$dmarc = $domain->dmarc()->where('is_active', true)->latest()->first();
if ($dmarc) {
$this->command->line(" • DMARC TXT @ _dmarc.{$base}");
$this->command->line(" " . ($dmarc->record_txt ?? $dmarc->renderTxt()));
}
}
}

9
package-lock.json generated
View File

@ -5,7 +5,8 @@
"packages": {
"": {
"dependencies": {
"@phosphor-icons/web": "^2.1.2"
"@phosphor-icons/web": "^2.1.2",
"jquery": "^3.7.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@ -1725,6 +1726,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/laravel-echo": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.4.tgz",

View File

@ -17,6 +17,7 @@
"vite": "^7.0.4"
},
"dependencies": {
"@phosphor-icons/web": "^2.1.2"
"@phosphor-icons/web": "^2.1.2",
"jquery": "^3.7.1"
}
}

View File

@ -1,7 +1,9 @@
@import 'tailwindcss';
/*@import "@plugins/Toastra/src/message.css";*/
@import '../js/plugins/GlassToastra/style.css';
@import "../fonts/BaiJamjuree/font.css";
@import "../fonts/Space/font.css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@ -10,6 +12,10 @@
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-bai: 'Bai Jamjuree';
--font-space: 'Space Age';
--color-glass-bg: rgba(17, 24, 39, 0.55);
--color-glass-border: rgba(255, 255, 255, 0.08);
--color-glass-light: rgba(31, 41, 55, 0.4);
@ -18,30 +24,290 @@
--color-accent-600: #0891b2; /* cyan-600 */
--color-accent-700: #0e7490; /* cyan-700 */
--color-ring: rgba(34, 197, 94, .35); /* ein Hauch Grün im Focus-Glow */
--sidebar-collapsed: 5.2rem; /* ~83px */
--sidebar-expanded: 16rem; /* 256px */
/* Optional: max-width Tokens */
--max-w-sb-col: 5.2rem;
--max-w-sb-exp: 16rem;
}
@utility w-sb-col {
width: var(--sidebar-collapsed);
}
@utility w-sb-exp {
width: var(--sidebar-expanded);
}
/* Max-width utilities, if du sie brauchst */
@utility max-w-sb-col {
max-width: var(--max-w-sb-col);
}
@utility max-w-sb-exp {
max-width: var(--max-w-sb-exp);
}
@layer components {
/* Hauptkarte entspricht dem helleren Glasslook deiner Server-Box */
.mw-card {
@apply rounded-2xl p-5 md:p-6
border border-white/10
bg-white/5 backdrop-blur-xl
shadow-[0_20px_60px_-20px_rgba(0,0,0,.6),inset_0_1px_0_rgba(255,255,255,.06)];
/* feiner Verlauf wie oben */
background-image: radial-gradient(120% 120% at 100% 0%, rgba(56, 189, 248, .08) 0%, transparent 60%),
radial-gradient(140% 140% at 0% 120%, rgba(16, 185, 129, .06) 0%, transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02));
}
/* Subkarte (kleinere Panels in einer Karte) */
.mw-subcard {
@apply rounded-xl p-4
border border-white/10
bg-white/5 backdrop-blur-xl;
}
.mw-title {
@apply text-white/90 font-semibold tracking-wide;
}
.mw-subtle {
@apply text-white/60 text-sm;
}
.mw-divider {
@apply border-t border-white/10 my-4;
}
}
/* ============ BOOT-STATE: keine Sprünge ============ */
/* Während booting KEINE Transitionen */
html[data-ui="booting"] * {
transition: none !important;
}
/* Labels, Carets, Submenüs in der Sidebar während booting NICHT zeigen */
html[data-ui="booting"] #sidebar .sidebar-label,
html[data-ui="booting"] #sidebar .sidebar-caret,
html[data-ui="booting"] #sidebar [data-submenu] {
display: none !important;
}
/* Mobil: Sidebar standardmäßig offcanvas (wegschieben) */
@media (max-width: 639.98px) {
#sidebar {
transform: translateX(-100%);
}
}
/* Tablet: Sidebar standardmäßig RAIL (schmal) */
@media (min-width: 640px) and (max-width: 1023.98px) {
#sidebar {
--sbw: 5.2rem;
width: var(--sbw);
max-width: var(--sbw);
}
/* Nur im RAIL-Zustand ausblenden falls du per JS .u-collapsed setzt */
#sidebar.u-collapsed .sidebar-label,
#sidebar.u-collapsed .sidebar-caret,
#sidebar.u-collapsed [data-submenu] {
display: none !important;
}
}
/* Desktop: Sidebar standardmäßig EXPANDED (breit) */
@media (min-width: 1024px) {
#sidebar {
--sb-w: 16rem;
width: var(--sb-w);
max-width: var(--sb-w);
}
}
/* ======== READY-STATE: Transitionen wieder an ========= */
/* Wenn JS fertig ist -> sanfte Transitions zulassen (ab jetzt darfs animieren) */
html[data-ui="ready"] #sidebar {
transition: width .2s ease, max-width .2s ease, transform .2s ease;
}
/* Rail-Zustand per Klasse (dein JS schaltet diese Klassen weiter) */
#sidebar.u-collapsed {
--sb-w: 5.2rem;
width: var(--sb-w);
max-width: var(--sb-w);
}
#sidebar.ut-expanded {
--sb-w: 16rem;
width: var(--sb-w);
max-width: var(--sb-w);
}
/* Rail: Labels/Carets/Submenüs IMMER ausblenden */
#sidebar.u-collapsed .sidebar-label,
#sidebar.u-collapsed .sidebar-caret,
#sidebar.u-collapsed [data-submenu] {
display: none !important;
}
/* Mobile Offcanvas: Klassen aus deinem JS */
#sidebar.translate-x-0 {
transform: translateX(0);
}
/* Sicherheitsnetz: [x-cloak] bleibt unsichtbar bis Alpine lädt */
[x-cloak] {
display: none !important;
}
/* === App Backdrop (türkis/glass) === */
.app-backdrop {
position: fixed;
inset: 0;
z-index: -10;
pointer-events: none;
background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%),
radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%),
linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);
}
.primary-btn {
@apply relative inline-flex items-center justify-center rounded-xl
px-6 py-2.5 text-sm font-medium text-white transition-all
bg-gradient-to-r from-[rgba(34,211,238,0.35)] to-[rgba(16,185,129,0.35)]
border border-white/10 shadow-[0_0_25px_rgba(16,185,129,0.08)]
hover:from-[rgba(34,211,238,0.55)] hover:to-[rgba(16,185,129,0.55)]
hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]
focus:outline-none focus:ring-2 focus:ring-[rgba(34,211,238,0.4)]
focus:ring-offset-0;
}
/* === Layout Breite der Sidebar per Variable steuern === */
:root {
--sb-w: 16rem;
}
/* Basiswert (expanded) */
/* Header + Main bekommen die gleiche linke Einrückung */
.header-shell,
.main-shell {
padding-left: var(--sb-w);
transition: padding-left .2s ease;
}
/* ---------- Desktop (>=1024) ---------- */
@media (min-width: 1024px) {
/* default expanded */
.main-shell {
--sb-w: 16rem;
}
.main-shell.rail {
--sb-w: 5.2rem;
}
/* wenn per JS .rail gesetzt wird */
/* Sidebar-Breite koppeln */
#sidebar {
--sbw: 16rem;
}
#sidebar.u-collapsed {
--sbw: 5.2rem;
}
}
/* ---------- Tablet (6401023) ---------- */
@media (min-width: 640px) and (max-width: 1023.98px) {
.main-shell {
--sb-w: 5.2rem;
}
/* standard rail */
.main-shell.ut-expanded {
--sb-w: 16rem;
}
/* bei expand */
#sidebar {
--sbw: 5.2rem;
}
#sidebar.ut-expanded {
--sbw: 16rem;
}
}
/* ---------- Mobile (<640) ---------- */
@media (max-width: 639.98px) {
.main-shell {
--sb-w: 0;
}
/* off-canvas → kein Padding */
}
/* Sidebar selbst nimmt die variable Breite */
#sidebar {
width: var(--sbw, 16rem);
max-width: var(--sbw, 16rem);
transition: width .2s ease, max-width .2s ease, transform .2s ease;
}
/* Rail: Label, Carets, Submenüs ausblenden (kein Springen) */
#sidebar.u-collapsed .sidebar-label {
display: none !important;
}
#sidebar.u-collapsed .sidebar-caret {
display: none !important;
}
#sidebar.u-collapsed [data-submenu] {
display: none !important;
}
/* Anti-FOUC für Alpine */
[x-cloak] {
display: none !important;
}
/* Optional: beim First Paint Animationen unterdrücken (falls du body.no-animate setzt) */
.no-animate * {
transition: none !important;
}
/* Reusable utilities */
.glass-card {
@apply bg-glass-bg/70 backdrop-blur-md border border-glass-border rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,.25)];
}
.glass-input {
@apply bg-glass-light/50 border border-glass-border rounded-lg text-gray-100 placeholder-gray-400
focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)] focus:border-cyan-500/60
focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)] focus:border-cyan-500/60
transition-colors;
}
.btn-primary {
@apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-white font-medium
bg-gradient-to-b from-cyan-500 to-cyan-600 hover:from-cyan-400 hover:to-cyan-600
focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)]
focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)]
shadow-[inset_0_1px_0_rgba(255,255,255,.08),0_8px_20px_rgba(0,0,0,.25)];
}
.badge {
@apply text-xs px-2 py-0.5 rounded-md border border-glass-border text-gray-300/80 bg-glass-light/40;
}
.card-title {
@apply text-gray-100/95 font-semibold tracking-wide;
}
.card-subtle {
@apply text-gray-300/70 text-sm leading-relaxed;
}
@ -50,9 +316,11 @@
.input-error {
@apply border-red-400/50 focus:border-red-400/70 focus:ring-red-400/30;
}
.input-success {
@apply border-emerald-400/50 focus:border-emerald-400/70 focus:ring-emerald-400/30;
}
.input-disabled {
@apply opacity-60 cursor-not-allowed;
}
@ -61,9 +329,11 @@
.field-label {
@apply block text-sm text-gray-300 mb-1;
}
.field-error {
@apply mt-1 text-xs text-red-300;
}
.field-help {
@apply mt-1 text-xs text-gray-400;
}
@ -71,7 +341,7 @@
/* Select im gleichen Look wie .glass-input */
.glass-select {
@apply bg-glass-light/50 border border-glass-border rounded-lg text-gray-100
focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)]
focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)]
focus:border-cyan-500/60 transition-colors;
}
@ -79,11 +349,14 @@
.input-with-icon {
@apply relative;
}
.input-with-icon > .icon-left {
@apply absolute inset-y-0 left-3 flex items-center text-gray-400;
}
.input-with-icon > input {
@apply pl-10; /* Platz für Icon */
@apply pl-10;
/* Platz für Icon */
}
/* Karten-Interaktion (leichtes Hover-Lift) */
@ -92,15 +365,16 @@
}
/* Buttons zusätzliche Varianten */
.btn-ghost {
.ghost-btn {
@apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-gray-200
bg-white/5 border border-white/10 hover:bg-white/10
focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)];
focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)];
}
.btn-danger {
.danger-btn {
@apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-white
bg-gradient-to-b from-rose-500 to-rose-600 hover:from-rose-400 hover:to-rose-600
focus:outline-none focus:ring-4 focus:ring-rose-400/40;
focus:outline-none focus:ring-0 focus:ring-rose-400/40;
}
/* Checkbox/Switch im Glas-Look */
@ -114,3 +388,160 @@
.divider {
@apply border-t border-glass-border/80 my-6;
}
button {
cursor: pointer;
}
.chip {
@apply flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 backdrop-blur px-3 py-2 hover:bg-white/10 transition;
}
.badge {
@apply ml-1 rounded-full px-2 py-0.5 text-xs font-medium border border-white/10;
}
@layer components {
.section-title {
@apply text-white/70 text-sm font-medium uppercase tracking-wide;
}
.btn-surface {
@apply inline-flex items-center gap-2 rounded-lg border border-sky-400/20 bg-sky-600/20
hover:bg-sky-600/30 text-sky-100 text-sm px-3 py-1.5 transition;
}
.input-surface {
@apply bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white/90 placeholder-white/30;
}
.checkbox-accent {
@apply h-4 w-4 rounded border border-white/20 bg-white/5 text-sky-400 focus:ring-0;
}
.toggle-tile {
@apply flex items-center gap-3 p-3 rounded-xl border border-white/10 bg-white/5;
}
.host-row {
@apply flex items-stretch rounded-xl overflow-hidden border border-white/10 bg-white/5;
}
.host-prefix {
@apply px-2.5 flex items-center text-white/40 text-xs border-r border-white/10;
}
.host-input {
@apply flex-1 bg-transparent px-3 py-2 outline-none text-white/90;
}
.host-suffix {
@apply px-2.5 flex items-center text-white/50 bg-white/5 border-l border-white/10;
}
.host-fqdn {
@apply text-xs text-white/60 mt-1 font-mono tabular-nums;
}
.btn-primary {
@apply inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-white/80 hover:bg-white/[0.12] hover:text-white;
}
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="password"],
textarea,
select {
@apply outline-none transition
focus:border-sky-400/50 focus:ring-0 focus:ring-sky-400/20
}
@layer components {
.nx-card {
@apply rounded-2xl p-6 md:p-7
bg-white/5 backdrop-blur-xl
border border-white/10
shadow-[0_20px_60px_-20px_rgba(0,0,0,.6),inset_0_1px_0_rgba(255,255,255,.06)];
}
.nx-chip {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm
bg-white/8 text-white/90 border border-white/10;
}
.nx-subtle {
@apply text-white/80 leading-relaxed;
}
.nx-input {
@apply w-full rounded-xl px-3.5 py-3 text-white/95
bg-[rgba(12,18,28,.55)]
border border-white/10
shadow-[inset_0_1px_0_rgba(255,255,255,.06)]
outline-none transition
focus:border-sky-400/50 focus:ring-0 focus:ring-sky-400/20;
}
.nx-label {
@apply block text-xs text-white/70 mb-1;
}
.nx-eye {
@apply absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-lg
hover:bg-white/5 focus:outline-none focus:ring-0 focus:ring-sky-400/20;
}
.nx-check {
@apply h-4 w-4 rounded border-white/20 bg-white/10
focus:ring-2 focus:ring-sky-400/30;
}
.nx-btn {
@apply inline-flex items-center justify-center rounded-xl py-3 font-medium text-white
bg-gradient-to-r from-[#6d7cff] via-[#5aa7ff] to-[#39d0ff]
shadow-[inset_0_1px_0_rgba(255,255,255,.12),0_10px_30px_-8px_rgba(56,189,248,.45)]
hover:from-[#7e89ff] hover:to-[#46d7ff]
focus:outline-none focus:ring-0 focus:ring-sky-400/25;
}
.nx-btn-ghost {
@apply rounded-xl px-4 py-2.5 text-white/85 border border-white/10 bg-white/5
hover:bg-white/10 focus:outline-none focus:ring-0 focus:ring-sky-400/20;
}
.nx-link {
@apply text-sky-300 hover:text-sky-200 transition;
}
.nx-divider {
@apply relative text-center text-white/50 text-xs mt-7 mb-2;
}
.nx-divider::before, .nx-divider::after {
content: "";
@apply absolute top-1/2 w-[38%] h-px bg-white/10;
}
.nx-divider::before {
left: 0;
}
.nx-divider::after {
right: 0;
}
.nx-alert {
@apply flex gap-3 items-start rounded-xl p-3.5
border border-rose-400/25 bg-rose-400/10 text-rose-50;
}
hr, .hr {
border-color: var(--color-slate-700);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,97 @@
/* bai-jamjuree-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 200;
src: url('bai-jamjuree-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 200;
src: url('bai-jamjuree-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 300;
src: url('bai-jamjuree-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 300;
src: url('bai-jamjuree-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 400;
src: url('bai-jamjuree-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 400;
src: url('bai-jamjuree-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 500;
src: url('bai-jamjuree-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 500;
src: url('bai-jamjuree-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 600;
src: url('bai-jamjuree-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 600;
src: url('bai-jamjuree-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: normal;
font-weight: 700;
src: url('bai-jamjuree-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* bai-jamjuree-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Bai Jamjuree';
font-style: italic;
font-weight: 700;
src: url('bai-jamjuree-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -0,0 +1,7 @@
@font-face {
font-family: 'space';
src: url('space age.otf') format('opentype'),
url('space age.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More