Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
108
app/Providers/AppServiceProvider.php
Normal file
108
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Auth\PireonTokenGuard;
|
||||
use App\Services\Git\GitManager;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// 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()));
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/Providers/AuthServiceProvider.php
Normal file
74
app/Providers/AuthServiceProvider.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\Category;
|
||||
use App\Models\Client;
|
||||
use App\Models\Media;
|
||||
use App\Models\Member;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Repository;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Technology;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\User;
|
||||
use App\Policies\ApplicationPolicy;
|
||||
use App\Policies\CallPolicy;
|
||||
use App\Policies\CategoryPolicy;
|
||||
use App\Policies\ClientPolicy;
|
||||
use App\Policies\MediaPolicy;
|
||||
use App\Policies\MemberPolicy;
|
||||
use App\Policies\MissingUrlPolicy;
|
||||
use App\Policies\PagePolicy;
|
||||
use App\Policies\ProjectPolicy;
|
||||
use App\Policies\RedirectPolicy;
|
||||
use App\Policies\RepositoryPolicy;
|
||||
use App\Policies\RolePolicy;
|
||||
use App\Policies\ServicePolicy;
|
||||
use App\Policies\TalkPolicy;
|
||||
use App\Policies\TechnologyPolicy;
|
||||
use App\Policies\TestimonialPolicy;
|
||||
use App\Policies\UserPolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(Page::class, PagePolicy::class);
|
||||
Gate::policy(Category::class, CategoryPolicy::class);
|
||||
Gate::policy(Project::class, ProjectPolicy::class);
|
||||
Gate::policy(Member::class, MemberPolicy::class);
|
||||
Gate::policy(Technology::class, TechnologyPolicy::class);
|
||||
Gate::policy(Call::class, CallPolicy::class);
|
||||
Gate::policy(Application::class, ApplicationPolicy::class);
|
||||
Gate::policy(Service::class, ServicePolicy::class);
|
||||
Gate::policy(Client::class, ClientPolicy::class);
|
||||
Gate::policy(Testimonial::class, TestimonialPolicy::class);
|
||||
Gate::policy(Talk::class, TalkPolicy::class);
|
||||
Gate::policy(Media::class, MediaPolicy::class);
|
||||
Gate::policy(Redirect::class, RedirectPolicy::class);
|
||||
Gate::policy(MissingUrl::class, MissingUrlPolicy::class);
|
||||
Gate::policy(Repository::class, RepositoryPolicy::class);
|
||||
Gate::policy(User::class, UserPolicy::class);
|
||||
Gate::policy(Role::class, RolePolicy::class);
|
||||
|
||||
// Super admins bypass every permission check. Granting the role this
|
||||
// way rather than by attaching every permission means the role can
|
||||
// never fall behind as the catalogue grows.
|
||||
//
|
||||
// Returning null (not false) when the role is absent is important: it
|
||||
// lets the normal policy chain run instead of short-circuiting it.
|
||||
Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null);
|
||||
}
|
||||
}
|
||||
127
app/Providers/Filament/AdminPanelProvider.php
Normal file
127
app/Providers/Filament/AdminPanelProvider.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Auth\PireonLogin;
|
||||
use App\Filament\Widgets\ContentOverview;
|
||||
use App\Filament\Widgets\WhatNeedsYou;
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->brandName(config('app.name'))
|
||||
|
||||
// No password form: this page only offers a button that starts the
|
||||
// OAuth flow at the identity server.
|
||||
->login(PireonLogin::class)
|
||||
|
||||
->colors([
|
||||
'primary' => Color::Indigo,
|
||||
])
|
||||
->font('Inter')
|
||||
->sidebarCollapsibleOnDesktop()
|
||||
|
||||
// The bell. Submissions from the public site arrive here, because
|
||||
// an inbox nobody is told about is an inbox nobody reads.
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('60s')
|
||||
|
||||
->navigationGroups([
|
||||
__('panel.groups.collective'),
|
||||
__('panel.groups.content'),
|
||||
__('panel.groups.administration'),
|
||||
])
|
||||
|
||||
// The API documentation lives outside the panel — it is a plain
|
||||
// page, readable without an account — but this is where somebody
|
||||
// goes looking for it. A new tab, so opening the reference does
|
||||
// not throw away whatever was half-edited in the panel.
|
||||
->navigationItems([
|
||||
// The label is a closure, not a resolved string: the panel is
|
||||
// configured once per process, so a translation evaluated here
|
||||
// would freeze in whatever language happened to be active then.
|
||||
NavigationItem::make(fn (): string => __('docs.title'))
|
||||
->url(fn (): string => route('docs.api'), shouldOpenInNewTab: true)
|
||||
->icon(Heroicon::OutlinedCodeBracketSquare)
|
||||
->group(__('panel.groups.administration'))
|
||||
->sort(95),
|
||||
])
|
||||
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
// First on the dashboard: what is waiting for somebody, rather
|
||||
// than counters that never change.
|
||||
WhatNeedsYou::class,
|
||||
ContentOverview::class,
|
||||
])
|
||||
|
||||
// Language switcher, rendered next to the user menu.
|
||||
// First render hook to fire on every page, panel and login alike:
|
||||
// the one place that has to be true regardless of which screen
|
||||
// somebody lands on first.
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn (): string => view('filament.partials.environment-banner')->render(),
|
||||
)
|
||||
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_BEFORE,
|
||||
fn (): string => view('filament.partials.language-switcher')->render(),
|
||||
)
|
||||
|
||||
// And once more under the sign-in form, which is the one screen
|
||||
// with no user whose preference we could read.
|
||||
->renderHook(
|
||||
PanelsRenderHook::AUTH_LOGIN_FORM_AFTER,
|
||||
fn (): string => view('filament.partials.login-footer')->render(),
|
||||
)
|
||||
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
PreventRequestForgery::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
// Filament panels do not run the "web" middleware group, so
|
||||
// locale negotiation has to be listed here explicitly.
|
||||
SetLocale::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user