Files
landing-api/routes/api.php
Josep Piguillem f93775e549 Issue machine tokens with limited permissions and an expiry
A token acts as the person who made it, within the permissions it was given
and those they still hold, checked on every request. Permission checks now go
through one gate rule, since spatie's answered first and never asked the
token.

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

308 lines
15 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\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\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');
// 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']);
});
});