POST /api/v1/media stores an image where the panel keeps a page's and answers with the path to send as `featured_image`. Without it the MCP server could create a page but never give it a picture. The file goes through Media::for(), so it is processed, served and pruned like one uploaded in the panel. Two ways in, one per request: a multipart `file`, or JSON with a `url` the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided from the bytes; no SVG, which is a document that can carry script. The URL is fetched from inside the stack's network, so RemoteImage guards it: HTTPS only, every resolved address must be public, the connection is pinned to the checked address with CURLOPT_RESOLVE so a changing DNS answer cannot swap in a private one, redirects are not followed, and the body is read to a ceiling whatever its headers say. Uploading needs permission to write pages. MediaPolicy::create stays false, so the panel gains no free-standing "new file" button. In the MCP server a multipart binary field becomes `<field>_path` over stdio: the server reads the file from the caller's disk and sends it as multipart, instead of the model pushing base64 through its context. Over HTTP the server's disk is not the caller's, so only `body.url` is offered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
8.2 KiB
PHP
180 lines
8.2 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\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);
|
|
|
|
// 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']);
|
|
});
|
|
});
|