Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class GeocodingService
{
/** Erlaubte Geocoding-Hosts (SSRF-Schutz) */
private const ALLOWED_HOSTS = [
'nominatim.openstreetmap.org',
'photon.komoot.io',
];
public function search(string $query): array
{
$baseUrl = config('nominatim.base_url');
// SSRF-Schutz: Nur erlaubte Hosts und HTTPS
$parsedHost = parse_url($baseUrl, PHP_URL_HOST);
if (!$parsedHost || !in_array($parsedHost, self::ALLOWED_HOSTS)) {
return [];
}
if (parse_url($baseUrl, PHP_URL_SCHEME) !== 'https') {
return [];
}
$cacheKey = 'geocode:' . hash('sha256', mb_strtolower(trim($query)));
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$response = Http::withHeaders([
'User-Agent' => config('nominatim.user_agent'),
])->timeout(5)->get($baseUrl . '/search', [
'q' => $query,
'format' => 'json',
'addressdetails' => 1,
'namedetails' => 1,
'limit' => 5,
'countrycodes' => 'de,at,ch',
'accept-language' => 'de',
]);
// Fehlerhafte Responses nicht cachen (V20)
if ($response->failed()) {
return [];
}
$results = collect($response->json())->map(function ($item) {
$addr = $item['address'] ?? [];
// Structured address from components
$street = trim(($addr['road'] ?? '') . ' ' . ($addr['house_number'] ?? ''));
$postcode = $addr['postcode'] ?? '';
$city = $addr['city'] ?? $addr['town'] ?? $addr['village'] ?? $addr['municipality'] ?? '';
$name = $item['namedetails']['name'] ?? '';
// Build formatted address line
$parts = array_filter([$street, implode(' ', array_filter([$postcode, $city]))]);
$formatted = implode(', ', $parts);
return [
'display_name' => $item['display_name'],
'formatted_address' => $formatted ?: $item['display_name'],
'name' => $name,
'street' => $street,
'postcode' => $postcode,
'city' => $city,
'lat' => $item['lat'],
'lon' => $item['lon'],
'type' => $item['type'] ?? '',
];
})->toArray();
Cache::put($cacheKey, $results, 86400);
return $results;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use HTMLPurifier;
use HTMLPurifier_Config;
class HtmlSanitizerService
{
private HTMLPurifier $purifier;
public function __construct()
{
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,b,em,i,u,ul,ol,li,a[href|target],h2[id],h3[id],h4[id],blockquote,span[style]');
$config->set('CSS.AllowedProperties', 'color,background-color');
$config->set('HTML.TargetBlank', true);
$config->set('AutoFormat.RemoveEmpty', true);
// DOM-Clobbering-Schutz: IDs in User-Content prefixen (V18)
$config->set('Attr.IDPrefix', 'uc-');
$config->set('Cache.SerializerPath', storage_path('app/purifier'));
$this->purifier = new HTMLPurifier($config);
}
public function sanitize(string $dirtyHtml): string
{
return $this->purifier->purify($dirtyHtml);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services;
use App\Enums\UserRole;
use App\Models\Invitation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class InvitationService
{
public function createInvitation(array $data, User $admin): Invitation
{
$rawToken = bin2hex(random_bytes(32));
$invitation = new Invitation([
'email' => $data['email'] ?? null,
'expires_at' => now()->addDays((int) ($data['expires_in_days'] ?? 7)),
'created_at' => now(),
]);
// Token gehasht speichern — Klartext nur in der URL (V05)
$invitation->token = hash('sha256', $rawToken);
$invitation->created_by = $admin->id;
$invitation->save();
if (!empty($data['player_ids'])) {
$invitation->players()->attach($data['player_ids']);
}
// raw_token für die URL-Generierung bereitstellen (nicht persistiert)
$invitation->raw_token = $rawToken;
return $invitation;
}
public function redeemInvitation(Invitation $invitation, array $userData): User
{
return DB::transaction(function () use ($invitation, $userData) {
$user = User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => $userData['password'],
]);
$user->is_active = true;
$user->role = UserRole::User;
$user->save();
// Eltern-Kind-Zuordnungen aus der Einladung übernehmen
foreach ($invitation->players as $player) {
$user->children()->attach($player->id, [
'created_at' => now(),
]);
}
$invitation->accepted_at = now();
$invitation->save();
return $user;
});
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SupportApiService
{
private ?array $installedData = null;
// ─── Registration ────────────────────────────────────
public function register(array $data): ?array
{
try {
$response = $this->httpClient()
->post('/register', $data);
if ($response->successful()) {
$result = $response->json();
$this->saveCredentials(
$result['installation_id'] ?? '',
$result['api_token'] ?? ''
);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('Support API registration failed: ' . $e->getMessage());
return null;
}
}
public function isRegistered(): bool
{
$data = $this->readInstalled();
return !empty($data['installation_id']) && !empty($data['api_token']);
}
// ─── License ─────────────────────────────────────────
public function validateLicense(string $key): ?array
{
try {
$response = $this->authenticatedClient()
->post('/license/validate', ['license_key' => $key]);
if ($response->successful()) {
$result = $response->json();
Cache::put('support.license_valid', $result['valid'] ?? false, 86400);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('License validation failed: ' . $e->getMessage());
return null;
}
}
// ─── Updates ─────────────────────────────────────────
public function checkForUpdate(bool $force = false): ?array
{
$cacheKey = 'support.update_check';
if (!$force && Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
try {
$params = [
'current_version' => config('app.version'),
'app_name' => \App\Models\Setting::get('app_name', config('app.name')),
];
$logoUrl = $this->getLogoUrl();
if ($logoUrl) {
$params['logo_url'] = $logoUrl;
}
$response = $this->authenticatedClient()
->get('/version/check', $params);
if ($response->successful()) {
$result = $response->json();
Cache::put($cacheKey, $result, 86400);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('Update check failed: ' . $e->getMessage());
return null;
}
}
public function hasUpdate(): bool
{
$cached = Cache::get('support.update_check');
if (!$cached) {
return false;
}
return version_compare($cached['latest_version'] ?? '0.0.0', config('app.version'), '>');
}
// ─── Tickets ─────────────────────────────────────────
public function getTickets(): ?array
{
try {
$response = $this->authenticatedClient()->get('/tickets');
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning('Ticket list fetch failed: ' . $e->getMessage());
return null;
}
}
public function getTicket(int $id): ?array
{
try {
$response = $this->authenticatedClient()->get("/tickets/{$id}");
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning("Ticket #{$id} fetch failed: " . $e->getMessage());
return null;
}
}
public function createTicket(array $data): ?array
{
try {
$response = $this->authenticatedClient()->post('/tickets', $data);
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning('Ticket creation failed: ' . $e->getMessage());
return null;
}
}
public function replyToTicket(int $id, array $data): ?array
{
try {
$response = $this->authenticatedClient()
->post("/tickets/{$id}/messages", $data);
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning("Ticket #{$id} reply failed: " . $e->getMessage());
return null;
}
}
// ─── System Info ─────────────────────────────────────
public function getSystemInfo(): array
{
return [
'app_version' => config('app.version'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'db_driver' => config('database.default'),
'locale' => app()->getLocale(),
'os' => PHP_OS,
];
}
public function getLogoUrl(): ?string
{
$favicon = \App\Models\Setting::get('app_favicon');
if ($favicon) {
return rtrim(config('app.url'), '/') . '/storage/' . $favicon;
}
return null;
}
// ─── Storage/Installed Access ────────────────────────
public function readInstalled(): array
{
if ($this->installedData !== null) {
return $this->installedData;
}
$path = storage_path('installed');
if (!file_exists($path)) {
return $this->installedData = [];
}
$data = json_decode(file_get_contents($path), true);
return $this->installedData = is_array($data) ? $data : [];
}
// ─── Private Helpers ─────────────────────────────────
private function httpClient(): \Illuminate\Http\Client\PendingRequest
{
$apiUrl = config('support.api_url');
// SSRF-Schutz: Nur HTTPS und keine privaten IPs (T06)
$parsed = parse_url($apiUrl);
$scheme = $parsed['scheme'] ?? '';
$host = $parsed['host'] ?? '';
if ($scheme !== 'https') {
throw new \RuntimeException('Support API URL must use HTTPS.');
}
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new \RuntimeException('Support API URL must not resolve to a private/reserved IP.');
}
// DNS-Rebinding verhindern: aufgelöste IP direkt verwenden (V07)
$resolvedUrl = str_replace($host, $ip, $apiUrl);
return Http::baseUrl($resolvedUrl)
->timeout(config('support.timeout', 10))
->connectTimeout(config('support.connect_timeout', 5))
->withHeaders(['Accept' => 'application/json', 'Host' => $host]);
}
private function authenticatedClient(): \Illuminate\Http\Client\PendingRequest
{
$token = $this->readInstalled()['api_token'] ?? '';
return $this->httpClient()->withToken($token);
}
private function saveCredentials(string $installationId, string $apiToken): void
{
$path = storage_path('installed');
$data = $this->readInstalled();
$data['installation_id'] = $installationId;
$data['api_token'] = $apiToken;
$data['registered_at'] = now()->toIso8601String();
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
chmod($path, 0600);
// Reset memoized data
$this->installedData = $data;
}
}