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:
175
routes/api.php
Normal file
175
routes/api.php
Normal 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
46
routes/console.php
Normal 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
61
routes/web.php
Normal 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');
|
||||
Reference in New Issue
Block a user