Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Middleware\HandleCors as BaseHandleCors;
use Illuminate\Http\Request;
class ConditionalCors
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
$paths = config('cors.paths', null);
// If paths are empty the CORS config intentionally disables CORS.
if (is_array($paths) && count($paths) === 0) {
return $next($request);
}
// Fallback to env if config wasn't populated for some reason.
$enabled = env('CP_ENABLE_CORS', false);
if (! $enabled) {
return $next($request);
}
$handler = app(BaseHandleCors::class);
return $handler->handle($request, $next);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureAdminOrModerator
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || (! $user->isAdmin() && ! $user->isModerator())) {
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureArtworkMaturityAccess
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user('controlpanel') !== null) {
return $next($request);
}
$user = $request->user();
$role = strtolower((string) ($user?->role ?? ''));
if (in_array($role, ['admin', 'moderator'], true)) {
return $next($request);
}
if (! $request->expectsJson() && route('cp.login', absolute: false) !== null) {
return redirect()->route('cp.login');
}
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCreatorAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
abort(403, 'Authentication required.');
}
$role = strtolower((string) ($user->role ?? 'user'));
$isCreatorRole = in_array($role, ['creator', 'user', 'admin', 'moderator', 'mod'], true);
if (! $isCreatorRole || (property_exists($user, 'is_active') && $user->is_active === false)) {
abort(403, 'Creator access is required.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
/**
* Paths that must always be reachable regardless of onboarding state,
* so authenticated users can log out, complete OAuth flows, etc.
*/
private const ALWAYS_ALLOW = [
'logout',
'auth/*', // OAuth redirects & callbacks
'verify/*', // email verification links
'setup/*', // all /setup/* pages (password, username)
'up', // health check
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$step = strtolower((string) ($user->onboarding_step ?? ''));
if ($step === 'complete') {
return $next($request);
}
// Always allow critical auth / setup paths through.
if ($request->is(self::ALWAYS_ALLOW)) {
return $next($request);
}
$target = match ($step) {
'email' => '/login',
'verified' => '/setup/password',
'password', 'username' => '/setup/username',
default => '/setup/password',
};
return redirect($target);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\AI\AIContentModerator;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForumAIModerationMiddleware
{
public function __construct(
private readonly AIContentModerator $aiContentModerator,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
{
$assessment = [
'action' => $action,
'ai_spam_score' => 0,
'ai_toxicity_score' => 0,
'flags' => [],
'reasons' => [],
'provider' => 'none',
'available' => false,
'raw' => null,
'language' => null,
];
$title = trim((string) $request->input('title', ''));
$content = trim((string) $request->input('content', ''));
$combinedContent = trim($title !== '' ? $title . "\n" . $content : $content);
if (
$combinedContent !== ''
&& (bool) config('skinbase_ai_moderation.enabled', true)
&& (bool) config('skinbase_ai_moderation.preflight.run_ai_sync', true)
) {
$spamAssessment = $request->attributes->get('forum_spam_assessment');
$assessment = ['action' => $action] + $this->aiContentModerator->analyze($combinedContent, [
'links' => is_array($spamAssessment) ? (array) ($spamAssessment['links'] ?? []) : [],
]);
}
$request->attributes->set('forum_ai_assessment', $assessment);
return $next($request);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Middleware;
use App\Services\Security\CaptchaVerifier;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class ForumBotProtectionMiddleware
{
public function __construct(
private readonly BotProtectionService $botProtectionService,
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
{
if (! (bool) config('forum_bot_protection.enabled', true)) {
return $next($request);
}
if ($this->shouldBypassForLocalE2E($request)) {
return $next($request);
}
$assessment = $this->botProtectionService->assess($request, $action);
$request->attributes->set('forum_bot_assessment', $assessment);
if ($this->requiresCaptcha($assessment, $action)) {
$captcha = $this->captchaVerifier->frontendConfig();
$tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName());
$token = (string) (
$request->input($tokenInput)
?: $request->header('X-Captcha-Token', '')
?: $request->header('X-Turnstile-Token', '')
);
if (! $this->captchaVerifier->verify($token, $request->ip())) {
$message = (string) config('forum_bot_protection.captcha.message', 'Complete the captcha challenge to continue.');
if ($request->expectsJson()) {
return response()->json([
'message' => $message,
'errors' => [
'captcha' => [$message],
],
'requires_captcha' => true,
'captcha' => $captcha,
'captcha_provider' => (string) ($captcha['provider'] ?? $this->captchaVerifier->provider()),
'captcha_site_key' => (string) ($captcha['siteKey'] ?? ''),
'captcha_input' => (string) ($captcha['inputName'] ?? $tokenInput),
'captcha_script_url' => (string) ($captcha['scriptUrl'] ?? ''),
], 422);
}
return redirect()->back()
->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput]))
->withErrors(['captcha' => $message])
->with('bot_captcha_required', true)
->with('bot_turnstile_required', true);
}
$request->attributes->set('forum_bot_captcha_verified', true);
}
if ((bool) ($assessment['blocked'] ?? false)) {
$message = 'Suspicious activity detected.';
if ($request->expectsJson()) {
return response()->json([
'message' => $message,
'errors' => [
'bot' => [$message],
],
], 429);
}
throw ValidationException::withMessages([
'bot' => [$message],
]);
}
return $next($request);
}
private function requiresCaptcha(array $assessment, string $action): bool
{
if (! $this->captchaVerifier->isEnabled()) {
return false;
}
if ((int) ($assessment['risk_score'] ?? 0) < (int) config('forum_bot_protection.thresholds.captcha', 60)) {
return false;
}
return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true);
}
private function shouldBypassForLocalE2E(Request $request): bool
{
if (! app()->environment(['local', 'testing'])) {
return false;
}
if ($request->cookies->get('e2e_bot_bypass') === '1') {
return true;
}
$userAgent = strtolower((string) $request->userAgent());
return str_contains($userAgent, 'headlesschrome') || str_contains($userAgent, 'playwright');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Symfony\Component\HttpFoundation\Response;
class ForumRateLimitMiddleware
{
public function __construct(
private readonly ThrottleRequests $throttleRequests,
private readonly BotProtectionService $botProtectionService,
) {
}
public function handle(Request $request, Closure $next): Response
{
$routeName = (string) optional($request->route())->getName();
$limiterName = match ($routeName) {
'forum.topic.store' => 'forum-thread-create',
default => 'forum-post-write',
};
try {
return $this->throttleRequests->handle($request, $next, $limiterName);
} catch (ThrottleRequestsException $exception) {
$maxAttempts = (int) ($exception->getHeaders()['X-RateLimit-Limit'] ?? 0);
$this->botProtectionService->recordRateLimitViolation(
$request,
$this->resolveActionName($routeName),
[
'limiter' => $limiterName,
'bucket' => $this->resolveBucket($limiterName, $maxAttempts),
'max_attempts' => $maxAttempts,
'retry_after' => (int) ($exception->getHeaders()['Retry-After'] ?? 0),
'reason' => sprintf('Forum write rate limit exceeded on %s.', $routeName !== '' ? $routeName : 'unknown route'),
],
);
throw $exception;
}
}
private function resolveActionName(string $routeName): string
{
return match ($routeName) {
'forum.topic.store' => 'forum_topic_create',
'forum.post.update' => 'forum_post_update',
default => 'forum_reply_create',
};
}
private function resolveBucket(string $limiterName, int $maxAttempts): string
{
return $maxAttempts <= $this->minuteLimitThreshold($limiterName) ? 'minute' : 'hour';
}
private function minuteLimitThreshold(string $limiterName): int
{
return match ($limiterName) {
'forum-thread-create', 'forum-post-write' => 3,
default => PHP_INT_MAX,
};
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Middleware;
use App\Services\Security\CaptchaVerifier;
use Closure;
use cPad\Plugins\Forum\Services\Security\ForumSecurityFirewallService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class ForumSecurityFirewallMiddleware
{
public function __construct(
private readonly ForumSecurityFirewallService $firewallService,
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
{
$assessment = $this->firewallService->assess($request, $action);
$request->attributes->set('forum_firewall_assessment', $assessment);
if ($this->requiresCaptcha($assessment, $action)) {
$captcha = $this->captchaVerifier->frontendConfig();
$tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName());
$token = (string) (
$request->input($tokenInput)
?: $request->header('X-Captcha-Token', '')
?: $request->header('X-Turnstile-Token', '')
);
if (! $this->captchaVerifier->verify($token, $request->ip())) {
$message = 'Additional verification is required before continuing.';
if ($request->expectsJson()) {
return response()->json([
'message' => $message,
'errors' => [
'captcha' => [$message],
],
'requires_captcha' => true,
'captcha' => $captcha,
], 422);
}
return redirect()->back()
->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput]))
->withErrors(['captcha' => $message]);
}
$request->attributes->set('forum_firewall_captcha_verified', true);
}
if ((bool) ($assessment['blocked'] ?? false)) {
$message = 'Security firewall blocked this request.';
if ($request->expectsJson()) {
return response()->json([
'message' => $message,
'errors' => [
'security' => [$message],
],
], 429);
}
throw ValidationException::withMessages([
'security' => [$message],
]);
}
return $next($request);
}
private function requiresCaptcha(array $assessment, string $action): bool
{
if (! $this->captchaVerifier->isEnabled()) {
return false;
}
if (! (bool) ($assessment['requires_captcha'] ?? false)) {
return false;
}
return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\ForumSpamDetector;
use cPad\Plugins\Forum\Services\LinkAnalyzer;
use cPad\Plugins\Forum\Services\Security\ContentPatternAnalyzer;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForumSpamDetectionMiddleware
{
public function __construct(
private readonly ForumSpamDetector $spamDetector,
private readonly LinkAnalyzer $linkAnalyzer,
private readonly ContentPatternAnalyzer $contentPatternAnalyzer,
) {
}
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
{
$title = trim((string) $request->input('title', ''));
$content = trim((string) $request->input('content', ''));
$combinedContent = trim($title !== '' ? $title . "\n" . $content : $content);
if ($combinedContent === '') {
$request->attributes->set('forum_spam_assessment', [
'action' => $action,
'spam_score' => 0,
'spam_reasons' => [],
'link_score' => 0,
'link_reasons' => [],
'links' => [],
'domains' => [],
'pattern_score' => 0,
'pattern_reasons' => [],
'matched_categories' => [],
]);
return $next($request);
}
$spam = $this->spamDetector->analyze($combinedContent);
$link = $this->linkAnalyzer->analyze($combinedContent);
$patterns = $this->contentPatternAnalyzer->analyze($combinedContent);
$request->attributes->set('forum_spam_assessment', [
'action' => $action,
'spam_score' => max((int) ($spam['score'] ?? 0), (int) ($patterns['score'] ?? 0)),
'spam_reasons' => array_values(array_unique(array_merge(
(array) ($spam['reasons'] ?? []),
(array) ($patterns['reasons'] ?? []),
))),
'link_score' => (int) ($link['score'] ?? 0),
'link_reasons' => (array) ($link['reasons'] ?? []),
'links' => (array) ($link['links'] ?? []),
'domains' => (array) ($link['domains'] ?? []),
'pattern_score' => (int) ($patterns['score'] ?? 0),
'pattern_reasons' => (array) ($patterns['reasons'] ?? []),
'matched_categories' => (array) ($patterns['matched_categories'] ?? []),
]);
return $next($request);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\GroupService;
use Illuminate\Http\Request;
use Inertia\Middleware;
final class HandleInertiaRequests extends Middleware
{
protected $rootView = 'upload';
/**
* Select the root Blade view based on route prefix.
*/
public function rootView(Request $request): string
{
if ($request->path() === 'leaderboard') {
return 'leaderboard';
}
if (str_starts_with($request->path(), 'studio')) {
return 'studio';
}
// Profile pages: /@{username}
if (str_starts_with($request->path(), '@')) {
return 'profile.show';
}
// Feed pages — ordered most-specific first
if ($request->path() === 'feed/trending') {
return 'feed.trending';
}
if ($request->path() === 'feed/saved') {
return 'feed.saved';
}
if (str_starts_with($request->path(), 'feed')) {
return 'feed.following';
}
// Hashtag pages: /tags/{tag}
if (str_starts_with($request->path(), 'tags/')) {
return 'feed.hashtag';
}
return $this->rootView;
}
public function version(Request $request): ?string
{
return parent::version($request);
}
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'is_admin' => $request->user()->isAdmin(),
'is_moderator' => $request->user()->isModerator(),
] : null,
],
'cdn' => [
'files_url' => config('cdn.files_url'),
],
'features' => [
'groups' => (bool) config('features.groups', true),
'groups_v1' => (bool) config('features.groups_v1', true),
'groups_v2' => (bool) config('features.groups_v2', true),
'group_posts' => (bool) config('features.group_posts', true),
'group_recruitment' => (bool) config('features.group_recruitment', true),
'group_join_requests' => (bool) config('features.group_join_requests', true),
'group_review_queue' => (bool) config('features.group_review_queue', true),
'group_projects' => (bool) config('features.group_projects', true),
'group_challenges' => (bool) config('features.group_challenges', true),
'group_events' => (bool) config('features.group_events', true),
'group_assets' => (bool) config('features.group_assets', true),
'group_activity_feed' => (bool) config('features.group_activity_feed', true),
],
'studio_groups' => $request->user()
? app(GroupService::class)->studioOptionsForUser($request->user())
: [],
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NoIndexDashboard
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
return $response;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Support\UsernamePolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NormalizeUsername
{
public function handle(Request $request, Closure $next): Response
{
$payload = $request->all();
if (array_key_exists('username', $payload)) {
$payload['username'] = UsernamePolicy::normalize((string) $payload['username']);
}
if (array_key_exists('old_username', $payload)) {
$payload['old_username'] = UsernamePolicy::normalize((string) $payload['old_username']);
}
$request->merge($payload);
return $next($request);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\User;
use App\Support\UsernamePolicy;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class RedirectLegacyProfileSubdomain
{
public function handle(Request $request, Closure $next): Response
{
$canonicalUsername = $this->resolveCanonicalUsername($request);
if ($canonicalUsername !== null) {
return redirect()->to($this->targetUrl($request, $canonicalUsername), 301);
}
return $next($request);
}
private function resolveCanonicalUsername(Request $request): ?string
{
$configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST);
if (! is_string($configuredHost) || $configuredHost === '') {
return null;
}
$requestHost = strtolower($request->getHost());
$configuredHost = strtolower($configuredHost);
if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) {
return null;
}
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
if ($subdomain === '' || str_contains($subdomain, '.')) {
return null;
}
$candidate = UsernamePolicy::normalize($subdomain);
if ($candidate === '' || $this->isReservedSubdomain($candidate)) {
return null;
}
$username = User::query()
->whereRaw('LOWER(username) = ?', [$candidate])
->value('username');
if (is_string($username) && $username !== '') {
return UsernamePolicy::normalize($username);
}
if (! Schema::hasTable('username_redirects')) {
return null;
}
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$candidate])
->value('new_username');
return is_string($redirect) && $redirect !== ''
? UsernamePolicy::normalize($redirect)
: null;
}
private function isReservedSubdomain(string $candidate): bool
{
$reserved = UsernamePolicy::reserved();
foreach ([config('cp.webroot'), config('cpad.webroot')] as $prefix) {
$value = strtolower(trim((string) $prefix, '/'));
if ($value !== '') {
$reserved[] = $value;
}
}
return in_array($candidate, array_values(array_unique($reserved)), true);
}
private function targetUrl(Request $request, string $username): string
{
$canonicalPath = match ($request->getPathInfo()) {
'/gallery', '/gallery/' => '/@' . $username . '/gallery',
default => '/@' . $username,
};
$target = rtrim((string) config('app.url'), '/') . $canonicalPath;
$query = $request->getQueryString();
if (is_string($query) && $query !== '') {
$target .= '?' . $query;
}
return $target;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class UpdateLastVisit
{
private const SESSION_KEY = 'last_visit.logged_at';
private const THROTTLE_SECONDS = 300;
private static ?bool $usersTableHasLastVisitAt = null;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$user = $request->user();
if (! $user) {
return $response;
}
if (! $this->usersTableHasLastVisitAt()) {
return $response;
}
$now = now();
$session = $request->hasSession() ? $request->session() : null;
$lastLoggedAt = $session?->get(self::SESSION_KEY);
if (is_numeric($lastLoggedAt) && ((int) $lastLoggedAt + self::THROTTLE_SECONDS) > $now->getTimestamp()) {
return $response;
}
$lastVisitAt = $user->last_visit_at;
if ($lastVisitAt !== null && method_exists($lastVisitAt, 'getTimestamp')) {
if (($lastVisitAt->getTimestamp() + self::THROTTLE_SECONDS) > $now->getTimestamp()) {
$session?->put(self::SESSION_KEY, $lastVisitAt->getTimestamp());
return $response;
}
}
DB::table('users')
->where('id', $user->id)
->update([
'last_visit_at' => $now,
'updated_at' => $now,
]);
$user->forceFill(['last_visit_at' => $now]);
$session?->put(self::SESSION_KEY, $now->getTimestamp());
return $response;
}
private function usersTableHasLastVisitAt(): bool
{
return self::$usersTableHasLastVisitAt ??= Schema::hasColumn('users', 'last_visit_at');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
* Add legacy endpoints that post from old JS which don't include tokens.
*
* @var array<int, string>
*/
protected $except = [
'chat_post',
'chat_post/*',
// Apple Sign In removed — no special CSRF exception required
];
}