Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.6 KiB
PHP
173 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\Auth\PireonTokenGuard;
|
|
use App\Models\Application;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ReviewComment;
|
|
use App\Models\Revision;
|
|
use App\Models\User;
|
|
use App\Services\Git\GitManager;
|
|
use App\Services\Pireon\PireonClient;
|
|
use App\Services\Pireon\TokenStore;
|
|
use App\Support\Audit\Auditor;
|
|
use App\Support\Webhooks\Webhooks;
|
|
use Illuminate\Auth\Events\Login;
|
|
use Illuminate\Auth\Events\Logout;
|
|
use Illuminate\Cache\RateLimiting\Limit;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\ServiceProvider;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Permission\Events\RoleAttached;
|
|
use Spatie\Permission\Events\RoleDetached;
|
|
use Spatie\Permission\Models\Role;
|
|
|
|
class AppServiceProvider extends ServiceProvider
|
|
{
|
|
public function register(): void
|
|
{
|
|
$this->app->singleton(PireonClient::class, fn () => PireonClient::fromConfig());
|
|
|
|
$this->app->scoped(TokenStore::class, fn ($app) => new TokenStore(
|
|
$app['session.store'],
|
|
$app->make(PireonClient::class),
|
|
));
|
|
|
|
// Resolves git hosting drivers; see config/git.php.
|
|
$this->app->singleton(GitManager::class);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
protected static function roleNames(mixed $rolesOrIds): array
|
|
{
|
|
return Role::query()
|
|
->whereIn('id', collect(is_iterable($rolesOrIds) ? $rolesOrIds : [$rolesOrIds])
|
|
->map(fn ($role) => $role instanceof Role ? $role->id : $role)
|
|
->all())
|
|
->pluck('name')
|
|
->all();
|
|
}
|
|
|
|
public function boot(): void
|
|
{
|
|
// Outgoing webhooks, fed by the records themselves so the panel, the
|
|
// API and a batch all announce the same things the same way.
|
|
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
|
|
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
|
|
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
|
|
AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry));
|
|
|
|
// The audit log: every change a person makes to an audited record,
|
|
// role changes, and signing in to the panel.
|
|
foreach (['created', 'updated', 'deleted', 'restored'] as $event) {
|
|
Event::listen("eloquent.{$event}: *", function (string $name, array $payload) use ($event): void {
|
|
if (($payload[0] ?? null) instanceof Model) {
|
|
Auditor::modelEvent($event, $payload[0]);
|
|
}
|
|
});
|
|
}
|
|
|
|
Event::listen(RoleAttached::class, fn (RoleAttached $e) => Auditor::record('users.roles_added', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
|
Event::listen(RoleDetached::class, fn (RoleDetached $e) => Auditor::record('users.roles_removed', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
|
Event::listen(Login::class, function (Login $e): void {
|
|
if ($e->user instanceof User) {
|
|
Auditor::record('auth.login', $e->user, null, $e->user);
|
|
}
|
|
});
|
|
|
|
// Backs the "api" guard declared in config/auth.php.
|
|
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
|
->make(PireonTokenGuard::class)($request));
|
|
|
|
// Drop the OAuth token set whenever a session ends, no matter which
|
|
// route did it — ours, or Filament's own logout button.
|
|
Event::listen(Logout::class, function (): void {
|
|
if ($this->app->bound('session') && $this->app['session']->isStarted()) {
|
|
$this->app->make(TokenStore::class)->forget();
|
|
}
|
|
});
|
|
|
|
// Backs `throttle:api` on the REST routes. Authenticated callers are
|
|
// budgeted per user so one noisy client cannot exhaust the allowance
|
|
// of everyone sharing an IP.
|
|
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
|
->by($request->user()?->id ?: $request->ip()));
|
|
|
|
// Backs POST /api/v1/auth/token. Budgeted per account and address
|
|
// rather than per address alone, because the deployed MCP server
|
|
// (mcp/) signs everyone in from one container: per IP, one person's
|
|
// wrong password — or a stranger's guesses — would lock every other
|
|
// user out of it. The per-address ceiling still bounds how many
|
|
// accounts a single client can try.
|
|
RateLimiter::for('auth-token', fn (Request $request) => [
|
|
Limit::perMinute(6)->by('auth-token:'.sha1(
|
|
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
|
|
.'|'.$request->ip()
|
|
)),
|
|
|
|
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
|
|
]);
|
|
|
|
// The public API is a different problem. The public frontend renders
|
|
// server-side, so every visitor's page view arrives from the same IP:
|
|
// a per-IP budget sized for one person would throttle the entire
|
|
// website as though it were a single abusive client. Hence a generous
|
|
// limit, and an explicit bypass for the frontend's own address.
|
|
RateLimiter::for('public-api', function (Request $request) {
|
|
if (in_array($request->ip(), (array) config('site.trusted_ips'), true)) {
|
|
return Limit::none();
|
|
}
|
|
|
|
return Limit::perMinute((int) config('site.rate_limit', 600))->by($request->ip());
|
|
});
|
|
|
|
// Submissions are the opposite problem again. Reads are throttled per
|
|
// IP; writes cannot be, because SSR means every submission on the site
|
|
// arrives from the frontend's single address — which is in
|
|
// `trusted_ips` and would bypass the limit anyway.
|
|
//
|
|
// So the budget is spent per e-mail address, with a site-wide hourly
|
|
// ceiling behind it as the actual protection against a flood.
|
|
RateLimiter::for('public-write', fn (Request $request) => [
|
|
Limit::perMinute((int) config('site.submissions.rate_limit', 3))
|
|
->by('submission:'.sha1(Str::lower((string) $request->input('email')))),
|
|
|
|
Limit::perHour((int) config('site.submissions.hourly_limit', 60))
|
|
->by('submissions'),
|
|
]);
|
|
|
|
// Telemetry is a third case again. It is one server reporting to
|
|
// another, authenticated by a shared secret, so the address it comes
|
|
// from is stable and a per-IP budget means what it says — unlike the
|
|
// submissions, where the same address stands for every visitor.
|
|
//
|
|
// Trusted IPs deliberately do *not* bypass this one: the frontend is
|
|
// the only caller there will ever be, so an exemption would leave the
|
|
// endpoint with no ceiling at all.
|
|
RateLimiter::for('public-telemetry', fn (Request $request) => Limit::perMinute(
|
|
(int) config('site.telemetry.rate_limit', 60)
|
|
)->by($request->ip()));
|
|
|
|
// Fail loudly in development when a relation is used without being
|
|
// loaded, or a non-fillable attribute is assigned.
|
|
Model::shouldBeStrict($this->app->isLocal());
|
|
Model::unguard(false);
|
|
|
|
// The OAuth redirect URI is registered as https on the identity
|
|
// server, so generated URLs must match it in deployed environments.
|
|
if ($this->app->environment('production') || str_starts_with((string) config('app.url'), 'https://')) {
|
|
URL::forceScheme('https');
|
|
}
|
|
}
|
|
}
|