12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
57
app/Services/Api/ApiAccessEligibilityService.php
Normal file
57
app/Services/Api/ApiAccessEligibilityService.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
|
||||
class ApiAccessEligibilityService
|
||||
{
|
||||
public function canCreateToken(User $user): bool
|
||||
{
|
||||
return $this->denialReason($user) === null;
|
||||
}
|
||||
|
||||
public function denialReason(User $user): ?string
|
||||
{
|
||||
if (! $user->is_active) {
|
||||
return 'Ihr Benutzerkonto ist nicht aktiv. API-Tokens können nur für aktive Benutzer erstellt werden.';
|
||||
}
|
||||
|
||||
if ($this->hasActivePaymentOption($user) || $this->hasPaidLegacyInvoice($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'API-Tokens werden erst freigeschaltet, wenn ein aktiver Zahlungsstatus oder freigegebener Bestandsschutz vorliegt.';
|
||||
}
|
||||
|
||||
private function hasActivePaymentOption(User $user): bool
|
||||
{
|
||||
return $user->userPaymentOptions()
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->where(function ($active): void {
|
||||
$active
|
||||
->where('status', UserPaymentOptionStatus::Active->value)
|
||||
->whereDate('current_period_start', '<=', now())
|
||||
->whereDate('current_period_end', '>=', now());
|
||||
})
|
||||
->orWhere(function ($grandfathered): void {
|
||||
$grandfathered
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereDate('grandfathered_until', '>=', now());
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasPaidLegacyInvoice(User $user): bool
|
||||
{
|
||||
$latestInvoice = $user->legacyInvoices()
|
||||
->latest('invoice_date')
|
||||
->latest('id')
|
||||
->first(['id', 'status']);
|
||||
|
||||
return $latestInvoice?->status === 'paid';
|
||||
}
|
||||
}
|
||||
106
app/Services/Api/ApiUsageReporter.php
Normal file
106
app/Services/Api/ApiUsageReporter.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Models\ApiUsageLog;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ApiUsageReporter
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function report(?string $from = null, ?string $to = null, ?int $userId = null, ?int $statusCode = null, int $top = 10): array
|
||||
{
|
||||
$baseQuery = $this->baseQuery($from, $to, $userId, $statusCode);
|
||||
|
||||
$totalRequests = (clone $baseQuery)->count();
|
||||
|
||||
return [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'filters' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'user_id' => $userId,
|
||||
'status_code' => $statusCode,
|
||||
'top' => $top,
|
||||
],
|
||||
'summary' => [
|
||||
'total_requests' => $totalRequests,
|
||||
'unique_users' => (clone $baseQuery)->whereNotNull('user_id')->distinct('user_id')->count('user_id'),
|
||||
'unique_tokens' => (clone $baseQuery)->whereNotNull('personal_access_token_id')->distinct('personal_access_token_id')->count('personal_access_token_id'),
|
||||
'successful_requests' => (clone $baseQuery)->whereBetween('status_code', [200, 299])->count(),
|
||||
'client_error_requests' => (clone $baseQuery)->whereBetween('status_code', [400, 499])->count(),
|
||||
'server_error_requests' => (clone $baseQuery)->whereBetween('status_code', [500, 599])->count(),
|
||||
'average_duration_ms' => $totalRequests > 0 ? round((float) (clone $baseQuery)->avg('duration_ms'), 2) : 0.0,
|
||||
],
|
||||
'top_paths' => $this->topRows($baseQuery, 'path', $top),
|
||||
'status_codes' => $this->topRows($baseQuery, 'status_code', $top),
|
||||
'top_users' => $this->topRows($baseQuery, 'user_id', $top),
|
||||
'top_tokens' => $this->topRows($baseQuery, 'personal_access_token_id', $top),
|
||||
'recent_requests' => $this->recentRequests($baseQuery, $top),
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery(?string $from, ?string $to, ?int $userId, ?int $statusCode): Builder
|
||||
{
|
||||
return ApiUsageLog::query()
|
||||
->when($from !== null, fn (Builder $query) => $query->where('requested_at', '>=', $from))
|
||||
->when($to !== null, fn (Builder $query) => $query->where('requested_at', '<=', $to))
|
||||
->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId))
|
||||
->when($statusCode !== null, fn (Builder $query) => $query->where('status_code', $statusCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: mixed, requests: int}>
|
||||
*/
|
||||
private function topRows(Builder $baseQuery, string $column, int $limit): array
|
||||
{
|
||||
return (clone $baseQuery)
|
||||
->selectRaw("{$column} as value, count(*) as requests")
|
||||
->whereNotNull($column)
|
||||
->groupBy($column)
|
||||
->orderByDesc('requests')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'value' => $row->value,
|
||||
'requests' => (int) $row->requests,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function recentRequests(Builder $baseQuery, int $limit): array
|
||||
{
|
||||
return (clone $baseQuery)
|
||||
->latest('requested_at')
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'personal_access_token_id',
|
||||
'method',
|
||||
'path',
|
||||
'route_name',
|
||||
'status_code',
|
||||
'duration_ms',
|
||||
'requested_at',
|
||||
])
|
||||
->map(fn (ApiUsageLog $log): array => [
|
||||
'id' => $log->id,
|
||||
'user_id' => $log->user_id,
|
||||
'personal_access_token_id' => $log->personal_access_token_id,
|
||||
'method' => $log->method,
|
||||
'path' => $log->path,
|
||||
'route_name' => $log->route_name,
|
||||
'status_code' => $log->status_code,
|
||||
'duration_ms' => $log->duration_ms,
|
||||
'requested_at' => $log->requested_at?->toIso8601String(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
184
app/Services/Api/LegacyApiAccessLogAnalyzer.php
Normal file
184
app/Services/Api/LegacyApiAccessLogAnalyzer.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LegacyApiAccessLogAnalyzer
|
||||
{
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function analyze(array $paths, int $top = 20): array
|
||||
{
|
||||
$report = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'input_paths' => $paths,
|
||||
'missing_paths' => [],
|
||||
'summary' => [
|
||||
'files' => 0,
|
||||
'total_lines' => 0,
|
||||
'matched_requests' => 0,
|
||||
'legacy_key_requests' => 0,
|
||||
'unique_client_ips' => 0,
|
||||
'unique_api_key_fingerprints' => 0,
|
||||
],
|
||||
'endpoints' => [],
|
||||
'client_ips' => [],
|
||||
'api_key_fingerprints' => [],
|
||||
'status_codes' => [],
|
||||
'sample_requests' => [],
|
||||
];
|
||||
|
||||
foreach ($this->expandPaths($paths) as $path) {
|
||||
if (! is_readable($path)) {
|
||||
$report['missing_paths'][] = $path;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$report['summary']['files']++;
|
||||
$file = new \SplFileObject($path);
|
||||
|
||||
while (! $file->eof()) {
|
||||
$line = trim((string) $file->fgets());
|
||||
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$report['summary']['total_lines']++;
|
||||
$request = $this->parseLine($line);
|
||||
|
||||
if ($request === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = $this->legacyEndpoint($request['path']);
|
||||
|
||||
if ($endpoint === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->recordMatch($report, $request, $endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
$report['summary']['unique_client_ips'] = count($report['client_ips']);
|
||||
$report['summary']['unique_api_key_fingerprints'] = count($report['api_key_fingerprints']);
|
||||
|
||||
$this->sortAndLimit($report, $top);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandPaths(array $paths): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$matches = Str::contains($path, ['*', '?', '['])
|
||||
? glob($path) ?: []
|
||||
: [$path];
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$expanded[] = $match;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null}|null
|
||||
*/
|
||||
private function parseLine(string $line): ?array
|
||||
{
|
||||
if (! preg_match('/^(?P<client_ip>\S+).*"(?P<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(?P<target>\S+)[^"]*"\s+(?P<status>\d{3}|-)?/', $line, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = $matches['target'];
|
||||
$parsedUrl = parse_url($target);
|
||||
$path = Arr::get($parsedUrl, 'path', Str::before($target, '?'));
|
||||
$query = Arr::get($parsedUrl, 'query', Str::contains($target, '?') ? Str::after($target, '?') : '');
|
||||
|
||||
return [
|
||||
'client_ip' => $matches['client_ip'],
|
||||
'method' => $matches['method'],
|
||||
'target' => $target,
|
||||
'path' => $path,
|
||||
'query' => $query,
|
||||
'status' => $matches['status'] !== '-' ? ($matches['status'] ?? null) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function legacyEndpoint(string $path): ?string
|
||||
{
|
||||
$normalizedPath = Str::of($path)
|
||||
->replace('/index.php', '')
|
||||
->trim('/')
|
||||
->toString();
|
||||
|
||||
if (! preg_match('~(?:^|/)(pressrelease|pressreleaseimage|company|category|newsletter)/([a-zA-Z0-9_-]+)~', $normalizedPath, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "{$matches[1]}/{$matches[2]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @param array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null} $request
|
||||
*/
|
||||
private function recordMatch(array &$report, array $request, string $endpoint): void
|
||||
{
|
||||
$report['summary']['matched_requests']++;
|
||||
$report['endpoints'][$endpoint] = ($report['endpoints'][$endpoint] ?? 0) + 1;
|
||||
$report['client_ips'][$request['client_ip']] = ($report['client_ips'][$request['client_ip']] ?? 0) + 1;
|
||||
|
||||
if ($request['status'] !== null) {
|
||||
$report['status_codes'][$request['status']] = ($report['status_codes'][$request['status']] ?? 0) + 1;
|
||||
}
|
||||
|
||||
parse_str($request['query'], $query);
|
||||
|
||||
if (filled($query['api_key'] ?? null)) {
|
||||
$report['summary']['legacy_key_requests']++;
|
||||
$fingerprint = hash('sha256', (string) $query['api_key']);
|
||||
$report['api_key_fingerprints'][$fingerprint] = ($report['api_key_fingerprints'][$fingerprint] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (count($report['sample_requests']) < 10) {
|
||||
$report['sample_requests'][] = [
|
||||
'method' => $request['method'],
|
||||
'target' => $this->maskApiKey($request['target']),
|
||||
'endpoint' => $endpoint,
|
||||
'client_ip' => $request['client_ip'],
|
||||
'status' => $request['status'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function maskApiKey(string $target): string
|
||||
{
|
||||
return preg_replace('/([?&]api_key=)[^&]+/', '$1***', $target) ?? $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function sortAndLimit(array &$report, int $top): void
|
||||
{
|
||||
foreach (['endpoints', 'client_ips', 'api_key_fingerprints', 'status_codes'] as $key) {
|
||||
arsort($report[$key]);
|
||||
$report[$key] = array_slice($report[$key], 0, $top, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Services/Api/LegacyApiCustomerReporter.php
Normal file
143
app/Services/Api/LegacyApiCustomerReporter.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class LegacyApiCustomerReporter
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function report(string $portal = 'all'): array
|
||||
{
|
||||
$users = $this->candidateUsers($portal);
|
||||
$rows = $users
|
||||
->map(fn (User $user): array => $this->mapUser($user))
|
||||
->values();
|
||||
|
||||
return [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'portal' => $portal,
|
||||
'source' => [
|
||||
'candidate_rule' => 'users.registration_type = apiuser OR Spatie role api-only',
|
||||
'eligibility_rule' => 'user is active AND latest legacy invoice status is paid',
|
||||
'missing_data' => [
|
||||
'Legacy api_key values are intentionally not imported.',
|
||||
'Legacy apiReadAccess/apiWriteAccess assignments are not preserved per-user beyond mapped roles.',
|
||||
'Users without archived legacy invoices require manual review.',
|
||||
],
|
||||
],
|
||||
'summary' => [
|
||||
'total_candidates' => $rows->count(),
|
||||
'eligible' => $rows->where('classification', 'eligible')->count(),
|
||||
'needs_review' => $rows->where('classification', 'needs_review')->count(),
|
||||
'blocked' => $rows->where('classification', 'blocked')->count(),
|
||||
],
|
||||
'customers' => $rows->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
private function candidateUsers(string $portal): Collection
|
||||
{
|
||||
return User::query()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'portal',
|
||||
'registration_type',
|
||||
'is_active',
|
||||
'is_super_admin',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
'last_login_at',
|
||||
])
|
||||
->with([
|
||||
'roles:id,name',
|
||||
'legacyInvoices' => fn ($query) => $query
|
||||
->select([
|
||||
'id',
|
||||
'user_id',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
'number',
|
||||
'status',
|
||||
'invoice_date',
|
||||
'paid_at',
|
||||
'total_cents',
|
||||
])
|
||||
->latest('invoice_date')
|
||||
->latest('id'),
|
||||
])
|
||||
->where(function ($query): void {
|
||||
$query->where('registration_type', RegistrationType::ApiUser->value)
|
||||
->orWhereHas('roles', fn ($roles) => $roles->where('name', 'api-only'));
|
||||
})
|
||||
->when($portal !== 'all', fn ($query) => $query->where('portal', $portal))
|
||||
->orderBy('email')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapUser(User $user): array
|
||||
{
|
||||
$latestInvoice = $user->legacyInvoices->first();
|
||||
$classification = $this->classification($user, $latestInvoice);
|
||||
|
||||
return [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'portal' => $user->portal?->value,
|
||||
'legacy' => [
|
||||
'portal' => $user->legacy_portal,
|
||||
'id' => $user->legacy_id,
|
||||
],
|
||||
'registration_type' => $user->registration_type?->value,
|
||||
'roles' => $user->roles->pluck('name')->values()->all(),
|
||||
'is_active' => $user->is_active,
|
||||
'is_super_admin' => $user->is_super_admin,
|
||||
'last_login_at' => $user->last_login_at?->toDateTimeString(),
|
||||
'latest_legacy_invoice' => $latestInvoice ? [
|
||||
'id' => $latestInvoice->id,
|
||||
'legacy' => [
|
||||
'portal' => $latestInvoice->legacy_portal?->value,
|
||||
'id' => $latestInvoice->legacy_id,
|
||||
],
|
||||
'number' => $latestInvoice->number,
|
||||
'status' => $latestInvoice->status,
|
||||
'invoice_date' => $latestInvoice->invoice_date?->toDateString(),
|
||||
'paid_at' => $latestInvoice->paid_at?->toDateTimeString(),
|
||||
'total_cents' => $latestInvoice->total_cents,
|
||||
] : null,
|
||||
'classification' => $classification,
|
||||
'recommended_action' => match ($classification) {
|
||||
'eligible' => 'invite_to_generate_sanctum_token',
|
||||
'needs_review' => 'manual_billing_review_required',
|
||||
default => 'do_not_grant_api_access',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private function classification(User $user, ?LegacyInvoice $latestInvoice): string
|
||||
{
|
||||
if (! $user->is_active) {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if ($latestInvoice === null) {
|
||||
return 'needs_review';
|
||||
}
|
||||
|
||||
return $latestInvoice->status === 'paid' ? 'eligible' : 'blocked';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue