Save workspace changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user