Hinzufügen der Seiten Seccrity und Domains
parent
5760d69902
commit
7af7b2fb2b
|
|
@ -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');
|
||||
|
||||
// Let’s 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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]; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
// ]);
|
||||
// }
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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),
|
||||
// ]);
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => 'Let’s Encrypt wird ausgeführt…']);
|
||||
$this->syncCache($task);
|
||||
$this->emit($task, 'running', 'Let’s 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',
|
||||
'Let’s 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' => 'Let’s 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);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 5–10 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,
|
||||
// ];
|
||||
// }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')) ;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
// }
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => 'Let’s Encrypt (Staging)'],
|
||||
['value' => 'production', 'label' => 'Let’s 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -123,4 +123,6 @@ return [
|
|||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
'version' => env('APP_VERSION', '1.0.0')
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -111,5 +111,4 @@ return [
|
|||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -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),
|
||||
]],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -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'],
|
||||
],
|
||||
]
|
||||
],
|
||||
];
|
||||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 darf’s 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 (640–1023) ---------- */
|
||||
@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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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+ */
|
||||
}
|
||||
|
||||
|
|
@ -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
Loading…
Reference in New Issue