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.
This commit is contained in:
test
2026-09-20 14:49:00 +02:00
parent 3078319f3c
commit 5437f84c4c
9 changed files with 336 additions and 250 deletions
+7 -4
View File
@@ -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);
}
/**
+12 -9
View File
@@ -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 ──────────────────────────────────────────────────────────
+4
View File
@@ -1,10 +1,14 @@
<?php
namespace App\Models;
use App\Observers\ArtworkFeatureObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy([ArtworkFeatureObserver::class])]
class ArtworkFeature extends Model
{
use SoftDeletes;
+8 -5
View File
@@ -2,15 +2,18 @@
namespace App\Models;
use App\Observers\ArtworkReactionObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
*/
#[ObservedBy([ArtworkReactionObserver::class])]
class ArtworkReaction extends Model
{
public $timestamps = false;
@@ -21,7 +24,7 @@ class ArtworkReaction extends Model
protected $casts = [
'artwork_id' => 'integer',
'user_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
];
+6 -5
View File
@@ -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, '/');
}
}
+19 -2
View File
@@ -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, '/');
}
}
}
+4 -1
View File
@@ -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);
}
}
}
+28 -1
View File
@@ -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');
}
}
}
+248 -223
View File
@@ -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.'/*');
}
}