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:
2026-09-20 14:49:00 +02:00
parent 586c44ba74
commit 31c87e4977
9 changed files with 336 additions and 250 deletions
+4 -1
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Observers\ArtworkAwardObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[ObservedBy([ArtworkAwardObserver::class])]
class ArtworkAward extends Model class ArtworkAward extends Model
{ {
protected $table = 'artwork_medals'; protected $table = 'artwork_medals';
@@ -35,7 +38,7 @@ class ArtworkAward extends Model
public static function weightFor(string $medal): int 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);
} }
/** /**
+6 -3
View File
@@ -2,6 +2,9 @@
namespace App\Models; 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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,12 +15,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $user_id * @property int $user_id
* @property int $artwork_id * @property int $artwork_id
* @property int|null $legacy_id Original favourite_id from the old site * @property int|null $legacy_id Original favourite_id from the old site
* @property \Carbon\Carbon $created_at * @property Carbon $created_at
* @property \Carbon\Carbon $updated_at * @property Carbon $updated_at
*
* @property-read User $user * @property-read User $user
* @property-read Artwork $artwork * @property-read Artwork $artwork
*/ */
#[ObservedBy([ArtworkFavouriteObserver::class])]
class ArtworkFavourite extends Model class ArtworkFavourite extends Model
{ {
protected $table = 'artwork_favourites'; protected $table = 'artwork_favourites';
+4
View File
@@ -1,10 +1,14 @@
<?php <?php
namespace App\Models; namespace App\Models;
use App\Observers\ArtworkFeatureObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy([ArtworkFeatureObserver::class])]
class ArtworkFeature extends Model class ArtworkFeature extends Model
{ {
use SoftDeletes; use SoftDeletes;
+3
View File
@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Observers\ArtworkReactionObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -11,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $user_id * @property int $user_id
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…) * @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
*/ */
#[ObservedBy([ArtworkReactionObserver::class])]
class ArtworkReaction extends Model class ArtworkReaction extends Model
{ {
public $timestamps = false; public $timestamps = false;
+6 -5
View File
@@ -2,15 +2,16 @@
namespace App\Models; 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\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use App\Models\Artwork;
#[ObservedBy([ContentTypeObserver::class])]
class ContentType extends Model 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 = [ protected $casts = [
'order' => 'integer', 'order' => 'integer',
@@ -81,6 +82,6 @@ class ContentType extends Model
return $path; 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, '/');
} }
} }
+18 -1
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Observers\GroupReleaseObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy([GroupReleaseObserver::class])]
class GroupRelease extends Model class GroupRelease extends Model
{ {
use HasFactory; use HasFactory;
use SoftDeletes; use SoftDeletes;
public const STATUS_PLANNED = 'planned'; public const STATUS_PLANNED = 'planned';
public const STATUS_IN_PROGRESS = 'in_progress'; public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_INTERNAL_REVIEW = 'internal_review'; public const STATUS_INTERNAL_REVIEW = 'internal_review';
public const STATUS_SCHEDULED = 'scheduled'; public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_RELEASED = 'released'; public const STATUS_RELEASED = 'released';
public const STATUS_ARCHIVED = 'archived'; public const STATUS_ARCHIVED = 'archived';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
public const STAGE_CONCEPT = 'concept'; public const STAGE_CONCEPT = 'concept';
public const STAGE_PRODUCTION = 'production'; public const STAGE_PRODUCTION = 'production';
public const STAGE_REVIEW = 'review'; public const STAGE_REVIEW = 'review';
public const STAGE_PACKAGING = 'packaging'; public const STAGE_PACKAGING = 'packaging';
public const STAGE_APPROVAL = 'approval'; public const STAGE_APPROVAL = 'approval';
public const STAGE_PUBLISHING = 'publishing'; public const STAGE_PUBLISHING = 'publishing';
public const STAGE_RELEASED = 'released'; public const STAGE_RELEASED = 'released';
public const VISIBILITY_PUBLIC = 'public'; public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted'; public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private'; public const VISIBILITY_PRIVATE = 'private';
protected $fillable = [ protected $fillable = [
@@ -151,6 +168,6 @@ class GroupRelease extends Model
return $path; 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, '/');
} }
} }
+3
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Observers\GroupReleaseContributorObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[ObservedBy([GroupReleaseContributorObserver::class])]
class GroupReleaseContributor extends Model class GroupReleaseContributor extends Model
{ {
use HasFactory; use HasFactory;
+27
View File
@@ -4,48 +4,75 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Observers\HomepageAnnouncementObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
#[ObservedBy([HomepageAnnouncementObserver::class])]
class HomepageAnnouncement extends Model class HomepageAnnouncement extends Model
{ {
use SoftDeletes; use SoftDeletes;
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published'; public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived'; public const STATUS_ARCHIVED = 'archived';
public const TYPE_ANNOUNCEMENT = 'announcement'; public const TYPE_ANNOUNCEMENT = 'announcement';
public const TYPE_LAUNCH = 'launch'; public const TYPE_LAUNCH = 'launch';
public const TYPE_NEWS = 'news'; public const TYPE_NEWS = 'news';
public const TYPE_WORLD = 'world'; public const TYPE_WORLD = 'world';
public const TYPE_EVENT = 'event'; public const TYPE_EVENT = 'event';
public const TYPE_NOTICE = 'notice'; public const TYPE_NOTICE = 'notice';
public const TYPE_MAINTENANCE = 'maintenance'; public const TYPE_MAINTENANCE = 'maintenance';
public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured'; public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured';
public const LINK_TYPE_NONE = 'none'; public const LINK_TYPE_NONE = 'none';
public const LINK_TYPE_CUSTOM_URL = 'custom_url'; public const LINK_TYPE_CUSTOM_URL = 'custom_url';
public const LINK_TYPE_NEWS = 'news'; public const LINK_TYPE_NEWS = 'news';
public const LINK_TYPE_WORLD = 'world'; public const LINK_TYPE_WORLD = 'world';
public const LINK_TYPE_ARTWORK = 'artwork'; public const LINK_TYPE_ARTWORK = 'artwork';
public const LINK_TYPE_COLLECTION = 'collection'; public const LINK_TYPE_COLLECTION = 'collection';
public const LINK_TYPE_GROUP = 'group'; public const LINK_TYPE_GROUP = 'group';
public const LINK_TYPE_PROFILE = 'profile'; public const LINK_TYPE_PROFILE = 'profile';
public const LINK_TYPE_EXPLORE = 'explore'; public const LINK_TYPE_EXPLORE = 'explore';
public const LINK_TYPE_UPLOAD = 'upload'; public const LINK_TYPE_UPLOAD = 'upload';
public const GRADIENT_NOVA_AURORA = 'nova_aurora'; public const GRADIENT_NOVA_AURORA = 'nova_aurora';
public const GRADIENT_DEEP_SPACE = 'deep_space'; public const GRADIENT_DEEP_SPACE = 'deep_space';
public const GRADIENT_SUNRISE = 'sunrise'; public const GRADIENT_SUNRISE = 'sunrise';
public const GRADIENT_OCEAN_GLOW = 'ocean_glow'; public const GRADIENT_OCEAN_GLOW = 'ocean_glow';
public const GRADIENT_SPRING_VIBES = 'spring_vibes'; public const GRADIENT_SPRING_VIBES = 'spring_vibes';
public const GRADIENT_FANTASY_REALMS = 'fantasy_realms'; public const GRADIENT_FANTASY_REALMS = 'fantasy_realms';
public const GRADIENT_MINIMAL_LIGHT = 'minimal_light'; public const GRADIENT_MINIMAL_LIGHT = 'minimal_light';
public const GRADIENT_DARK_GLASS = 'dark_glass'; public const GRADIENT_DARK_GLASS = 'dark_glass';
protected $table = 'homepage_announcements'; protected $table = 'homepage_announcements';
+151 -126
View File
@@ -3,53 +3,59 @@
namespace App\Providers; namespace App\Providers;
use App\Contracts\Images\SubjectDetectorInterface; use App\Contracts\Images\SubjectDetectorInterface;
use Illuminate\Cache\RateLimiting\Limit; use App\Contracts\Moderation\CommentSpamClassifier;
use Illuminate\Http\Request; use App\Events\Achievements\AchievementCheckRequested;
use Illuminate\Support\Facades\RateLimiter; use App\Events\Achievements\UserXpUpdated;
use Illuminate\Support\ServiceProvider; 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\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\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\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\ContentTypes\ContentTypeSlugResolver;
use App\Services\Worlds\WorldService; use App\Services\Countries\CountryRemoteProvider;
use Illuminate\Support\Facades\Blade; use App\Services\Countries\CountryRemoteProviderInterface;
use Illuminate\Support\Facades\View; use App\Services\EarlyGrowth\SpotlightEngine;
use Illuminate\Support\Facades\DB; use App\Services\EarlyGrowth\SpotlightEngineInterface;
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\Images\Detectors\ChainedSubjectDetector; use App\Services\Images\Detectors\ChainedSubjectDetector;
use App\Services\Images\Detectors\HeuristicSubjectDetector; use App\Services\Images\Detectors\HeuristicSubjectDetector;
use App\Services\Images\Detectors\NullSubjectDetector; use App\Services\Images\Detectors\NullSubjectDetector;
use App\Services\Images\Detectors\VisionSubjectDetector; 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 Klevze\ControlPanel\Framework\Core\Menu;
use Laravel\Cashier\Events\WebhookHandled; use Laravel\Cashier\Events\WebhookHandled;
use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\Academy\HandleAcademyStripeWebhook; use Meilisearch\Client;
use App\Listeners\Academy\HandleAcademyStripeWebhookHandled; use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -58,14 +64,18 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind(CommentSpamClassifier::class, function ($app) {
return $app->make(TogetherCommentSpamClassifier::class);
});
$this->app->bind( $this->app->bind(
\Inertia\Ssr\Gateway::class, Gateway::class,
\App\Support\Http\TimedInertiaSsrGateway::class, TimedInertiaSsrGateway::class,
); );
$this->app->singleton( $this->app->singleton(
\App\Services\Countries\CountryRemoteProviderInterface::class, CountryRemoteProviderInterface::class,
\App\Services\Countries\CountryRemoteProvider::class, CountryRemoteProvider::class,
); );
// Bind UploadDraftService interface to implementation // Bind UploadDraftService interface to implementation
@@ -75,14 +85,14 @@ class AppServiceProvider extends ServiceProvider
// Bind vector adapter interface for similarity system (resolves via factory) // Bind vector adapter interface for similarity system (resolves via factory)
$this->app->bind( $this->app->bind(
\App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class, VectorAdapterInterface::class,
fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(), fn () => VectorAdapterFactory::make(),
); );
// EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine // EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine
$this->app->singleton( $this->app->singleton(
\App\Services\EarlyGrowth\SpotlightEngineInterface::class, SpotlightEngineInterface::class,
\App\Services\EarlyGrowth\SpotlightEngine::class, SpotlightEngine::class,
); );
$this->app->singleton(SubjectDetectorInterface::class, function ($app) { $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 // with no HTTP timeout, so a slow/overloaded Meilisearch leaves PHP-FPM
// workers blocked forever in curl_exec(), draining the whole pool under // workers blocked forever in curl_exec(), draining the whole pool under
// a search traffic spike. Fail fast instead. // 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'); $config = $app['config']->get('scout.meilisearch');
$httpClient = new \GuzzleHttp\Client([ $httpClient = new \GuzzleHttp\Client([
@@ -105,7 +115,7 @@ class AppServiceProvider extends ServiceProvider
'timeout' => 5, '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 // Map the 'legacy' view namespace to resources/views/_legacy so all
// view('legacy::foo') and @include('legacy::foo') calls resolve correctly // view('legacy::foo') and @include('legacy::foo') calls resolve correctly
// after the folder was renamed from legacy/ to _legacy/. // 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'); $exceptionRendererComponentsPath = base_path('vendor/laravel/framework/src/Illuminate/Foundation/resources/exceptions/renderer/components');
$registerExceptionRendererComponents = static function ($compiler) use ($exceptionRendererComponentsPath): void {
if (is_dir($exceptionRendererComponentsPath)) { if (is_dir($exceptionRendererComponentsPath)) {
Blade::anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer'); $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(); $this->configureAuthRateLimiters();
@@ -140,45 +167,35 @@ class AppServiceProvider extends ServiceProvider
$this->configureSettingsRateLimiters(); $this->configureSettingsRateLimiters();
$this->configureMailFailureLogging(); $this->configureMailFailureLogging();
ArtworkAward::observe(ArtworkAwardObserver::class);
ArtworkMedal::observe(ArtworkAwardObserver::class);
Artwork::observe(ArtworkObserver::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 ────────────────────────────────────── // ── OAuth / SocialiteProviders ──────────────────────────────────────
Event::listen( Event::listen(
\SocialiteProviders\Manager\SocialiteWasCalled::class, SocialiteWasCalled::class,
\SocialiteProviders\Discord\DiscordExtendSocialite::class, DiscordExtendSocialite::class,
); );
// Apple provider removed — no listener registered // Apple provider removed — no listener registered
// ── Posts / Feed System Events ────────────────────────────────────── // ── Posts / Feed System Events ──────────────────────────────────────
Event::listen( Event::listen(
\App\Events\Posts\ArtworkShared::class, ArtworkShared::class,
\App\Listeners\Posts\SendArtworkSharedNotification::class, SendArtworkSharedNotification::class,
); );
Event::listen( Event::listen(
\App\Events\Posts\PostCommented::class, PostCommented::class,
\App\Listeners\Posts\SendPostCommentedNotification::class, SendPostCommentedNotification::class,
); );
Event::listen( Event::listen(
\App\Events\Posts\PostCommented::class, PostCommented::class,
\App\Listeners\Posts\AwardXpForPostCommented::class, AwardXpForPostCommented::class,
); );
Event::listen( Event::listen(
\App\Events\Achievements\AchievementCheckRequested::class, AchievementCheckRequested::class,
\App\Listeners\Achievements\CheckUserAchievements::class, CheckUserAchievements::class,
); );
Event::listen( Event::listen(
\App\Events\Achievements\UserXpUpdated::class, UserXpUpdated::class,
\App\Listeners\Achievements\CheckUserAchievements::class, CheckUserAchievements::class,
); );
Event::listen( Event::listen(
WebhookReceived::class, WebhookReceived::class,
@@ -190,7 +207,8 @@ class AppServiceProvider extends ServiceProvider
); );
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic) // Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { $registerNovaLayoutComposer = function (Factory $factory): void {
$factory->composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
$uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0; $uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0;
$avatarHash = null; $avatarHash = null;
$displayName = null; $displayName = null;
@@ -198,7 +216,7 @@ class AppServiceProvider extends ServiceProvider
$toolbarContentTypes = collect(); $toolbarContentTypes = collect();
$toolbarActiveCampaign = null; $toolbarActiveCampaign = null;
$request = request(); $request = request();
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request $canReadSessionAuth = $request instanceof Request
&& $request->hasSession() && $request->hasSession()
&& $request->attributes->get('skinbase.session_skipped') !== true; && $request->attributes->get('skinbase.session_skipped') !== true;
$authUser = $canReadSessionAuth ? Auth::user() : null; $authUser = $canReadSessionAuth ? Auth::user() : null;
@@ -299,21 +317,28 @@ class AppServiceProvider extends ServiceProvider
$displayName = $authUser->name ?: ($authUser->username ?? ''); $displayName = $authUser->name ?: ($authUser->username ?? '');
} }
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign')); $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 // Replace the framework HandleCors with our ConditionalCors so the
// CP_ENABLE_CORS / config('cors.paths') toggle takes effect. // CP_ENABLE_CORS / config('cors.paths') toggle takes effect.
try { try {
$middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class); $middlewareConfig = $this->app->make(Middleware::class);
$middlewareConfig->replace( $middlewareConfig->replace(
\Illuminate\Http\Middleware\HandleCors::class, HandleCors::class,
\App\Http\Middleware\ConditionalCors::class ConditionalCors::class
); );
} catch (\Throwable $_) { } catch (\Throwable $_) {
// Fallback: push to kernel if replace isn't available in this app instance // Fallback: push to kernel if replace isn't available in this app instance
$this->app->make(\Illuminate\Contracts\Http\Kernel::class) $this->app->make(Kernel::class)
->pushMiddleware(\App\Http\Middleware\ConditionalCors::class); ->pushMiddleware(ConditionalCors::class);
} }
} }
@@ -322,13 +347,13 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('register-ip', function (Request $request): Limit { RateLimiter::for('register-ip', function (Request $request): Limit {
$limit = max(1, (int) config('registration.ip_per_minute_limit', 3)); $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 { RateLimiter::for('register-ip-daily', function (Request $request): Limit {
$limit = max(1, (int) config('registration.ip_per_day_limit', 20)); $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 { RateLimiter::for('register', function (Request $request): array {
@@ -337,8 +362,8 @@ class AppServiceProvider extends ServiceProvider
$emailLimit = (int) config('registration.email_per_minute_limit', 6); $emailLimit = (int) config('registration.email_per_minute_limit', 6);
return [ return [
Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()), Limit::perMinute($ipLimit)->by('register:ip:'.$request->ip()),
Limit::perMinute($emailLimit)->by('register:email:' . $emailKey), Limit::perMinute($emailLimit)->by('register:email:'.$emailKey),
]; ];
}); });
} }
@@ -382,15 +407,15 @@ class AppServiceProvider extends ServiceProvider
private function configureNovaCardRateLimiters(): void private function configureNovaCardRateLimiters(): void
{ {
foreach (['drafts', 'autosave', 'publish', 'background_upload', 'render'] as $key) { foreach (['drafts', 'autosave', 'publish', 'background_upload', 'render'] as $key) {
RateLimiter::for('nova-cards-' . str_replace('_', '-', $key), function (Request $request) use ($key): array { RateLimiter::for('nova-cards-'.str_replace('_', '-', $key), function (Request $request) use ($key): array {
$config = (array) config('nova_cards.rate_limits.' . $key, []); $config = (array) config('nova_cards.rate_limits.'.$key, []);
$perUser = max(1, (int) ($config['per_user'] ?? 30)); $perUser = max(1, (int) ($config['per_user'] ?? 30));
$perIp = max(1, (int) ($config['per_ip'] ?? 60)); $perIp = max(1, (int) ($config['per_ip'] ?? 60));
$scope = 'nova-cards:' . $key . ':'; $scope = 'nova-cards:'.$key.':';
return [ return [
Limit::perMinute($perUser)->by($scope . 'user:' . ($request->user()?->id ?? 'guest')), Limit::perMinute($perUser)->by($scope.'user:'.($request->user()?->id ?? 'guest')),
Limit::perMinute($perIp)->by($scope . 'ip:' . $request->ip()), Limit::perMinute($perIp)->by($scope.'ip:'.$request->ip()),
]; ];
}); });
} }
@@ -398,7 +423,7 @@ class AppServiceProvider extends ServiceProvider
private function buildUploadLimits(Request $request, string $key): array 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); $decay = (int) config('uploads.rate_limits.decay_minutes', 1);
$perUser = (int) ($config['per_user'] ?? 0); $perUser = (int) ($config['per_user'] ?? 0);
$perIp = (int) ($config['per_ip'] ?? 0); $perIp = (int) ($config['per_ip'] ?? 0);
@@ -407,11 +432,11 @@ class AppServiceProvider extends ServiceProvider
if ($perUser > 0) { if ($perUser > 0) {
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId); $limits[] = Limit::perMinutes($decay, $perUser)->by('u:'.$userId);
} }
if ($perIp > 0) { if ($perIp > 0) {
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip()); $limits[] = Limit::perMinutes($decay, $perIp)->by('ip:'.$request->ip());
} }
return $limits; return $limits;
@@ -423,8 +448,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(20)->by('messages:user:' . $userId), Limit::perMinute(20)->by('messages:user:'.$userId),
Limit::perMinute(40)->by('messages:ip:' . $request->ip()), Limit::perMinute(40)->by('messages:ip:'.$request->ip()),
]; ];
}); });
@@ -432,8 +457,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(60)->by('messages:react:user:' . $userId), Limit::perMinute(60)->by('messages:react:user:'.$userId),
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), Limit::perMinute(120)->by('messages:react:ip:'.$request->ip()),
]; ];
}); });
@@ -441,8 +466,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(120)->by('messages:read:user:' . $userId), Limit::perMinute(120)->by('messages:read:user:'.$userId),
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()), Limit::perMinute(240)->by('messages:read:ip:'.$request->ip()),
]; ];
}); });
@@ -451,8 +476,8 @@ class AppServiceProvider extends ServiceProvider
$conversationId = (int) $request->route('conversation_id'); $conversationId = (int) $request->route('conversation_id');
return [ return [
Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId), Limit::perMinute(90)->by('messages:typing:user:'.$userId.':conv:'.$conversationId),
Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()), Limit::perMinute(180)->by('messages:typing:ip:'.$request->ip()),
]; ];
}); });
@@ -461,8 +486,8 @@ class AppServiceProvider extends ServiceProvider
$conversationId = (int) $request->route('conversation_id'); $conversationId = (int) $request->route('conversation_id');
return [ return [
Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId), Limit::perMinute(30)->by('messages:recovery:user:'.$userId.':conv:'.$conversationId),
Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()), Limit::perMinute(60)->by('messages:recovery:ip:'.$request->ip()),
]; ];
}); });
@@ -470,8 +495,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(180)->by('messages:presence:user:' . $userId), Limit::perMinute(180)->by('messages:presence:user:'.$userId),
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()), 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, // Higher user-based allowance prevents false positives for active users,
// while IP limit still protects guest endpoints from bursts. // while IP limit still protects guest endpoints from bursts.
return [ return [
Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')), Limit::perMinute(60)->by('downloads:user:'.($userId ?? 'guest')),
Limit::perMinute(120)->by('downloads:ip:' . $request->ip()), 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 // Search fans out to Meilisearch + DB queries per request, so IP
// limits are kept tight to blunt scripted floods of /search traffic. // limits are kept tight to blunt scripted floods of /search traffic.
return [ return [
Limit::perMinute(20)->by('search:user:' . ($userId ?? 'guest')), Limit::perMinute(20)->by('search:user:'.($userId ?? 'guest')),
Limit::perMinute(30)->by('search:ip:' . $request->ip()), 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 // per-IP allowance tight — unlike cached list endpoints, a flood of
// distinct artwork IDs can't be absorbed by cache alone. // distinct artwork IDs can't be absorbed by cache alone.
return [ return [
Limit::perMinute(30)->by('vector-search:user:' . ($userId ?? 'guest')), Limit::perMinute(30)->by('vector-search:user:'.($userId ?? 'guest')),
Limit::perMinute(20)->by('vector-search:ip:' . $request->ip()), Limit::perMinute(20)->by('vector-search:ip:'.$request->ip()),
]; ];
}); });
} }
@@ -529,10 +554,10 @@ class AppServiceProvider extends ServiceProvider
return [ return [
// Prevent burst spam on a single artwork while allowing normal exploration. // 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. // Global safety net for user/IP across all artworks.
Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')), Limit::perMinute($perMinute * 6)->by('awards:user:'.($userId ?? 'guest')),
Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()), Limit::perMinute($perMinute * 9)->by('awards:ip:'.$request->ip()),
]; ];
}); });
} }
@@ -544,8 +569,8 @@ class AppServiceProvider extends ServiceProvider
return [ return [
// Comment-heavy pages can trigger many reaction reads at once. // Comment-heavy pages can trigger many reaction reads at once.
Limit::perMinute(600)->by('reactions-read:user:' . ($userId ?? 'guest')), Limit::perMinute(600)->by('reactions-read:user:'.($userId ?? 'guest')),
Limit::perMinute(900)->by('reactions-read:ip:' . $request->ip()), Limit::perMinute(900)->by('reactions-read:ip:'.$request->ip()),
]; ];
}); });
@@ -553,8 +578,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id; $userId = $request->user()?->id;
return [ return [
Limit::perMinute(120)->by('reactions-write:user:' . ($userId ?? 'guest')), Limit::perMinute(120)->by('reactions-write:user:'.($userId ?? 'guest')),
Limit::perMinute(180)->by('reactions-write:ip:' . $request->ip()), Limit::perMinute(180)->by('reactions-write:ip:'.$request->ip()),
]; ];
}); });
} }
@@ -565,9 +590,9 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(12)->by('follow-write:minute:user:' . $userId), Limit::perMinute(12)->by('follow-write:minute:user:'.$userId),
Limit::perHour(120)->by('follow-write:hour:user:' . $userId), Limit::perHour(120)->by('follow-write:hour:user:'.$userId),
Limit::perMinute(24)->by('follow-write:minute:ip:' . $request->ip()), Limit::perMinute(24)->by('follow-write:minute:ip:'.$request->ip()),
]; ];
}); });
@@ -575,8 +600,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(60)->by('social-write:user:' . $userId), Limit::perMinute(60)->by('social-write:user:'.$userId),
Limit::perMinute(120)->by('social-write:ip:' . $request->ip()), Limit::perMinute(120)->by('social-write:ip:'.$request->ip()),
]; ];
}); });
@@ -584,8 +609,8 @@ class AppServiceProvider extends ServiceProvider
$userId = $request->user()?->id ?? 'guest'; $userId = $request->user()?->id ?? 'guest';
return [ return [
Limit::perMinute(240)->by('social-read:user:' . $userId), Limit::perMinute(240)->by('social-read:user:'.$userId),
Limit::perMinute(480)->by('social-read:ip:' . $request->ip()), Limit::perMinute(480)->by('social-read:ip:'.$request->ip()),
]; ];
}); });
} }
@@ -593,7 +618,7 @@ class AppServiceProvider extends ServiceProvider
private function configureSettingsRateLimiters(): void private function configureSettingsRateLimiters(): void
{ {
RateLimiter::for('username-check', function (Request $request): Limit { 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')) { if (method_exists(Limit::class, 'perSecond')) {
return Limit::perSecond(5)->by($key); return Limit::perSecond(5)->by($key);
@@ -605,8 +630,8 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('email-change-request', function (Request $request): Limit { RateLimiter::for('email-change-request', function (Request $request): Limit {
$userId = $request->user()?->id; $userId = $request->user()?->id;
$key = $userId !== null $key = $userId !== null
? 'email-change-request:user:' . $userId ? 'email-change-request:user:'.$userId
: 'email-change-request:ip:' . $request->ip(); : 'email-change-request:ip:'.$request->ip();
return Limit::perHour(1)->by($key); return Limit::perHour(1)->by($key);
}); });
@@ -648,6 +673,6 @@ class AppServiceProvider extends ServiceProvider
$request = $this->app->make('request'); $request = $this->app->make('request');
return $request->is($prefix) || $request->is($prefix . '/*'); return $request->is($prefix) || $request->is($prefix.'/*');
} }
} }