Files
landing-api/routes/api.php
2026-09-30 19:38:58 +02:00

209 lines
9.5 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\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\ClientController;
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\PageController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController;
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 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:auth-token')
->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('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: a talk has no bin.
Route::apiResource('talks', TalkController::class);
// Upload only: the file is then referenced from a record by its path.
Route::post('media', [MediaController::class, 'store'])->name('media.store');
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']);
});
});