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:
83
app/Services/GeocodingService.php
Executable file
83
app/Services/GeocodingService.php
Executable 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;
|
||||
}
|
||||
}
|
||||
30
app/Services/HtmlSanitizerService.php
Executable file
30
app/Services/HtmlSanitizerService.php
Executable 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);
|
||||
}
|
||||
}
|
||||
62
app/Services/InvitationService.php
Executable file
62
app/Services/InvitationService.php
Executable 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
259
app/Services/SupportApiService.php
Normal file
259
app/Services/SupportApiService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user