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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

175
routes/api.php Normal file
View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\PublicApi\V1\ApplicationController;
use App\Http\Controllers\Api\PublicApi\V1\CallController as PublicCallController;
use App\Http\Controllers\Api\PublicApi\V1\MemberController as PublicMemberController;
use App\Http\Controllers\Api\PublicApi\V1\PageController as PublicPageController;
use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectController;
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\TokenController;
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| REST API v1
|--------------------------------------------------------------------------
|
| Every route is authenticated by the "api" guard, which validates a bearer
| token issued by the Pireon identity server (App\Auth\PireonTokenGuard).
| Authorisation on top of that is local: policies resolve the caller's spatie
| permissions, exactly as the panel does.
|
| Obtain a token with the client credentials of your registered OAuth client:
|
| curl -X POST https://oauth.pireon.dev/oauth/token \
| -d grant_type=password -d client_id=... -d client_secret=... \
| -d username=you@example.com -d password=...
|
*/
/*
|--------------------------------------------------------------------------
| Public API — consumed by the frontend
|--------------------------------------------------------------------------
|
| Unauthenticated on purpose: everything here is already published on the
| public website, so a token would add ceremony without adding protection.
| Unpublished content is gated by the preview secret instead (AllowPreview).
|
| Records are addressed by slug within the active locale, because that is the
| access pattern an SSR frontend has: it receives a URL, not an id. Each
| response carries `alternates`, the same record's path in every other
| language — only this side knows that "/proyectos/mi-web" and
| "/ca/projectes/el-meu-web" are one thing, and without it the frontend
| cannot emit correct hreflang tags.
|
*/
Route::prefix('public/v1')
->name('api.public.')
->middleware(['throttle:public-api', 'preview', 'site.available', 'cache.public'])
->group(function () {
Route::get('site', [SiteController::class, 'index'])->name('site');
Route::get('slugs', [SiteController::class, 'slugs'])->name('slugs');
Route::get('radar', [SiteController::class, 'radar'])->name('radar');
Route::get('technologies', [SiteController::class, 'technologies'])->name('technologies');
Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues');
Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects');
Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index');
Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show');
Route::get('projects', [PublicProjectController::class, 'index'])->name('projects.index');
Route::get('projects/{slug}', [PublicProjectController::class, 'show'])->name('projects.show');
Route::get('members', [PublicMemberController::class, 'index'])->name('members.index');
Route::get('members/{slug}', [PublicMemberController::class, 'show'])->name('members.show');
Route::get('calls', [PublicCallController::class, 'index'])->name('calls.index');
Route::get('calls/{slug}', [PublicCallController::class, 'show'])->name('calls.show');
// The pieces a landing page is made of. Four collections rather than a
// "home page" payload: what gets composed, and in what order, is the
// frontend's business.
Route::get('services', [ShowcaseController::class, 'services'])->name('services.index');
Route::get('services/{slug}', [ShowcaseController::class, 'service'])->name('services.show');
Route::get('clients', [ShowcaseController::class, 'clients'])->name('clients');
Route::get('testimonials', [ShowcaseController::class, 'testimonials'])->name('testimonials');
Route::get('talks', [ShowcaseController::class, 'talks'])->name('talks');
});
/*
|--------------------------------------------------------------------------
| Public API — the write half
|--------------------------------------------------------------------------
|
| The only endpoints on the public API that are not read-only, and therefore
| the only ones a stranger can use to put a row in the database. They carry
| their own throttle, because the read budget is sized for an SSR frontend
| making hundreds of requests a minute and would be meaningless here.
|
| `cache.public` is absent on purpose: a confirmation is nobody else's, and a
| shared cache holding one would hand it to the next visitor.
|
*/
Route::prefix('public/v1')
->name('api.public.')
->middleware(['throttle:public-write', 'site.available'])
->group(function () {
Route::post('calls/{slug}/apply', [ApplicationController::class, 'apply'])
->name('calls.apply');
Route::post('contact', [ApplicationController::class, 'contact'])
->name('contact');
});
/*
|--------------------------------------------------------------------------
| Public API — the frontend reporting back
|--------------------------------------------------------------------------
|
| Also a write endpoint, but not the same kind: the author is the frontend
| itself rather than a visitor, so it can hold a shared secret and does. It
| carries its own throttle for the same reason the submissions do — the read
| budget is sized for an SSR frontend and would mean nothing here.
|
| Outside `site.available` on purpose. Maintenance mode is about what visitors
| are shown; a 404 that happens while the site is down is still a 404 worth
| knowing about, and dropping it would lose exactly the reports produced by
| whatever change the maintenance was for.
|
*/
Route::prefix('public/v1')
->name('api.public.')
->middleware(['throttle:public-telemetry', 'ingest'])
->group(function () {
Route::post('telemetry/not-found', [TelemetryController::class, 'notFound'])
->name('telemetry.not-found');
Route::post('telemetry/views', [TelemetryController::class, 'views'])
->name('telemetry.views');
});
/*
|--------------------------------------------------------------------------
| Admin API — authenticated
|--------------------------------------------------------------------------
*/
Route::prefix('v1')->name('api.v1.')->group(function () {
// Convenience endpoint for first-party clients: exchanges credentials for
// a token upstream so they do not need to know the identity server's URL.
Route::post('auth/token', [TokenController::class, 'store'])
->middleware('throttle:6,1')
->name('auth.token');
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::get('me', MeController::class)->name('me');
Route::apiResource('pages', PageController::class);
Route::post('pages/{page}/restore', [PageController::class, 'restore'])
->withTrashed()
->name('pages.restore');
Route::apiResource('categories', CategoryController::class);
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
// Declared before the resource route so "catalogue" is not swallowed
// by the {role} parameter.
Route::get('roles/catalogue', [RoleController::class, 'catalogue'])->name('roles.catalogue');
Route::apiResource('roles', RoleController::class)->only(['index', 'show']);
});
});

46
routes/console.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Schedule;
/*
|--------------------------------------------------------------------------
| Scheduled tasks
|--------------------------------------------------------------------------
|
| Keeping repository stats fresh is what stops the public site from looking
| abandoned, which is the main risk for a site like this. `withoutOverlapping`
| guards against a slow run colliding with the next tick.
|
*/
/*
* Publication windows are enforced by the `published` scopes, so content
* appears and disappears on time whether or not this runs. What it does is
* keep the stored state honest — a call the site stopped offering weeks ago
* should not still say "Open" in the panel.
*/
Schedule::command('content:schedule')
->everyFiveMinutes()
->withoutOverlapping()
->runInBackground();
/*
* Safety net for image processing. Uploads queue their own work, but a worker
* that is down would otherwise leave images without dimensions indefinitely —
* and the symptom, a page that reflows as it loads, is easy to miss.
*/
Schedule::command('media:process')
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
Schedule::command('repos:sync')
->cron(match ($cron = (string) config('git.sync_cron', 'hourly')) {
'hourly' => '0 * * * *',
'daily' => '0 4 * * *',
default => $cron,
})
->withoutOverlapping()
->runInBackground();

61
routes/web.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Auth\PireonAuthController;
use App\Http\Controllers\DocsController;
use App\Http\Controllers\LocaleController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/admin');
/*
|--------------------------------------------------------------------------
| Authentication — Pireon OAuth2 (Authorization Code + PKCE)
|--------------------------------------------------------------------------
|
| `login` is the page Filament bounces guests to; it renders a single button
| that starts the flow at `auth.redirect`.
|
*/
// Laravel's own "unauthenticated" redirect targets a route named `login`;
// point it at the panel's login page so both agree on one screen.
Route::redirect('/login', '/admin/login')->name('login');
Route::middleware('guest')->group(function () {
Route::get('/auth/redirect', [PireonAuthController::class, 'redirect'])
->name('auth.redirect');
Route::get('/auth/callback', [PireonAuthController::class, 'callback'])
->name('auth.callback');
});
Route::post('/logout', [PireonAuthController::class, 'logout'])
->middleware('auth')
->name('logout');
/*
|--------------------------------------------------------------------------
| Locale switching
|--------------------------------------------------------------------------
*/
Route::get('/locale/{locale}', LocaleController::class)->name('locale.switch');
/*
|--------------------------------------------------------------------------
| API documentation
|--------------------------------------------------------------------------
|
| Browsable docs for both APIs, rendered from public/openapi.yaml. Readable
| without signing in on purpose: the people who need it most are writing the
| public frontend and have no reason to hold a panel account, and the
| specification describes endpoints rather than secrets.
|
| To keep it to the collective instead, wrap this route in the `auth`
| middleware and move openapi.yaml out of public/.
|
*/
Route::get('/docs/api', DocsController::class)->name('docs.api');