12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use stdClass;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$stats = app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::DashboardStats, AdminPerformanceCache::StatsTtl, fn (): array => $this->stats());
|
||||
|
||||
return view('admin.dashboard', [
|
||||
'stats' => $stats,
|
||||
'recentPRs' => PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name', 'user:id,name'])
|
||||
->latest('created_at')
|
||||
->limit(8)
|
||||
->get(['id', 'title', 'status', 'portal', 'company_id', 'user_id', 'created_at']),
|
||||
'pendingReviews' => PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name'])
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->latest('created_at')
|
||||
->limit(5)
|
||||
->get(['id', 'title', 'company_id', 'portal', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{press_releases: array{total: int, published: int, review: int, draft: int}, companies: int, contacts: int, users: int, newsletter: int}
|
||||
*/
|
||||
private function stats(): array
|
||||
{
|
||||
$pressReleaseStats = PressRelease::withoutGlobalScopes()
|
||||
->toBase()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->first();
|
||||
|
||||
return [
|
||||
'press_releases' => $this->normalizePressReleaseStats($pressReleaseStats),
|
||||
'companies' => Company::withoutGlobalScopes()->count(),
|
||||
'contacts' => Contact::withoutGlobalScopes()->count(),
|
||||
'users' => User::query()->toBase()->count('*'),
|
||||
'newsletter' => NewsletterSubscription::withoutGlobalScopes()
|
||||
->where('is_confirmed', true)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, published: int, review: int, draft: int}
|
||||
*/
|
||||
private function normalizePressReleaseStats(?stdClass $stats): array
|
||||
{
|
||||
return [
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
'review' => (int) ($stats->review ?? 0),
|
||||
'draft' => (int) ($stats->draft ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class LeaveImpersonationController extends Controller
|
||||
{
|
||||
public function __invoke(UserImpersonation $impersonation): RedirectResponse
|
||||
{
|
||||
$admin = $impersonation->stop();
|
||||
|
||||
if ($admin !== null) {
|
||||
return redirect()
|
||||
->route('admin.users.index')
|
||||
->with('status', __('Erfolgreich zurück zum Admin-Account gewechselt.'));
|
||||
}
|
||||
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return CategoryResource::collection($categories);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CompanyResource;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||
|
||||
$companies = Company::withoutGlobalScopes()
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return CompanyResource::collection($companies);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $company): CompanyResource
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||
|
||||
$company = Company::withoutGlobalScopes()
|
||||
->whereKey($company)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->first();
|
||||
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
return CompanyResource::make($company);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function destroy(string $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewsletterSubscriptionController extends Controller
|
||||
{
|
||||
public function store(SubscribeNewsletterRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'portal' => $validated['portal'],
|
||||
'email' => mb_strtolower($validated['email']),
|
||||
],
|
||||
[
|
||||
'user_id' => $request->user()->id,
|
||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'ip_address' => $request->ip(),
|
||||
'is_confirmed' => false,
|
||||
'confirmation_token' => Str::random(32),
|
||||
'subscribed_at' => now(),
|
||||
'unsubscribed_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Newsletter subscription created.',
|
||||
'data' => [
|
||||
'id' => $subscription->id,
|
||||
'portal' => $subscription->portal->value,
|
||||
'email' => $subscription->email,
|
||||
'is_confirmed' => $subscription->is_confirmed,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||||
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
||||
use App\Http\Resources\PressReleaseResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PressReleaseController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$pressReleases = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||||
->latest()
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return PressReleaseResource::collection($pressReleases);
|
||||
}
|
||||
|
||||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||||
...$validated,
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $request->user()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'slug' => $this->uniqueSlug(
|
||||
Str::slug($validated['title']),
|
||||
$company->portal->value,
|
||||
$validated['language'],
|
||||
),
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
]);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||||
{
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$company = isset($validated['company_id'])
|
||||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$language = $validated['language'] ?? $pressRelease->language;
|
||||
$title = $validated['title'] ?? $pressRelease->title;
|
||||
$portal = $company->portal->value;
|
||||
|
||||
$pressRelease->fill([
|
||||
...$validated,
|
||||
'portal' => $portal,
|
||||
]);
|
||||
|
||||
if (
|
||||
array_key_exists('title', $validated)
|
||||
|| array_key_exists('language', $validated)
|
||||
|| array_key_exists('company_id', $validated)
|
||||
) {
|
||||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if ($pressRelease->status->value === 'published') {
|
||||
return response()->json([
|
||||
'message' => 'Published press releases cannot be deleted via API.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$pressRelease->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function findOwnedCompany(int $companyId, Request $request): ?Company
|
||||
{
|
||||
return Company::withoutGlobalScopes()
|
||||
->whereKey($companyId)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||||
{
|
||||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
|
||||
while (
|
||||
PressRelease::withoutGlobalScopes()
|
||||
->where('portal', $portal)
|
||||
->where('language', $language)
|
||||
->where('slug', $candidate)
|
||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||
->exists()
|
||||
) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
||||
use App\Http\Resources\PressReleaseImageResource;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
class PressReleaseImageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ImageService $imageService) {}
|
||||
|
||||
public function index(Request $request, int $pressRelease): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
return PressReleaseImageResource::collection(
|
||||
$pressRelease->images()->orderBy('sort_order')->orderBy('id')->get()
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StorePressReleaseImageRequest $request, int $pressRelease): JsonResponse
|
||||
{
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if (! $this->canChangeImages($pressRelease)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
/** @var UploadedFile $file */
|
||||
$file = $request->file('image');
|
||||
|
||||
try {
|
||||
$stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ((bool) ($validated['is_preview'] ?? false)) {
|
||||
$pressRelease->images()->update(['is_preview' => false]);
|
||||
}
|
||||
|
||||
$image = $pressRelease->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
'title' => $validated['title'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'copyright' => $validated['copyright'] ?? null,
|
||||
'is_preview' => (bool) ($validated['is_preview'] ?? false),
|
||||
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime' => $stored['mime'],
|
||||
]);
|
||||
|
||||
return PressReleaseImageResource::make($image)
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $pressReleaseImage): Response|JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
|
||||
|
||||
$image = PressReleaseImage::query()
|
||||
->whereKey($pressReleaseImage)
|
||||
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
|
||||
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
|
||||
->first();
|
||||
|
||||
abort_unless($image !== null, 403);
|
||||
|
||||
if (! $this->canChangeImages($image->pressRelease)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function canChangeImages(PressRelease $pressRelease): bool
|
||||
{
|
||||
$status = $pressRelease->status instanceof PressReleaseStatus
|
||||
? $pressRelease->status->value
|
||||
: (string) $pressRelease->status;
|
||||
|
||||
return in_array($status, [PressReleaseStatus::Draft->value, PressReleaseStatus::Rejected->value], true);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MagicLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MagicLinkConsumeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->with('user')
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->where('purpose', 'login')
|
||||
->first();
|
||||
|
||||
if (! $magicLink || ! $magicLink->user) {
|
||||
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
|
||||
}
|
||||
|
||||
if ($magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) {
|
||||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||
}
|
||||
|
||||
if (! $magicLink->user->is_active) {
|
||||
return redirect()->route('login')->with('status', __('Your account is not active.'));
|
||||
}
|
||||
|
||||
$magicLink->update([
|
||||
'consumed_at' => now(),
|
||||
'ip_consumed' => $request->ip(),
|
||||
]);
|
||||
|
||||
$magicLink->user->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
Auth::guard('web')->login($magicLink->user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$home = $magicLink->user->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: route('me.dashboard', absolute: false);
|
||||
|
||||
return redirect()->intended($home);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
|
|
@ -14,17 +15,21 @@ class VerifyEmailController extends Controller
|
|||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
$home = $request->user()->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: route('me.dashboard', absolute: false);
|
||||
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
return redirect()->intended($home.'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
||||
/** @var MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
return redirect()->intended($home.'?verified=1');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Services\Billing\LegacyInvoicePdfRenderer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LegacyInvoicePdfController extends Controller
|
||||
{
|
||||
public function __invoke(LegacyInvoice $legacyInvoice, LegacyInvoicePdfRenderer $renderer): Response
|
||||
{
|
||||
Gate::authorize('downloadPdf', $legacyInvoice);
|
||||
|
||||
if (filled($legacyInvoice->pdf_path) && Storage::disk('local')->exists($legacyInvoice->pdf_path)) {
|
||||
return response()->file(Storage::disk('local')->path($legacyInvoice->pdf_path), [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$renderer->filename($legacyInvoice).'"',
|
||||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('legacy_invoices', 'pdf_generated_at')) {
|
||||
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $renderer->inlineResponse($legacyInvoice);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PressReleasePreviewController extends Controller
|
||||
{
|
||||
public function __invoke(string $token): View|Response
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->where('purpose', 'press_release_access')
|
||||
->first();
|
||||
|
||||
if (! $magicLink) {
|
||||
return $this->renderError(__('Der Vorschau-Link ist ungültig.'), 404);
|
||||
}
|
||||
|
||||
if ($magicLink->expires_at && $magicLink->expires_at->isPast()) {
|
||||
return $this->renderError(__('Der Vorschau-Link ist abgelaufen.'), 410);
|
||||
}
|
||||
|
||||
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||
|
||||
$pressRelease = $pressReleaseId
|
||||
? PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name'])
|
||||
->find($pressReleaseId)
|
||||
: null;
|
||||
|
||||
if (! $pressRelease) {
|
||||
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
||||
}
|
||||
|
||||
return view('press-release-preview', [
|
||||
'pressRelease' => $pressRelease,
|
||||
'expiresAt' => $magicLink->expires_at,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderError(string $message, int $status): Response
|
||||
{
|
||||
return response()->view('press-release-preview-error', [
|
||||
'message' => $message,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BasicAuthMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Skip Basic Auth für Livewire-Requests komplett
|
||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||
$path = $request->path();
|
||||
|
||||
if (
|
||||
str_starts_with($path, 'livewire/') ||
|
||||
str_contains($path, '/livewire/') ||
|
||||
$request->is('livewire/*') ||
|
||||
$request->is('*/livewire/*')
|
||||
) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||
if (str_starts_with($path, 'flux/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für API und Short-Links; API-Zugriff wird per Sanctum geschützt.
|
||||
if ($request->is('api/*') || $request->is('_cabinet/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
||||
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureApiTokenRateLimit
|
||||
{
|
||||
private const MAX_ATTEMPTS = 60;
|
||||
|
||||
private const DECAY_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $this->rateLimitKey($request);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
|
||||
$retryAfter = RateLimiter::availableIn($key);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'API rate limit exceeded.',
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
'X-RateLimit-Limit' => (string) self::MAX_ATTEMPTS,
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, self::DECAY_SECONDS);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-RateLimit-Limit', (string) self::MAX_ATTEMPTS);
|
||||
$response->headers->set('X-RateLimit-Remaining', (string) RateLimiter::remaining($key, self::MAX_ATTEMPTS));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function rateLimitKey(Request $request): string
|
||||
{
|
||||
$bearerToken = $request->bearerToken();
|
||||
|
||||
if ($bearerToken !== null && str_contains($bearerToken, '|')) {
|
||||
[$tokenId] = explode('|', $bearerToken, 2);
|
||||
|
||||
if (ctype_digit($tokenId)) {
|
||||
return 'api-v1:token:'.$tokenId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bearerToken !== null) {
|
||||
return 'api-v1:bearer:'.hash('sha256', $bearerToken);
|
||||
}
|
||||
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if (is_object($token) && method_exists($token, 'getKey') && $token->getKey() !== null) {
|
||||
return 'api-v1:token:'.$token->getKey();
|
||||
}
|
||||
|
||||
return 'api-v1:user:'.($request->user()?->getAuthIdentifier() ?? $request->ip());
|
||||
}
|
||||
}
|
||||
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureApiUserIsActive
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->is_active) {
|
||||
return response()->json([
|
||||
'message' => 'API access is disabled for inactive users.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
|
||||
$user->loadMissing('roles');
|
||||
}
|
||||
|
||||
if (app(UserImpersonation::class)->isActive()) {
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
|
||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||
}
|
||||
|
||||
if (! $user?->canAccessAdmin()) {
|
||||
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsCustomer
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->canAccessCustomer()) {
|
||||
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/LogApiUsage.php
Normal file
48
app/Http/Middleware/LogApiUsage.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiUsageLog;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LogApiUsage
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$response = $next($request);
|
||||
|
||||
$this->writeLog($request, $response, $startedAt);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||
{
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
$tokenId = $token instanceof PersonalAccessToken && (int) $token->getKey() > 0
|
||||
? (int) $token->getKey()
|
||||
: null;
|
||||
|
||||
ApiUsageLog::query()->create([
|
||||
'user_id' => $request->user()?->id,
|
||||
'personal_access_token_id' => $tokenId,
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.$request->path(),
|
||||
'route_name' => $request->route()?->getName(),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
'requested_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LogSlowAdminRequests
|
||||
{
|
||||
public function __construct(private AdminRequestPerformanceMetrics $metrics) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('admin_performance.slow_requests.enabled', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$this->metrics->start();
|
||||
|
||||
try {
|
||||
$response = $next($request);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->metrics->stop();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$snapshot = $this->metrics->snapshot();
|
||||
$this->metrics->stop();
|
||||
|
||||
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
if ($this->shouldLog($durationMs, $snapshot['database_time_ms'], $snapshot['query_count'])) {
|
||||
$this->logger()->warning('Slow admin request detected.', [
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.$request->path(),
|
||||
'route_name' => $request->route()?->getName(),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'user_id' => $request->user()?->id,
|
||||
'duration_ms' => $durationMs,
|
||||
'database_time_ms' => $snapshot['database_time_ms'],
|
||||
'query_count' => $snapshot['query_count'],
|
||||
'slow_queries' => $snapshot['slow_queries'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function shouldLog(int $durationMs, float $databaseTimeMs, int $queryCount): bool
|
||||
{
|
||||
return $durationMs >= $this->durationThresholdMs()
|
||||
|| $databaseTimeMs >= $this->databaseThresholdMs()
|
||||
|| $queryCount >= $this->queryCountThreshold();
|
||||
}
|
||||
|
||||
private function logger(): LoggerInterface
|
||||
{
|
||||
$channel = config('admin_performance.slow_requests.channel') ?: config('logging.default');
|
||||
|
||||
return Log::channel(is_string($channel) && $channel !== '' ? $channel : 'stack');
|
||||
}
|
||||
|
||||
private function durationThresholdMs(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.duration_threshold_ms', 750);
|
||||
}
|
||||
|
||||
private function databaseThresholdMs(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.database_threshold_ms', 250);
|
||||
}
|
||||
|
||||
private function queryCountThreshold(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.query_count_threshold', 100);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RejectLegacyApiKeys
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
|
||||
return response()->json([
|
||||
'message' => 'Legacy API keys are no longer supported.',
|
||||
'migration_url' => url('/customer/tokens'),
|
||||
'docs_url' => url('/docs/api/v1'),
|
||||
], 410);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Setzt den Portal-Kontext für den aktuellen Request.
|
||||
*
|
||||
* Reihenfolge der Auflösung:
|
||||
* 1. Admin-Session-Override: Ein angemeldeter Admin kann über die Session ein
|
||||
* bestimmtes Portal forcieren (für die Filteransicht im Admin-Bereich).
|
||||
* 2. Domain-Konfiguration: Das aktive Theme (gesetzt vom ThemeServiceProvider)
|
||||
* bestimmt das Portal über den config('app.theme')-Wert.
|
||||
* 3. Kein Kontext: Portal-Scope filtert nicht (z.B. CLI, Tests).
|
||||
*
|
||||
* Theme → Portal-Mapping:
|
||||
* 'presseecho' → Portal::Presseecho
|
||||
* 'businessportal24'→ Portal::Businessportal24
|
||||
* 'main' / andere → null (Admin-Domain; Super-Admin sieht alles)
|
||||
*/
|
||||
class SetCurrentPortal
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$portal = $this->resolvePortal($request);
|
||||
CurrentPortalContext::set($portal);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolvePortal(Request $request): ?Portal
|
||||
{
|
||||
// Admin-Session-Override hat höchste Priorität
|
||||
if ($request->hasSession() && $request->session()->has('admin_portal_filter')) {
|
||||
$overrideValue = $request->session()->get('admin_portal_filter');
|
||||
$override = Portal::tryFrom((string) $overrideValue);
|
||||
if ($override !== null) {
|
||||
return $override;
|
||||
}
|
||||
}
|
||||
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider
|
||||
$theme = config('app.theme', 'main');
|
||||
|
||||
return match ($theme) {
|
||||
'presseecho' => Portal::Presseecho,
|
||||
'businessportal24' => Portal::Businessportal24,
|
||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||
};
|
||||
}
|
||||
}
|
||||
59
app/Http/Middleware/SetDomainUrl.php
Normal file
59
app/Http/Middleware/SetDomainUrl.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware um die URL-Konfiguration basierend auf der aktuellen Domain zu setzen.
|
||||
*
|
||||
* Diese Middleware muss sehr früh im Request-Lifecycle ausgeführt werden,
|
||||
* um sicherzustellen, dass url() und asset() die richtige Domain verwenden.
|
||||
*/
|
||||
class SetDomainUrl
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
|
||||
// Suche nach der Domain-Konfiguration
|
||||
$domainConfig = null;
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $name => $config) {
|
||||
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
|
||||
$domainConfig = $config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn eine Domain-Konfiguration gefunden wurde, setze die URL
|
||||
if ($domainConfig && isset($domainConfig['url'])) {
|
||||
$domainUrl = $domainConfig['url'];
|
||||
|
||||
// URL-Generator konfigurieren
|
||||
URL::forceRootUrl($domainUrl);
|
||||
URL::forceScheme(parse_url($domainUrl, PHP_URL_SCHEME) ?: 'https');
|
||||
|
||||
// Asset-Root setzen
|
||||
/** @var UrlGenerator $urlGenerator */
|
||||
$urlGenerator = app('url');
|
||||
$urlGenerator->useAssetOrigin($domainUrl);
|
||||
|
||||
// Config aktualisieren
|
||||
config([
|
||||
'app.url' => $domainUrl,
|
||||
'app.asset_url' => $domainUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThemeMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$path = $request->path();
|
||||
|
||||
// Theme-Switching über Subdomains
|
||||
if (str_contains($host, 'b2in')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
} elseif (str_contains($host, 'stileigentum')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
} elseif (str_contains($host, 'style2own')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
}
|
||||
|
||||
// Theme-Switching über URL-Parameter (für Testing)
|
||||
if ($request->has('theme')) {
|
||||
$theme = $request->get('theme');
|
||||
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
|
||||
config(['app.theme' => $theme]);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
|
||||
if (str_starts_with($path, 'b2in/')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'style2own/')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePressReleaseImageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-release-images:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
|
||||
'title' => ['nullable', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'copyright' => ['nullable', 'string', 'max:255'],
|
||||
'is_preview' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePressReleaseRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-releases:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => ['required', 'integer', 'exists:companies,id'],
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'language' => ['required', 'string', 'size:2'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'text' => ['required', 'string'],
|
||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', Rule::in([
|
||||
PressReleaseStatus::Draft->value,
|
||||
PressReleaseStatus::Review->value,
|
||||
])],
|
||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||
'no_export' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php
Normal file
38
app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubscribeNewsletterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('newsletter:subscribe') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'portal' => ['required', Rule::in([
|
||||
Portal::Presseecho->value,
|
||||
Portal::Businessportal24->value,
|
||||
])],
|
||||
'email' => ['required', 'email', 'max:190'],
|
||||
'salutation_key' => ['nullable', 'string', 'max:20'],
|
||||
'first_name' => ['nullable', 'string', 'max:80'],
|
||||
'last_name' => ['nullable', 'string', 'max:80'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php
Normal file
44
app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePressReleaseRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-releases:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'],
|
||||
'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'],
|
||||
'language' => ['sometimes', 'required', 'string', 'size:2'],
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'text' => ['sometimes', 'required', 'string'],
|
||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['sometimes', Rule::in([
|
||||
PressReleaseStatus::Draft->value,
|
||||
PressReleaseStatus::Review->value,
|
||||
])],
|
||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||
'no_export' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/CategoryResource.php
Normal file
36
app/Http/Resources/CategoryResource.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'is_active' => $this->is_active,
|
||||
'translations' => $this->whenLoaded('translations', fn () => $this->translations
|
||||
->mapWithKeys(fn ($translation) => [
|
||||
$translation->locale => [
|
||||
'name' => $translation->name,
|
||||
'slug' => $translation->slug,
|
||||
'description' => $translation->description,
|
||||
],
|
||||
])
|
||||
->all()),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/CompanyResource.php
Normal file
36
app/Http/Resources/CompanyResource.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'type' => $this->type?->value,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'email' => $this->email,
|
||||
'website' => $this->website,
|
||||
'phone' => $this->phone,
|
||||
'country_code' => $this->country_code,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Resources/PressReleaseImageResource.php
Normal file
46
app/Http/Resources/PressReleaseImageResource.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PressReleaseImageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variants = is_array($this->variants) ? $this->variants : [];
|
||||
$variantUrls = [];
|
||||
|
||||
foreach ($variants as $key => $relativePath) {
|
||||
if (is_string($relativePath) && filled($relativePath)) {
|
||||
$variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'press_release_id' => $this->press_release_id,
|
||||
'url' => $this->url(),
|
||||
'urls' => array_merge(['original' => $this->url()], $variantUrls),
|
||||
'variants' => $variants,
|
||||
'disk' => $this->disk,
|
||||
'path' => $this->path,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'copyright' => $this->copyright,
|
||||
'is_preview' => $this->is_preview,
|
||||
'sort_order' => $this->sort_order,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'mime' => $this->mime,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Resources/PressReleaseResource.php
Normal file
46
app/Http/Resources/PressReleaseResource.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PressReleaseResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'language' => $this->language,
|
||||
'title' => $this->title,
|
||||
'slug' => $this->slug,
|
||||
'text' => $this->text,
|
||||
'backlink_url' => $this->backlink_url,
|
||||
'keywords' => $this->keywords,
|
||||
'status' => $this->status?->value,
|
||||
'hits' => $this->hits,
|
||||
'teaser' => [
|
||||
'begin' => $this->teaser_begin,
|
||||
'end' => $this->teaser_end,
|
||||
],
|
||||
'no_export' => $this->no_export,
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
'company' => CompanyResource::make($this->whenLoaded('company')),
|
||||
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||
'images' => PressReleaseImageResource::collection($this->whenLoaded('images')),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Responses/RoleAwareLoginResponse.php
Normal file
51
app/Http/Responses/RoleAwareLoginResponse.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
|
||||
/**
|
||||
* Leitet Panel-User nach erfolgreichem Login je nach Rolle:
|
||||
* - Admin/Editor → /dashboard (Admin-Bereich)
|
||||
* - Customer → /admin/me (Mein Bereich)
|
||||
*/
|
||||
class RoleAwareLoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($request instanceof Request && $request->wantsJson()) {
|
||||
return new JsonResponse('', 204);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$intended = redirect()->intended();
|
||||
|
||||
if ($user?->canAccessAdmin()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('dashboard'))
|
||||
);
|
||||
}
|
||||
|
||||
if ($user?->canAccessCustomer()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('me.dashboard'))
|
||||
);
|
||||
}
|
||||
|
||||
return $intended->setTargetUrl(url('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die Intended-URL nur, wenn sie nicht auf den Default-Home-Pfad zeigt.
|
||||
*/
|
||||
private function resolveTarget(string $intendedUrl, string $fallback): string
|
||||
{
|
||||
$homePath = (string) config('fortify.home', '/dashboard');
|
||||
$intendedPath = parse_url($intendedUrl, PHP_URL_PATH) ?: '/';
|
||||
|
||||
return $intendedPath === $homePath || $intendedPath === '/' ? $fallback : $intendedUrl;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue