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'); 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']); }); });