From 31c87e49779d44e29151f9e76e9f3e86aa85fe15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20Klev=C5=BEe?= Date: Sun, 20 Sep 2026 14:49:00 +0200 Subject: [PATCH] Move model observers onto attributes and tidy provider bindings. Register observers with ObservedBy, bind the comment spam classifier, and keep AppServiceProvider focused on application wiring. --- app/Models/ArtworkAward.php | 11 +- app/Models/ArtworkFavourite.php | 21 +- app/Models/ArtworkFeature.php | 4 + app/Models/ArtworkReaction.php | 13 +- app/Models/ContentType.php | 11 +- app/Models/GroupRelease.php | 21 +- app/Models/GroupReleaseContributor.php | 5 +- app/Models/HomepageAnnouncement.php | 29 +- app/Providers/AppServiceProvider.php | 471 +++++++++++++------------ 9 files changed, 336 insertions(+), 250 deletions(-) diff --git a/app/Models/ArtworkAward.php b/app/Models/ArtworkAward.php index b25a3de3..545cc525 100644 --- a/app/Models/ArtworkAward.php +++ b/app/Models/ArtworkAward.php @@ -4,9 +4,12 @@ declare(strict_types=1); namespace App\Models; +use App\Observers\ArtworkAwardObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +#[ObservedBy([ArtworkAwardObserver::class])] class ArtworkAward extends Model { protected $table = 'artwork_medals'; @@ -21,21 +24,21 @@ class ArtworkAward extends Model protected $casts = [ 'artwork_id' => 'integer', - 'user_id' => 'integer', - 'weight' => 'integer', + 'user_id' => 'integer', + 'weight' => 'integer', ]; public const MEDALS = ['gold', 'silver', 'bronze']; public const WEIGHTS = [ - 'gold' => 5, + 'gold' => 5, 'silver' => 3, 'bronze' => 1, ]; public static function weightFor(string $medal): int { - return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0); + return (int) config('artwork_medals.weights.'.$medal, self::WEIGHTS[$medal] ?? 0); } /** diff --git a/app/Models/ArtworkFavourite.php b/app/Models/ArtworkFavourite.php index f814bef7..c1922d06 100644 --- a/app/Models/ArtworkFavourite.php +++ b/app/Models/ArtworkFavourite.php @@ -2,22 +2,25 @@ namespace App\Models; +use App\Observers\ArtworkFavouriteObserver; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Represents a user's "favourite" bookmark on an artwork. * - * @property int $id - * @property int $user_id - * @property int $artwork_id - * @property int|null $legacy_id Original favourite_id from the old site - * @property \Carbon\Carbon $created_at - * @property \Carbon\Carbon $updated_at - * + * @property int $id + * @property int $user_id + * @property int $artwork_id + * @property int|null $legacy_id Original favourite_id from the old site + * @property Carbon $created_at + * @property Carbon $updated_at * @property-read User $user * @property-read Artwork $artwork */ +#[ObservedBy([ArtworkFavouriteObserver::class])] class ArtworkFavourite extends Model { protected $table = 'artwork_favourites'; @@ -29,9 +32,9 @@ class ArtworkFavourite extends Model ]; protected $casts = [ - 'user_id' => 'integer', + 'user_id' => 'integer', 'artwork_id' => 'integer', - 'legacy_id' => 'integer', + 'legacy_id' => 'integer', ]; // ── Relations ────────────────────────────────────────────────────────── diff --git a/app/Models/ArtworkFeature.php b/app/Models/ArtworkFeature.php index d57f5d8f..54189aef 100644 --- a/app/Models/ArtworkFeature.php +++ b/app/Models/ArtworkFeature.php @@ -1,10 +1,14 @@ 'integer', - 'user_id' => 'integer', + 'user_id' => 'integer', 'created_at' => 'datetime', ]; diff --git a/app/Models/ContentType.php b/app/Models/ContentType.php index d6ee0c22..56a956e3 100644 --- a/app/Models/ContentType.php +++ b/app/Models/ContentType.php @@ -2,15 +2,16 @@ namespace App\Models; +use App\Observers\ContentTypeObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; - -use App\Models\Artwork; +#[ObservedBy([ContentTypeObserver::class])] class ContentType extends Model { - protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path']; + protected $fillable = ['name', 'slug', 'description', 'order', 'hide_from_menu', 'mascot_path', 'cover_art_path']; protected $casts = [ 'order' => 'integer', @@ -81,6 +82,6 @@ class ContentType extends Model return $path; } - return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/'); + return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/').'/'.ltrim($path, '/'); } } diff --git a/app/Models/GroupRelease.php b/app/Models/GroupRelease.php index e9c679a2..c7c369ba 100644 --- a/app/Models/GroupRelease.php +++ b/app/Models/GroupRelease.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Observers\GroupReleaseObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -11,29 +13,44 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +#[ObservedBy([GroupReleaseObserver::class])] class GroupRelease extends Model { use HasFactory; use SoftDeletes; public const STATUS_PLANNED = 'planned'; + public const STATUS_IN_PROGRESS = 'in_progress'; + public const STATUS_INTERNAL_REVIEW = 'internal_review'; + public const STATUS_SCHEDULED = 'scheduled'; + public const STATUS_RELEASED = 'released'; + public const STATUS_ARCHIVED = 'archived'; + public const STATUS_CANCELLED = 'cancelled'; public const STAGE_CONCEPT = 'concept'; + public const STAGE_PRODUCTION = 'production'; + public const STAGE_REVIEW = 'review'; + public const STAGE_PACKAGING = 'packaging'; + public const STAGE_APPROVAL = 'approval'; + public const STAGE_PUBLISHING = 'publishing'; + public const STAGE_RELEASED = 'released'; public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_UNLISTED = 'unlisted'; + public const VISIBILITY_PRIVATE = 'private'; protected $fillable = [ @@ -151,6 +168,6 @@ class GroupRelease extends Model return $path; } - return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/').'/'.ltrim($path, '/'); } -} \ No newline at end of file +} diff --git a/app/Models/GroupReleaseContributor.php b/app/Models/GroupReleaseContributor.php index 398e3066..3041e745 100644 --- a/app/Models/GroupReleaseContributor.php +++ b/app/Models/GroupReleaseContributor.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace App\Models; +use App\Observers\GroupReleaseContributorObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +#[ObservedBy([GroupReleaseContributorObserver::class])] class GroupReleaseContributor extends Model { use HasFactory; @@ -28,4 +31,4 @@ class GroupReleaseContributor extends Model { return $this->belongsTo(User::class); } -} \ No newline at end of file +} diff --git a/app/Models/HomepageAnnouncement.php b/app/Models/HomepageAnnouncement.php index ebc4ceb1..ee88de7f 100644 --- a/app/Models/HomepageAnnouncement.php +++ b/app/Models/HomepageAnnouncement.php @@ -4,48 +4,75 @@ declare(strict_types=1); namespace App\Models; +use App\Observers\HomepageAnnouncementObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; +#[ObservedBy([HomepageAnnouncementObserver::class])] class HomepageAnnouncement extends Model { use SoftDeletes; public const STATUS_DRAFT = 'draft'; + public const STATUS_PUBLISHED = 'published'; + public const STATUS_ARCHIVED = 'archived'; public const TYPE_ANNOUNCEMENT = 'announcement'; + public const TYPE_LAUNCH = 'launch'; + public const TYPE_NEWS = 'news'; + public const TYPE_WORLD = 'world'; + public const TYPE_EVENT = 'event'; + public const TYPE_NOTICE = 'notice'; + public const TYPE_MAINTENANCE = 'maintenance'; public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured'; public const LINK_TYPE_NONE = 'none'; + public const LINK_TYPE_CUSTOM_URL = 'custom_url'; + public const LINK_TYPE_NEWS = 'news'; + public const LINK_TYPE_WORLD = 'world'; + public const LINK_TYPE_ARTWORK = 'artwork'; + public const LINK_TYPE_COLLECTION = 'collection'; + public const LINK_TYPE_GROUP = 'group'; + public const LINK_TYPE_PROFILE = 'profile'; + public const LINK_TYPE_EXPLORE = 'explore'; + public const LINK_TYPE_UPLOAD = 'upload'; public const GRADIENT_NOVA_AURORA = 'nova_aurora'; + public const GRADIENT_DEEP_SPACE = 'deep_space'; + public const GRADIENT_SUNRISE = 'sunrise'; + public const GRADIENT_OCEAN_GLOW = 'ocean_glow'; + public const GRADIENT_SPRING_VIBES = 'spring_vibes'; + public const GRADIENT_FANTASY_REALMS = 'fantasy_realms'; + public const GRADIENT_MINIMAL_LIGHT = 'minimal_light'; + public const GRADIENT_DARK_GLASS = 'dark_glass'; protected $table = 'homepage_announcements'; @@ -194,4 +221,4 @@ class HomepageAnnouncement extends Model { return $this->belongsTo(User::class, 'updated_by'); } -} \ No newline at end of file +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c0d0ba52..228a56d2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,53 +3,59 @@ namespace App\Providers; use App\Contracts\Images\SubjectDetectorInterface; -use Illuminate\Cache\RateLimiting\Limit; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Support\ServiceProvider; +use App\Contracts\Moderation\CommentSpamClassifier; +use App\Events\Achievements\AchievementCheckRequested; +use App\Events\Achievements\UserXpUpdated; +use App\Events\Posts\ArtworkShared; +use App\Events\Posts\PostCommented; +use App\Http\Middleware\ConditionalCors; +use App\Listeners\Academy\HandleAcademyStripeWebhook; +use App\Listeners\Academy\HandleAcademyStripeWebhookHandled; +use App\Listeners\Achievements\CheckUserAchievements; +use App\Listeners\Posts\AwardXpForPostCommented; +use App\Listeners\Posts\SendArtworkSharedNotification; +use App\Listeners\Posts\SendPostCommentedNotification; use App\Models\Artwork; -use App\Models\ArtworkAward; -use App\Models\ArtworkComment; -use App\Models\ArtworkFeature; -use App\Models\ArtworkFavourite; -use App\Models\ArtworkMedal; -use App\Models\ArtworkReaction; use App\Models\ContentType; -use App\Models\GroupRelease; -use App\Models\GroupReleaseContributor; -use App\Models\HomepageAnnouncement; -use App\Observers\ArtworkAwardObserver; -use App\Observers\ArtworkCommentObserver; -use App\Observers\ArtworkFeatureObserver; -use App\Observers\ArtworkFavouriteObserver; use App\Observers\ArtworkObserver; -use App\Observers\ArtworkReactionObserver; -use App\Observers\ContentTypeObserver; -use App\Observers\GroupReleaseContributorObserver; -use App\Observers\GroupReleaseObserver; -use App\Observers\HomepageAnnouncementObserver; -use App\Services\Upload\Contracts\UploadDraftServiceInterface; -use App\Services\Upload\UploadDraftService; use App\Services\ContentTypes\ContentTypeSlugResolver; -use App\Services\Worlds\WorldService; -use Illuminate\Support\Facades\Blade; -use Illuminate\Support\Facades\View; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Log; -use Illuminate\Queue\Events\JobFailed; -use App\Services\ReceivedCommentsInboxService; +use App\Services\Countries\CountryRemoteProvider; +use App\Services\Countries\CountryRemoteProviderInterface; +use App\Services\EarlyGrowth\SpotlightEngine; +use App\Services\EarlyGrowth\SpotlightEngineInterface; use App\Services\Images\Detectors\ChainedSubjectDetector; use App\Services\Images\Detectors\HeuristicSubjectDetector; use App\Services\Images\Detectors\NullSubjectDetector; use App\Services\Images\Detectors\VisionSubjectDetector; +use App\Services\Moderation\TogetherCommentSpamClassifier; +use App\Services\ReceivedCommentsInboxService; +use App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory; +use App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface; +use App\Services\Upload\Contracts\UploadDraftServiceInterface; +use App\Services\Upload\UploadDraftService; +use App\Services\Worlds\WorldService; +use App\Support\Http\TimedInertiaSsrGateway; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Http\Kernel; +use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Middleware\HandleCors; +use Illuminate\Http\Request; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\ServiceProvider; +use Illuminate\View\Factory; +use Inertia\Ssr\Gateway; use Klevze\ControlPanel\Framework\Core\Menu; use Laravel\Cashier\Events\WebhookHandled; use Laravel\Cashier\Events\WebhookReceived; -use App\Listeners\Academy\HandleAcademyStripeWebhook; -use App\Listeners\Academy\HandleAcademyStripeWebhookHandled; +use Meilisearch\Client; +use SocialiteProviders\Discord\DiscordExtendSocialite; +use SocialiteProviders\Manager\SocialiteWasCalled; class AppServiceProvider extends ServiceProvider { @@ -58,14 +64,18 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->bind(CommentSpamClassifier::class, function ($app) { + return $app->make(TogetherCommentSpamClassifier::class); + }); + $this->app->bind( - \Inertia\Ssr\Gateway::class, - \App\Support\Http\TimedInertiaSsrGateway::class, + Gateway::class, + TimedInertiaSsrGateway::class, ); $this->app->singleton( - \App\Services\Countries\CountryRemoteProviderInterface::class, - \App\Services\Countries\CountryRemoteProvider::class, + CountryRemoteProviderInterface::class, + CountryRemoteProvider::class, ); // Bind UploadDraftService interface to implementation @@ -75,14 +85,14 @@ class AppServiceProvider extends ServiceProvider // Bind vector adapter interface for similarity system (resolves via factory) $this->app->bind( - \App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class, - fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(), + VectorAdapterInterface::class, + fn () => VectorAdapterFactory::make(), ); // EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine $this->app->singleton( - \App\Services\EarlyGrowth\SpotlightEngineInterface::class, - \App\Services\EarlyGrowth\SpotlightEngine::class, + SpotlightEngineInterface::class, + SpotlightEngine::class, ); $this->app->singleton(SubjectDetectorInterface::class, function ($app) { @@ -97,7 +107,7 @@ class AppServiceProvider extends ServiceProvider // with no HTTP timeout, so a slow/overloaded Meilisearch leaves PHP-FPM // workers blocked forever in curl_exec(), draining the whole pool under // a search traffic spike. Fail fast instead. - $this->app->singleton(\Meilisearch\Client::class, function ($app) { + $this->app->singleton(Client::class, function ($app) { $config = $app['config']->get('scout.meilisearch'); $httpClient = new \GuzzleHttp\Client([ @@ -105,7 +115,7 @@ class AppServiceProvider extends ServiceProvider 'timeout' => 5, ]); - return new \Meilisearch\Client($config['host'], $config['key'], $httpClient); + return new Client($config['host'], $config['key'], $httpClient); }); } @@ -119,12 +129,29 @@ class AppServiceProvider extends ServiceProvider // Map the 'legacy' view namespace to resources/views/_legacy so all // view('legacy::foo') and @include('legacy::foo') calls resolve correctly // after the folder was renamed from legacy/ to _legacy/. - View::addNamespace('legacy', resource_path('views/_legacy')); + $legacyViewPath = resource_path('views/_legacy'); + $registerLegacyViewNamespace = static function (Factory $view) use ($legacyViewPath): void { + $view->addNamespace('legacy', $legacyViewPath); + }; + + if ($this->app->resolved('view')) { + $registerLegacyViewNamespace($this->app->make('view')); + } else { + $this->app->afterResolving('view', $registerLegacyViewNamespace); + } $exceptionRendererComponentsPath = base_path('vendor/laravel/framework/src/Illuminate/Foundation/resources/exceptions/renderer/components'); - if (is_dir($exceptionRendererComponentsPath)) { - Blade::anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer'); + $registerExceptionRendererComponents = static function ($compiler) use ($exceptionRendererComponentsPath): void { + if (is_dir($exceptionRendererComponentsPath)) { + $compiler->anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer'); + } + }; + + if ($this->app->resolved('blade.compiler')) { + $registerExceptionRendererComponents($this->app->make('blade.compiler')); + } else { + $this->app->afterResolving('blade.compiler', $registerExceptionRendererComponents); } $this->configureAuthRateLimiters(); @@ -140,45 +167,35 @@ class AppServiceProvider extends ServiceProvider $this->configureSettingsRateLimiters(); $this->configureMailFailureLogging(); - ArtworkAward::observe(ArtworkAwardObserver::class); - ArtworkMedal::observe(ArtworkAwardObserver::class); Artwork::observe(ArtworkObserver::class); - ArtworkFeature::observe(ArtworkFeatureObserver::class); - ArtworkFavourite::observe(ArtworkFavouriteObserver::class); - ArtworkComment::observe(ArtworkCommentObserver::class); - ArtworkReaction::observe(ArtworkReactionObserver::class); - ContentType::observe(ContentTypeObserver::class); - GroupRelease::observe(GroupReleaseObserver::class); - GroupReleaseContributor::observe(GroupReleaseContributorObserver::class); - HomepageAnnouncement::observe(HomepageAnnouncementObserver::class); // ── OAuth / SocialiteProviders ────────────────────────────────────── Event::listen( - \SocialiteProviders\Manager\SocialiteWasCalled::class, - \SocialiteProviders\Discord\DiscordExtendSocialite::class, + SocialiteWasCalled::class, + DiscordExtendSocialite::class, ); // Apple provider removed — no listener registered // ── Posts / Feed System Events ────────────────────────────────────── Event::listen( - \App\Events\Posts\ArtworkShared::class, - \App\Listeners\Posts\SendArtworkSharedNotification::class, + ArtworkShared::class, + SendArtworkSharedNotification::class, ); Event::listen( - \App\Events\Posts\PostCommented::class, - \App\Listeners\Posts\SendPostCommentedNotification::class, + PostCommented::class, + SendPostCommentedNotification::class, ); Event::listen( - \App\Events\Posts\PostCommented::class, - \App\Listeners\Posts\AwardXpForPostCommented::class, + PostCommented::class, + AwardXpForPostCommented::class, ); Event::listen( - \App\Events\Achievements\AchievementCheckRequested::class, - \App\Listeners\Achievements\CheckUserAchievements::class, + AchievementCheckRequested::class, + CheckUserAchievements::class, ); Event::listen( - \App\Events\Achievements\UserXpUpdated::class, - \App\Listeners\Achievements\CheckUserAchievements::class, + UserXpUpdated::class, + CheckUserAchievements::class, ); Event::listen( WebhookReceived::class, @@ -190,130 +207,138 @@ class AppServiceProvider extends ServiceProvider ); // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) - View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { - $uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0; - $avatarHash = null; - $displayName = null; - $userId = null; - $toolbarContentTypes = collect(); - $toolbarActiveCampaign = null; - $request = request(); - $canReadSessionAuth = $request instanceof \Illuminate\Http\Request - && $request->hasSession() - && $request->attributes->get('skinbase.session_skipped') !== true; - $authUser = $canReadSessionAuth ? Auth::user() : null; - - try { - $toolbarContentTypes = $this->app - ->make(ContentTypeSlugResolver::class) - ->toolbarContentTypes() - ->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug'])) - ->map(fn (array $attributes) => new ContentType($attributes)); - } catch (\Throwable $e) { + $registerNovaLayoutComposer = function (Factory $factory): void { + $factory->composer(['layouts.nova', 'layouts.nova.*'], function ($view) { + $uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0; + $avatarHash = null; + $displayName = null; + $userId = null; $toolbarContentTypes = collect(); - } - - try { - $toolbarActiveCampaign = $this->app - ->make(WorldService::class) - ->navigationCampaign(); - } catch (\Throwable $e) { $toolbarActiveCampaign = null; - } - - if ($authUser) { - $authUser->loadMissing('profile'); - $userId = (int) $authUser->id; + $request = request(); + $canReadSessionAuth = $request instanceof Request + && $request->hasSession() + && $request->attributes->get('skinbase.session_skipped') !== true; + $authUser = $canReadSessionAuth ? Auth::user() : null; try { - $ttl = (int) config('toolbar.cache_ttl_seconds', 30); - - $stats = Cache::remember("toolbar:{$userId}", $ttl, function () use ($userId) { - $toolbarStats = DB::table('users') - ->select('id') - ->selectSub( - DB::table('artworks') - ->selectRaw('COUNT(*)') - ->whereColumn('user_id', 'users.id'), - 'upload_count' - ) - ->selectSub( - DB::table('artwork_favourites') - ->selectRaw('COUNT(*)') - ->whereColumn('user_id', 'users.id'), - 'fav_count' - ) - ->selectSub( - DB::table('notifications') - ->selectRaw('COUNT(*)') - ->whereColumn('user_id', 'users.id') - ->whereNull('read_at'), - 'notice_count' - ) - ->where('id', $userId) - ->first(); - - $uploadCount = (int) ($toolbarStats->upload_count ?? 0); - $favCount = (int) ($toolbarStats->fav_count ?? 0); - $noticeCount = (int) ($toolbarStats->notice_count ?? 0); - - $msgCount = (int) DB::table('conversation_participants as cp') - ->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id') - ->where('cp.user_id', $userId) - ->whereNull('cp.left_at') - ->whereNull('m.deleted_at') - ->where('m.sender_id', '!=', $userId) - ->where(function ($q) { - $q->whereNull('cp.last_read_at') - ->orWhereColumn('m.created_at', '>', 'cp.last_read_at'); - }) - ->count(); - - return [ - 'upload_count' => $uploadCount, - 'fav_count' => $favCount, - 'notice_count' => $noticeCount, - 'msg_count' => $msgCount, - ]; - }); - - $uploadCount = (int) ($stats['upload_count'] ?? 0); - $favCount = (int) ($stats['fav_count'] ?? 0); - $noticeCount = (int) ($stats['notice_count'] ?? 0); - $msgCount = (int) ($stats['msg_count'] ?? 0); + $toolbarContentTypes = $this->app + ->make(ContentTypeSlugResolver::class) + ->toolbarContentTypes() + ->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug'])) + ->map(fn (array $attributes) => new ContentType($attributes)); } catch (\Throwable $e) { - $uploadCount = 0; - $favCount = 0; - $msgCount = 0; - $noticeCount = 0; + $toolbarContentTypes = collect(); } try { - $receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class) - ->unreadCountForUser($authUser); + $toolbarActiveCampaign = $this->app + ->make(WorldService::class) + ->navigationCampaign(); } catch (\Throwable $e) { - $receivedCommentsCount = 0; + $toolbarActiveCampaign = null; } - $avatarHash = $authUser->profile?->avatar_hash; - $displayName = $authUser->name ?: ($authUser->username ?? ''); - } + if ($authUser) { + $authUser->loadMissing('profile'); + $userId = (int) $authUser->id; - $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign')); - }); + try { + $ttl = (int) config('toolbar.cache_ttl_seconds', 30); + + $stats = Cache::remember("toolbar:{$userId}", $ttl, function () use ($userId) { + $toolbarStats = DB::table('users') + ->select('id') + ->selectSub( + DB::table('artworks') + ->selectRaw('COUNT(*)') + ->whereColumn('user_id', 'users.id'), + 'upload_count' + ) + ->selectSub( + DB::table('artwork_favourites') + ->selectRaw('COUNT(*)') + ->whereColumn('user_id', 'users.id'), + 'fav_count' + ) + ->selectSub( + DB::table('notifications') + ->selectRaw('COUNT(*)') + ->whereColumn('user_id', 'users.id') + ->whereNull('read_at'), + 'notice_count' + ) + ->where('id', $userId) + ->first(); + + $uploadCount = (int) ($toolbarStats->upload_count ?? 0); + $favCount = (int) ($toolbarStats->fav_count ?? 0); + $noticeCount = (int) ($toolbarStats->notice_count ?? 0); + + $msgCount = (int) DB::table('conversation_participants as cp') + ->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id') + ->where('cp.user_id', $userId) + ->whereNull('cp.left_at') + ->whereNull('m.deleted_at') + ->where('m.sender_id', '!=', $userId) + ->where(function ($q) { + $q->whereNull('cp.last_read_at') + ->orWhereColumn('m.created_at', '>', 'cp.last_read_at'); + }) + ->count(); + + return [ + 'upload_count' => $uploadCount, + 'fav_count' => $favCount, + 'notice_count' => $noticeCount, + 'msg_count' => $msgCount, + ]; + }); + + $uploadCount = (int) ($stats['upload_count'] ?? 0); + $favCount = (int) ($stats['fav_count'] ?? 0); + $noticeCount = (int) ($stats['notice_count'] ?? 0); + $msgCount = (int) ($stats['msg_count'] ?? 0); + } catch (\Throwable $e) { + $uploadCount = 0; + $favCount = 0; + $msgCount = 0; + $noticeCount = 0; + } + + try { + $receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class) + ->unreadCountForUser($authUser); + } catch (\Throwable $e) { + $receivedCommentsCount = 0; + } + + $avatarHash = $authUser->profile?->avatar_hash; + $displayName = $authUser->name ?: ($authUser->username ?? ''); + } + + $view->with(compact('userId', 'uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign')); + }); + }; + + if ($this->app->resolved('view')) { + $registerNovaLayoutComposer($this->app->make('view')); + } else { + $this->app->afterResolving('view', $registerNovaLayoutComposer); + } // Replace the framework HandleCors with our ConditionalCors so the // CP_ENABLE_CORS / config('cors.paths') toggle takes effect. try { - $middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class); + $middlewareConfig = $this->app->make(Middleware::class); $middlewareConfig->replace( - \Illuminate\Http\Middleware\HandleCors::class, - \App\Http\Middleware\ConditionalCors::class + HandleCors::class, + ConditionalCors::class ); } catch (\Throwable $_) { // Fallback: push to kernel if replace isn't available in this app instance - $this->app->make(\Illuminate\Contracts\Http\Kernel::class) - ->pushMiddleware(\App\Http\Middleware\ConditionalCors::class); + $this->app->make(Kernel::class) + ->pushMiddleware(ConditionalCors::class); } } @@ -322,13 +347,13 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('register-ip', function (Request $request): Limit { $limit = max(1, (int) config('registration.ip_per_minute_limit', 3)); - return Limit::perMinute($limit)->by('register:ip:' . $request->ip()); + return Limit::perMinute($limit)->by('register:ip:'.$request->ip()); }); RateLimiter::for('register-ip-daily', function (Request $request): Limit { $limit = max(1, (int) config('registration.ip_per_day_limit', 20)); - return Limit::perDay($limit)->by('register:ip:daily:' . $request->ip()); + return Limit::perDay($limit)->by('register:ip:daily:'.$request->ip()); }); RateLimiter::for('register', function (Request $request): array { @@ -337,8 +362,8 @@ class AppServiceProvider extends ServiceProvider $emailLimit = (int) config('registration.email_per_minute_limit', 6); return [ - Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()), - Limit::perMinute($emailLimit)->by('register:email:' . $emailKey), + Limit::perMinute($ipLimit)->by('register:ip:'.$request->ip()), + Limit::perMinute($emailLimit)->by('register:email:'.$emailKey), ]; }); } @@ -382,15 +407,15 @@ class AppServiceProvider extends ServiceProvider private function configureNovaCardRateLimiters(): void { foreach (['drafts', 'autosave', 'publish', 'background_upload', 'render'] as $key) { - RateLimiter::for('nova-cards-' . str_replace('_', '-', $key), function (Request $request) use ($key): array { - $config = (array) config('nova_cards.rate_limits.' . $key, []); + RateLimiter::for('nova-cards-'.str_replace('_', '-', $key), function (Request $request) use ($key): array { + $config = (array) config('nova_cards.rate_limits.'.$key, []); $perUser = max(1, (int) ($config['per_user'] ?? 30)); $perIp = max(1, (int) ($config['per_ip'] ?? 60)); - $scope = 'nova-cards:' . $key . ':'; + $scope = 'nova-cards:'.$key.':'; return [ - Limit::perMinute($perUser)->by($scope . 'user:' . ($request->user()?->id ?? 'guest')), - Limit::perMinute($perIp)->by($scope . 'ip:' . $request->ip()), + Limit::perMinute($perUser)->by($scope.'user:'.($request->user()?->id ?? 'guest')), + Limit::perMinute($perIp)->by($scope.'ip:'.$request->ip()), ]; }); } @@ -398,7 +423,7 @@ class AppServiceProvider extends ServiceProvider private function buildUploadLimits(Request $request, string $key): array { - $config = (array) config('uploads.rate_limits.' . $key, []); + $config = (array) config('uploads.rate_limits.'.$key, []); $decay = (int) config('uploads.rate_limits.decay_minutes', 1); $perUser = (int) ($config['per_user'] ?? 0); $perIp = (int) ($config['per_ip'] ?? 0); @@ -407,11 +432,11 @@ class AppServiceProvider extends ServiceProvider if ($perUser > 0) { $userId = $request->user()?->id ?? 'guest'; - $limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId); + $limits[] = Limit::perMinutes($decay, $perUser)->by('u:'.$userId); } if ($perIp > 0) { - $limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip()); + $limits[] = Limit::perMinutes($decay, $perIp)->by('ip:'.$request->ip()); } return $limits; @@ -423,8 +448,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(20)->by('messages:user:' . $userId), - Limit::perMinute(40)->by('messages:ip:' . $request->ip()), + Limit::perMinute(20)->by('messages:user:'.$userId), + Limit::perMinute(40)->by('messages:ip:'.$request->ip()), ]; }); @@ -432,8 +457,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(60)->by('messages:react:user:' . $userId), - Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), + Limit::perMinute(60)->by('messages:react:user:'.$userId), + Limit::perMinute(120)->by('messages:react:ip:'.$request->ip()), ]; }); @@ -441,8 +466,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(120)->by('messages:read:user:' . $userId), - Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()), + Limit::perMinute(120)->by('messages:read:user:'.$userId), + Limit::perMinute(240)->by('messages:read:ip:'.$request->ip()), ]; }); @@ -451,8 +476,8 @@ class AppServiceProvider extends ServiceProvider $conversationId = (int) $request->route('conversation_id'); return [ - Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId), - Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()), + Limit::perMinute(90)->by('messages:typing:user:'.$userId.':conv:'.$conversationId), + Limit::perMinute(180)->by('messages:typing:ip:'.$request->ip()), ]; }); @@ -461,8 +486,8 @@ class AppServiceProvider extends ServiceProvider $conversationId = (int) $request->route('conversation_id'); return [ - Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId), - Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()), + Limit::perMinute(30)->by('messages:recovery:user:'.$userId.':conv:'.$conversationId), + Limit::perMinute(60)->by('messages:recovery:ip:'.$request->ip()), ]; }); @@ -470,8 +495,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(180)->by('messages:presence:user:' . $userId), - Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()), + Limit::perMinute(180)->by('messages:presence:user:'.$userId), + Limit::perMinute(300)->by('messages:presence:ip:'.$request->ip()), ]; }); } @@ -484,8 +509,8 @@ class AppServiceProvider extends ServiceProvider // Higher user-based allowance prevents false positives for active users, // while IP limit still protects guest endpoints from bursts. return [ - Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')), - Limit::perMinute(120)->by('downloads:ip:' . $request->ip()), + Limit::perMinute(60)->by('downloads:user:'.($userId ?? 'guest')), + Limit::perMinute(120)->by('downloads:ip:'.$request->ip()), ]; }); } @@ -498,8 +523,8 @@ class AppServiceProvider extends ServiceProvider // Search fans out to Meilisearch + DB queries per request, so IP // limits are kept tight to blunt scripted floods of /search traffic. return [ - Limit::perMinute(20)->by('search:user:' . ($userId ?? 'guest')), - Limit::perMinute(30)->by('search:ip:' . $request->ip()), + Limit::perMinute(20)->by('search:user:'.($userId ?? 'guest')), + Limit::perMinute(30)->by('search:ip:'.$request->ip()), ]; }); } @@ -514,8 +539,8 @@ class AppServiceProvider extends ServiceProvider // per-IP allowance tight — unlike cached list endpoints, a flood of // distinct artwork IDs can't be absorbed by cache alone. return [ - Limit::perMinute(30)->by('vector-search:user:' . ($userId ?? 'guest')), - Limit::perMinute(20)->by('vector-search:ip:' . $request->ip()), + Limit::perMinute(30)->by('vector-search:user:'.($userId ?? 'guest')), + Limit::perMinute(20)->by('vector-search:ip:'.$request->ip()), ]; }); } @@ -529,10 +554,10 @@ class AppServiceProvider extends ServiceProvider return [ // Prevent burst spam on a single artwork while allowing normal exploration. - Limit::perMinute($perMinute)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId), + Limit::perMinute($perMinute)->by('awards:user:'.($userId ?? 'guest').':art:'.$artworkId), // Global safety net for user/IP across all artworks. - Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')), - Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()), + Limit::perMinute($perMinute * 6)->by('awards:user:'.($userId ?? 'guest')), + Limit::perMinute($perMinute * 9)->by('awards:ip:'.$request->ip()), ]; }); } @@ -544,8 +569,8 @@ class AppServiceProvider extends ServiceProvider return [ // Comment-heavy pages can trigger many reaction reads at once. - Limit::perMinute(600)->by('reactions-read:user:' . ($userId ?? 'guest')), - Limit::perMinute(900)->by('reactions-read:ip:' . $request->ip()), + Limit::perMinute(600)->by('reactions-read:user:'.($userId ?? 'guest')), + Limit::perMinute(900)->by('reactions-read:ip:'.$request->ip()), ]; }); @@ -553,8 +578,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id; return [ - Limit::perMinute(120)->by('reactions-write:user:' . ($userId ?? 'guest')), - Limit::perMinute(180)->by('reactions-write:ip:' . $request->ip()), + Limit::perMinute(120)->by('reactions-write:user:'.($userId ?? 'guest')), + Limit::perMinute(180)->by('reactions-write:ip:'.$request->ip()), ]; }); } @@ -565,9 +590,9 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(12)->by('follow-write:minute:user:' . $userId), - Limit::perHour(120)->by('follow-write:hour:user:' . $userId), - Limit::perMinute(24)->by('follow-write:minute:ip:' . $request->ip()), + Limit::perMinute(12)->by('follow-write:minute:user:'.$userId), + Limit::perHour(120)->by('follow-write:hour:user:'.$userId), + Limit::perMinute(24)->by('follow-write:minute:ip:'.$request->ip()), ]; }); @@ -575,8 +600,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(60)->by('social-write:user:' . $userId), - Limit::perMinute(120)->by('social-write:ip:' . $request->ip()), + Limit::perMinute(60)->by('social-write:user:'.$userId), + Limit::perMinute(120)->by('social-write:ip:'.$request->ip()), ]; }); @@ -584,8 +609,8 @@ class AppServiceProvider extends ServiceProvider $userId = $request->user()?->id ?? 'guest'; return [ - Limit::perMinute(240)->by('social-read:user:' . $userId), - Limit::perMinute(480)->by('social-read:ip:' . $request->ip()), + Limit::perMinute(240)->by('social-read:user:'.$userId), + Limit::perMinute(480)->by('social-read:ip:'.$request->ip()), ]; }); } @@ -593,7 +618,7 @@ class AppServiceProvider extends ServiceProvider private function configureSettingsRateLimiters(): void { RateLimiter::for('username-check', function (Request $request): Limit { - $key = 'username-check:ip:' . $request->ip(); + $key = 'username-check:ip:'.$request->ip(); if (method_exists(Limit::class, 'perSecond')) { return Limit::perSecond(5)->by($key); @@ -605,8 +630,8 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('email-change-request', function (Request $request): Limit { $userId = $request->user()?->id; $key = $userId !== null - ? 'email-change-request:user:' . $userId - : 'email-change-request:ip:' . $request->ip(); + ? 'email-change-request:user:'.$userId + : 'email-change-request:ip:'.$request->ip(); return Limit::perHour(1)->by($key); }); @@ -648,6 +673,6 @@ class AppServiceProvider extends ServiceProvider $request = $this->app->make('request'); - return $request->is($prefix) || $request->is($prefix . '/*'); + return $request->is($prefix) || $request->is($prefix.'/*'); } }