406 lines
16 KiB
PHP
406 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Providers;
|
|
|
|
use Illuminate\Cache\RateLimiting\Limit;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\ServiceProvider;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkAward;
|
|
use App\Models\ArtworkComment;
|
|
use App\Models\ArtworkFavourite;
|
|
use App\Models\ArtworkReaction;
|
|
use App\Observers\ArtworkAwardObserver;
|
|
use App\Observers\ArtworkCommentObserver;
|
|
use App\Observers\ArtworkFavouriteObserver;
|
|
use App\Observers\ArtworkObserver;
|
|
use App\Observers\ArtworkReactionObserver;
|
|
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
|
use App\Services\Upload\UploadDraftService;
|
|
use Illuminate\Support\Facades\View;
|
|
use Illuminate\Support\Facades\DB;
|
|
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 Klevze\ControlPanel\Framework\Core\Menu;
|
|
|
|
class AppServiceProvider extends ServiceProvider
|
|
{
|
|
/**
|
|
* Register any application services.
|
|
*/
|
|
public function register(): void
|
|
{
|
|
$this->app->singleton(
|
|
\App\Services\Countries\CountryRemoteProviderInterface::class,
|
|
\App\Services\Countries\CountryRemoteProvider::class,
|
|
);
|
|
|
|
// Bind UploadDraftService interface to implementation
|
|
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
|
|
return new UploadDraftService($app->make('filesystem'));
|
|
});
|
|
|
|
// 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(),
|
|
);
|
|
|
|
// EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine
|
|
$this->app->singleton(
|
|
\App\Services\EarlyGrowth\SpotlightEngineInterface::class,
|
|
\App\Services\EarlyGrowth\SpotlightEngine::class,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bootstrap any application services.
|
|
*/
|
|
public function boot(): void
|
|
{
|
|
$this->registerCpadMenuItems();
|
|
|
|
// 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'));
|
|
|
|
$this->configureAuthRateLimiters();
|
|
$this->configureUploadRateLimiters();
|
|
$this->configureMessagingRateLimiters();
|
|
$this->configureDownloadRateLimiter();
|
|
$this->configureArtworkRateLimiters();
|
|
$this->configureReactionRateLimiters();
|
|
$this->configureSocialRateLimiters();
|
|
$this->configureSettingsRateLimiters();
|
|
$this->configureMailFailureLogging();
|
|
|
|
ArtworkAward::observe(ArtworkAwardObserver::class);
|
|
Artwork::observe(ArtworkObserver::class);
|
|
ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
|
|
ArtworkComment::observe(ArtworkCommentObserver::class);
|
|
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
|
|
|
// ── OAuth / SocialiteProviders ──────────────────────────────────────
|
|
Event::listen(
|
|
\SocialiteProviders\Manager\SocialiteWasCalled::class,
|
|
\SocialiteProviders\Discord\DiscordExtendSocialite::class,
|
|
);
|
|
// Apple provider removed — no listener registered
|
|
|
|
// ── Posts / Feed System Events ──────────────────────────────────────
|
|
Event::listen(
|
|
\App\Events\Posts\ArtworkShared::class,
|
|
\App\Listeners\Posts\SendArtworkSharedNotification::class,
|
|
);
|
|
Event::listen(
|
|
\App\Events\Posts\PostCommented::class,
|
|
\App\Listeners\Posts\SendPostCommentedNotification::class,
|
|
);
|
|
Event::listen(
|
|
\App\Events\Posts\PostCommented::class,
|
|
\App\Listeners\Posts\AwardXpForPostCommented::class,
|
|
);
|
|
Event::listen(
|
|
\App\Events\Achievements\AchievementCheckRequested::class,
|
|
\App\Listeners\Achievements\CheckUserAchievements::class,
|
|
);
|
|
Event::listen(
|
|
\App\Events\Achievements\UserXpUpdated::class,
|
|
\App\Listeners\Achievements\CheckUserAchievements::class,
|
|
);
|
|
|
|
// 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;
|
|
|
|
if (Auth::check()) {
|
|
$userId = Auth::id();
|
|
try {
|
|
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
|
|
} catch (\Throwable $e) {
|
|
$uploadCount = 0;
|
|
}
|
|
|
|
try {
|
|
$favCount = DB::table('artwork_favourites')->where('user_id', $userId)->count();
|
|
} catch (\Throwable $e) {
|
|
$favCount = 0;
|
|
}
|
|
|
|
try {
|
|
$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();
|
|
} catch (\Throwable $e) {
|
|
$msgCount = 0;
|
|
}
|
|
|
|
try {
|
|
$noticeCount = DB::table('notifications')->where('user_id', $userId)->whereNull('read_at')->count();
|
|
} catch (\Throwable $e) {
|
|
$noticeCount = 0;
|
|
}
|
|
|
|
try {
|
|
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
|
|
->unreadCountForUser(Auth::user());
|
|
} catch (\Throwable $e) {
|
|
$receivedCommentsCount = 0;
|
|
}
|
|
|
|
try {
|
|
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
|
|
$avatarHash = $profile->avatar_hash ?? null;
|
|
} catch (\Throwable $e) {
|
|
$avatarHash = null;
|
|
}
|
|
|
|
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
|
|
}
|
|
|
|
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName'));
|
|
});
|
|
|
|
// 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->replace(
|
|
\Illuminate\Http\Middleware\HandleCors::class,
|
|
\App\Http\Middleware\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);
|
|
}
|
|
}
|
|
|
|
private function configureAuthRateLimiters(): void
|
|
{
|
|
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());
|
|
});
|
|
|
|
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());
|
|
});
|
|
|
|
RateLimiter::for('register', function (Request $request): array {
|
|
$emailKey = strtolower((string) $request->input('email', 'unknown'));
|
|
$ipLimit = (int) config('registration.ip_per_minute_limit', 3);
|
|
$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),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureMailFailureLogging(): void
|
|
{
|
|
Event::listen(JobFailed::class, function (JobFailed $event): void {
|
|
if (! str_contains(strtolower($event->job->resolveName()), 'sendqueuedmailable')) {
|
|
return;
|
|
}
|
|
|
|
Log::warning('mail delivery failed', [
|
|
'transport' => config('mail.default'),
|
|
'job_name' => $event->job->resolveName(),
|
|
'queue' => $event->job->getQueue(),
|
|
'connection' => $event->connectionName,
|
|
'exception' => $event->exception->getMessage(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
private function configureUploadRateLimiters(): void
|
|
{
|
|
RateLimiter::for('uploads-init', function (Request $request): array {
|
|
return $this->buildUploadLimits($request, 'init');
|
|
});
|
|
|
|
RateLimiter::for('uploads-finish', function (Request $request): array {
|
|
return $this->buildUploadLimits($request, 'finish');
|
|
});
|
|
|
|
RateLimiter::for('uploads-status', function (Request $request): array {
|
|
return $this->buildUploadLimits($request, 'status');
|
|
});
|
|
}
|
|
|
|
private function buildUploadLimits(Request $request, string $key): array
|
|
{
|
|
$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);
|
|
|
|
$limits = [];
|
|
|
|
if ($perUser > 0) {
|
|
$userId = $request->user()?->id ?? 'guest';
|
|
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId);
|
|
}
|
|
|
|
if ($perIp > 0) {
|
|
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip());
|
|
}
|
|
|
|
return $limits;
|
|
}
|
|
|
|
private function configureMessagingRateLimiters(): void
|
|
{
|
|
RateLimiter::for('messages-send', function (Request $request): array {
|
|
$userId = $request->user()?->id ?? 'guest';
|
|
|
|
return [
|
|
Limit::perMinute(20)->by('messages:user:' . $userId),
|
|
Limit::perMinute(40)->by('messages:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
|
|
RateLimiter::for('messages-react', function (Request $request): array {
|
|
$userId = $request->user()?->id ?? 'guest';
|
|
|
|
return [
|
|
Limit::perMinute(60)->by('messages:react:user:' . $userId),
|
|
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureDownloadRateLimiter(): void
|
|
{
|
|
RateLimiter::for('downloads', function (Request $request): array {
|
|
$userId = $request->user()?->id;
|
|
|
|
// 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()),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureArtworkRateLimiters(): void
|
|
{
|
|
RateLimiter::for('artwork-awards', function (Request $request): array {
|
|
$userId = $request->user()?->id;
|
|
$artworkId = (int) $request->route('id');
|
|
|
|
return [
|
|
// Prevent burst spam on a single artwork while allowing normal exploration.
|
|
Limit::perMinute(20)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId),
|
|
// Global safety net for user/IP across all artworks.
|
|
Limit::perMinute(120)->by('awards:user:' . ($userId ?? 'guest')),
|
|
Limit::perMinute(180)->by('awards:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureReactionRateLimiters(): void
|
|
{
|
|
RateLimiter::for('reactions-read', function (Request $request): array {
|
|
$userId = $request->user()?->id;
|
|
|
|
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()),
|
|
];
|
|
});
|
|
|
|
RateLimiter::for('reactions-write', function (Request $request): array {
|
|
$userId = $request->user()?->id;
|
|
|
|
return [
|
|
Limit::perMinute(120)->by('reactions-write:user:' . ($userId ?? 'guest')),
|
|
Limit::perMinute(180)->by('reactions-write:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureSocialRateLimiters(): void
|
|
{
|
|
RateLimiter::for('social-write', function (Request $request): array {
|
|
$userId = $request->user()?->id ?? 'guest';
|
|
|
|
return [
|
|
Limit::perMinute(60)->by('social-write:user:' . $userId),
|
|
Limit::perMinute(120)->by('social-write:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
|
|
RateLimiter::for('social-read', function (Request $request): array {
|
|
$userId = $request->user()?->id ?? 'guest';
|
|
|
|
return [
|
|
Limit::perMinute(240)->by('social-read:user:' . $userId),
|
|
Limit::perMinute(480)->by('social-read:ip:' . $request->ip()),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function configureSettingsRateLimiters(): void
|
|
{
|
|
RateLimiter::for('username-check', function (Request $request): Limit {
|
|
$key = 'username-check:ip:' . $request->ip();
|
|
|
|
if (method_exists(Limit::class, 'perSecond')) {
|
|
return Limit::perSecond(5)->by($key);
|
|
}
|
|
|
|
return Limit::perMinute(300)->by($key);
|
|
});
|
|
|
|
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();
|
|
|
|
return Limit::perHour(1)->by($key);
|
|
});
|
|
}
|
|
|
|
private function registerCpadMenuItems(): void
|
|
{
|
|
if (! class_exists(Menu::class)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
/** @var Menu $menu */
|
|
$menu = $this->app->make(Menu::class);
|
|
$menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
|
|
$menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
|
|
} catch (\Throwable) {
|
|
// Control panel menu registration should never block the app boot.
|
|
}
|
|
}
|
|
}
|