Files
landing-api/routes/api.php
Josep Piguillem d10ce1ca57
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Take Pireon's webhooks: a deactivation or a revocation counts at once
Until now, an account switched off or a token revoked at the identity
server kept working here: bearer tokens until the introspection cache
ran out, panel sessions until they ended, and machine tokens for good,
as they only look at the local account.

POST /api/v1/webhooks/pireon takes user.deactivated (the account goes
inactive, which stops its machine tokens and the panel; its sessions
end and its cached tokens are forgotten) and token.revoked (sessions
and cached tokens go). Pireon signs each delivery with the secret of
the client that registered the webhook, so either PIREON_WEBHOOK_SECRET
(password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con
Pireon») verifies it; with neither, the endpoint refuses everything.

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

337 lines
17 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\ApplicationReplyController;
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\DuplicateController;
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\PireonWebhookController;
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\TagController;
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('tags', [PublicPageController::class, 'tags'])->name('tags');
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');
// Called by the identity server, signed with a client's webhook secret.
Route::post('webhooks/pireon', PireonWebhookController::class)
->middleware('throttle:60,1')
->name('webhooks.pireon');
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('tags', TagController::class)->except('show');
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::get('applications/{application}/replies', [ApplicationReplyController::class, 'index'])->name('applications.replies.index');
Route::post('applications/{application}/reply', [ApplicationReplyController::class, 'store'])->name('applications.replies.store');
Route::get('reply-templates', [ApplicationReplyController::class, 'templates'])->name('reply-templates.index');
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");
Route::post("{$resource}/{{$parameter}}/duplicate", [DuplicateController::class, 'store'])
->defaults('content', $resource)
->name("{$resource}.duplicate");
}
// 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']);
});
});