/api/v1/technologies is a full CRUD with the panel's own rules: name and slug are plain strings, the prose is translatable, `radar_ring: null` takes one off the radar, and TechnologyPolicy still refuses to delete one a project, member or call uses. The MCP server picks the tools up from openapi.yaml on its own. A logo is an SVG, so POST /v1/media now accepts one — sanitized with enshrined/svg-sanitize (scripts, event handlers and remote references removed), and served by Caddy under a Content-Security-Policy that would stop a script regardless. `for` (pages or technologies) says what an upload is for, which picks the directory the panel uses for it and the permission it takes. Creating a technology without a category answered 500: the column's default never reached the model, so the response read a null enum. The controller now states the default, as the panel does. Beside a file, the MCP upload tool no longer demands the JSON body's `url`: the body then carries the upload's other fields, such as `for`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
8.3 KiB
PHP
183 lines
8.3 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\MeController;
|
|
use App\Http\Controllers\Api\V1\MediaController;
|
|
use App\Http\Controllers\Api\V1\PageController;
|
|
use App\Http\Controllers\Api\V1\RoleController;
|
|
use App\Http\Controllers\Api\V1\TechnologyController;
|
|
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);
|
|
|
|
// 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']);
|
|
});
|
|
});
|