Files
landing-api/routes/api.php
Josep Piguillem 1fe1e67773 Keep an audit log of who did what, and how they came in
Written from model events, spatie's role events and panel sign-ins, only for
what a person did, with secrets masked; pruned after a year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:08:01 +02:00

319 lines
16 KiB
PHP

<?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\SearchController;
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\ApiTokenController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
use App\Http\Controllers\Api\V1\AuditController;
use App\Http\Controllers\Api\V1\BatchController;
use App\Http\Controllers\Api\V1\CallController;
use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\MaintenanceController;
use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MissingUrlController;
use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\PreviewController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RedirectController;
use App\Http\Controllers\Api\V1\RepositoryController;
use App\Http\Controllers\Api\V1\ReviewController;
use App\Http\Controllers\Api\V1\RevisionController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController;
use App\Http\Controllers\Api\V1\SettingController;
use App\Http\Controllers\Api\V1\TalkController;
use App\Http\Controllers\Api\V1\TechnologyController;
use App\Http\Controllers\Api\V1\TestimonialController;
use App\Http\Controllers\Api\V1\TokenController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\WebhookController;
use App\Support\AdminContent;
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('search', SearchController::class)->name('search');
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:auth-token')
->name('auth.token');
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::get('me', MeController::class)->name('me');
// Machine tokens, managed by the person they act for.
Route::get('tokens', [ApiTokenController::class, 'index'])->name('tokens.index');
Route::post('tokens', [ApiTokenController::class, 'store'])->name('tokens.store');
Route::delete('tokens/{token}', [ApiTokenController::class, 'destroy'])->name('tokens.destroy');
Route::apiResource('pages', PageController::class);
Route::post('pages/{page}/restore', [PageController::class, 'restore'])
->withTrashed()
->name('pages.restore');
Route::apiResource('categories', CategoryController::class);
Route::apiResource('technologies', TechnologyController::class);
// The collective's work, people and offer, and who it was for. Soft-deleted, so each can
// be brought back as a page can.
foreach ([
'projects' => ProjectController::class,
'members' => MemberController::class,
'services' => ServiceController::class,
'clients' => ClientController::class,
'testimonials' => TestimonialController::class,
] as $resource => $controller) {
$parameter = str($resource)->singular()->toString();
Route::apiResource($resource, $controller);
Route::post("{$resource}/{{$parameter}}/restore", [$controller, 'restore'])
->withTrashed()
->name("{$resource}.restore");
}
// Deleted for good, like the panel does: neither has a bin.
Route::apiResource('talks', TalkController::class);
Route::apiResource('calls', CallController::class);
// The inbox. Submissions arrive from the public site, never from here.
Route::apiResource('applications', AdminApplicationController::class)
->only(['index', 'show', 'update', 'destroy']);
Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
->withTrashed()
->name('applications.restore');
// Old addresses, and the missing ones the frontend reported.
Route::apiResource('redirects', RedirectController::class);
Route::apiResource('missing-urls', MissingUrlController::class)->only(['index', 'show', 'update', 'destroy']);
Route::post('missing-urls/{missing_url}/redirect', [MissingUrlController::class, 'redirect'])
->name('missing-urls.redirect');
Route::apiResource('repositories', RepositoryController::class);
Route::post('repositories/{repository}/sync', [RepositoryController::class, 'sync'])
->name('repositories.sync');
// Declared before the settings routes so "maintenance" is never read
// as a setting's key.
Route::get('maintenance', [MaintenanceController::class, 'show'])->name('maintenance.show');
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
// History, review and preview, for the kinds of content that have
// them. Each route says which kind through `content`, so one
// controller serves them all and the specification still names each.
foreach (AdminContent::WITH_HISTORY as $resource => [, $parameter]) {
Route::get("{$resource}/{{$parameter}}/revisions", [RevisionController::class, 'index'])
->defaults('content', $resource)
->name("{$resource}.revisions");
}
Route::post('revisions/{revision}/restore', [RevisionController::class, 'restore'])->name('revisions.restore');
Route::get('reviews', [ReviewController::class, 'queue'])->name('reviews.queue');
// Several calls in one. Each operation is still its own request, with
// its own validation and permission check.
Route::post('batch', [BatchController::class, 'batch'])->name('batch');
Route::post('import/{resource}', [BatchController::class, 'import'])
->whereIn('resource', array_keys(AdminContent::WITH_HISTORY))
->name('import');
// Telling other systems what happened here.
Route::apiResource('webhooks', WebhookController::class);
Route::get('webhooks/{webhook}/deliveries', [WebhookController::class, 'deliveries'])->name('webhooks.deliveries');
Route::post('webhooks/{webhook}/ping', [WebhookController::class, 'ping'])->name('webhooks.ping');
Route::post('webhook-deliveries/{delivery}/redeliver', [WebhookController::class, 'redeliver'])->name('webhook-deliveries.redeliver');
// Who did what, from where.
Route::get('audit', [AuditController::class, 'index'])->name('audit');
// The panel's health screen, and what is left to translate.
Route::get('health', [HealthController::class, 'index'])->name('health');
Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending');
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
$parameter = AdminContent::parameter($resource);
foreach ([
['get', 'reviews', 'thread'],
['post', 'comments', 'comment'],
['post', 'review', 'request'],
['post', 'return', 'return'],
['post', 'publish', 'publish'],
['post', 'unpublish', 'unpublish'],
] as [$method, $suffix, $action]) {
Route::{$method}("{$resource}/{{$parameter}}/{$suffix}", [ReviewController::class, $action])
->defaults('content', $resource)
->name("{$resource}.review.{$action}");
}
}
foreach (array_keys(AdminContent::PREVIEWABLE) as $resource) {
$parameter = AdminContent::parameter($resource);
Route::post("{$resource}/{{$parameter}}/preview", [PreviewController::class, 'store'])
->defaults('content', $resource)
->name("{$resource}.preview");
}
// The library. A file is uploaded here and then referenced from a
// record by its path; it is never edited, only processed again.
Route::get('media', [MediaController::class, 'index'])->name('media.index');
Route::post('media', [MediaController::class, 'store'])->name('media.store');
Route::get('media/{media}', [MediaController::class, 'show'])->name('media.show');
Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy');
Route::post('media/{media}/reprocess', [MediaController::class, 'reprocess'])->name('media.reprocess');
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']);
});
});