Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Enums\ApplicationStatus;
use App\Http\Requests\PublicApi\SubmitApplicationRequest;
use App\Models\Application;
use App\Models\Call;
use App\Notifications\ApplicationReceived;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* The public site writing back.
*
* Everything else under /api/public is read-only, which meant a call for
* contributors ended at "this is what we need" with nowhere to answer. These
* two endpoints close that loop, and they are the only place a stranger can
* put a row in the database — so they are also the only place that needs
* throttling, a honeypot and an off switch.
*/
class ApplicationController extends PublicController
{
/**
* POST /api/public/v1/calls/{slug}/apply
*
* Answering a specific opening. Only an open call accepts applications:
* taking someone's time to write a message for a position that is already
* filled is worse than showing them no form at all.
*/
public function apply(SubmitApplicationRequest $request, string $slug): JsonResponse
{
if ($closed = $this->refusedBecauseClosed()) {
return $closed;
}
$call = Call::query()->whereSlug($slug)->open()->firstOrFail();
return $this->record($request, $call);
}
/**
* POST /api/public/v1/contact
*
* The general form: someone offering time without a call in mind, or an
* association asking for help.
*/
public function contact(SubmitApplicationRequest $request): JsonResponse
{
if ($closed = $this->refusedBecauseClosed()) {
return $closed;
}
return $this->record($request, null);
}
/**
* Store the submission, or recognise it as one already stored.
*/
protected function record(SubmitApplicationRequest $request, ?Call $call): JsonResponse
{
$locale = app()->getLocale();
if ($existing = $this->recentDuplicate($request->string('email')->toString(), $call?->id)) {
// A double-clicked submit button, an impatient retry, a proxy
// replaying the request: none of them is a second person, and
// answering with an error would make the visitor try harder.
return $this->accepted($existing, duplicate: true);
}
$application = Application::create([
'call_id' => $call?->id,
'call_title' => $call?->getTranslation('title', $locale),
'kind' => $request->kind(),
// Filed, not discarded. The honeypot is a heuristic, and a real
// person whose password manager filled the hidden field can be
// rescued from the spam filter; a dropped message cannot.
'status' => $request->looksAutomated()
? ApplicationStatus::Spam
: ApplicationStatus::New,
'name' => $request->string('name')->trim()->toString(),
'email' => $request->string('email')->trim()->lower()->toString(),
'organisation' => $request->string('organisation')->trim()->toString() ?: null,
'message' => $request->string('message')->trim()->toString(),
'hours_per_week' => $request->integer('hours_per_week') ?: null,
'links' => $this->links($request),
// The language they wrote in, so the reply goes out in the same
// one. Taken from the negotiated locale, not from the payload.
'locale' => $locale,
'ip_hash' => Application::hashIp($request->ip()),
]);
$this->announce($application);
return $this->accepted($application);
}
/**
* Tell the panel someone is waiting. An inbox nobody is told about is an
* inbox nobody reads.
*
* Failure here must never reach the sender: their message is already
* saved, and a misconfigured mailer is our problem, not theirs.
*/
protected function announce(Application $application): void
{
if ($application->status === ApplicationStatus::Spam) {
return;
}
try {
Notification::send(ApplicationReceived::recipients(), new ApplicationReceived($application));
} catch (Throwable $e) {
report($e);
}
}
/**
* The same person answering the same thing again within the window.
*/
protected function recentDuplicate(string $email, ?int $callId): ?Application
{
$minutes = (int) config('site.submissions.deduplicate_minutes', 1440);
if ($minutes <= 0) {
return null;
}
return Application::query()
->where('email', Str::lower(trim($email)))
->where('call_id', $callId)
->where('created_at', '>=', now()->subMinutes($minutes))
->latest('id')
->first();
}
/**
* Named links, capped and with tidied keys — the payload allows an object
* like {"github": "..."} and the keys end up on screen in the panel.
*
* @return array<string, string>|null
*/
protected function links(SubmitApplicationRequest $request): ?array
{
$links = collect((array) $request->input('links'))
->filter(fn ($url) => filled($url))
->take(5)
->mapWithKeys(fn (string $url, string|int $key) => [
Str::limit(Str::slug((string) $key) ?: 'link', 30, '') => $url,
])
->all();
return $links === [] ? null : $links;
}
/**
* The frontend needs an id to show a confirmation and nothing else: the
* body of a submission is never echoed back, so a guessed id cannot be
* used to read somebody else's message.
*/
protected function accepted(Application $application, bool $duplicate = false): JsonResponse
{
return response()->json([
'data' => [
'id' => $application->id,
'kind' => $application->kind->value,
'status' => ApplicationStatus::New->value,
'duplicate' => $duplicate,
],
'message' => __('site.applications.received'),
], $duplicate ? Response::HTTP_OK : Response::HTTP_CREATED)
// Never cacheable, and never revalidated: a shared cache holding
// on to one of these would answer the next visitor with somebody
// else's confirmation.
->header('Cache-Control', 'no-store, private');
}
/**
* The kill switch, for when a spam run gets past everything else.
*/
protected function refusedBecauseClosed(): ?JsonResponse
{
if (config('site.submissions.enabled', true)) {
return null;
}
return response()->json([
'message' => __('site.applications.closed'),
'submissions_enabled' => false,
], Response::HTTP_SERVICE_UNAVAILABLE)
->header('Cache-Control', 'no-store, private');
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicCallResource;
use App\Models\Call;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Open calls for contributors — the conversion point of the whole site.
*/
class CallController extends PublicController
{
public function index(Request $request): AnonymousResourceCollection
{
$calls = Call::query()
->with(['technologies', 'project.coverMedia'])
->unless($this->isPreview($request), fn (Builder $q) => $q->open())
// A call attached to an unpublished project must not leak it.
->where(fn (Builder $q) => $q
->whereNull('project_id')
->orWhereHas('project', fn (Builder $p) => $p->published()))
->when($request->filled('technology'), fn (Builder $q) => $q
->whereHas('technologies', fn (Builder $t) => $t
->whereIn('slug', (array) $request->input('technology'))))
// "I have four hours a week" is the real filter people apply.
->when($request->filled('max_hours_per_week'), fn (Builder $q) => $q
->where('hours_per_week', '<=', $request->integer('max_hours_per_week')))
->orderByRaw('hours_per_week is null, hours_per_week asc')
->paginate($this->perPage($request))
->withQueryString();
return PublicCallResource::collection($calls);
}
public function show(Request $request, string $slug): PublicCallResource
{
$call = Call::query()
->with(['technologies', 'project.technologies', 'project.coverMedia'])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->open())
->firstOrFail();
return PublicCallResource::make($call);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicMemberResource;
use App\Models\Member;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class MemberController extends PublicController
{
public function index(Request $request): AnonymousResourceCollection
{
$members = Member::query()
->with(['technologies', 'avatarMedia'])
->withCount('projects')
->public()
// Former members are kept but hidden by default; the frontend can
// ask for them to build an "alumni" section.
->unless($request->boolean('include_past'), fn (Builder $q) => $q->current())
->when($request->filled('technology'), fn (Builder $q) => $q
->whereHas('technologies', fn (Builder $t) => $t
->whereIn('slug', (array) $request->input('technology'))))
->orderByDesc('is_founder')
->orderBy('position')
->orderBy('name')
->paginate($this->perPage($request, 50))
->withQueryString();
return PublicMemberResource::collection($members);
}
public function show(string $slug): PublicMemberResource
{
$member = Member::query()
->with([
'technologies',
'avatarMedia',
'projects' => fn ($q) => $q->published()
->with(['technologies', 'coverMedia'])
->withCount(['calls as open_calls_count' => fn ($c) => $c->open()]),
])
->public()
->where('slug', $slug)
->firstOrFail();
return PublicMemberResource::make($member);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicPageResource;
use App\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PageController extends PublicController
{
public function index(Request $request): AnonymousResourceCollection
{
$pages = Page::query()
->with(['category', 'featuredMedia'])
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->when($request->filled('category'), fn (Builder $q) => $q
->whereHas('category', fn (Builder $c) => $c
->where('slug->'.app()->getLocale(), $request->string('category'))))
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
->orderByDesc('published_at')
->paginate($this->perPage($request))
->withQueryString();
return PublicPageResource::collection($pages);
}
public function show(Request $request, string $slug): PublicPageResource
{
$page = Page::query()
->with(['category', 'featuredMedia'])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->firstOrFail();
return PublicPageResource::make($page);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicProjectResource;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProjectController extends PublicController
{
/**
* GET /api/public/v1/projects
*/
public function index(Request $request): AnonymousResourceCollection
{
$locale = app()->getLocale();
$projects = Project::query()
// `coverMedia` carries the dimensions and responsive versions of
// the cover; loaded here so a listing costs one query, not one per
// project.
->with(['technologies', 'lead', 'repositories', 'coverMedia', 'lead.avatarMedia', 'client.logoMedia'])
->withCount([
'members',
// Powers the `needs_help` flag without loading every call.
'calls as open_calls_count' => fn (Builder $q) => $q->open(),
])
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->when($request->filled('status'), fn (Builder $q) => $q
->whereIn('status', (array) $request->input('status')))
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
->when($request->boolean('needs_help'), fn (Builder $q) => $q->needingHelp())
->when($request->filled('technology'), fn (Builder $q) => $q
->whereHas('technologies', fn (Builder $t) => $t
->whereIn('slug', (array) $request->input('technology'))))
->when($request->filled('member'), fn (Builder $q) => $q
->whereHas('members', fn (Builder $m) => $m
->where('slug', $request->string('member'))))
// Only projects that actually exist in this language, so the
// frontend never renders a listing of fallback content.
->when($request->boolean('translated'), fn (Builder $q) => $q
->whereNotNull("title->{$locale}"))
->orderByDesc('is_featured')
->orderBy('position')
->orderByDesc('published_at')
->paginate($this->perPage($request))
->withQueryString();
return PublicProjectResource::collection($projects);
}
/**
* GET /api/public/v1/projects/{slug}
*
* Resolution is by slug within the active locale — the access pattern an
* SSR frontend actually has, since it receives a URL, not an id.
*/
public function show(Request $request, string $slug): PublicProjectResource
{
$project = Project::query()
->with([
'technologies',
'coverMedia',
'images.media',
'client.logoMedia',
'services',
'testimonials' => fn ($q) => $q->public()->with('avatarMedia')->orderBy('position'),
'talks' => fn ($q) => $q->public()->with('member')->recentFirst(),
'lead',
'lead.avatarMedia',
'members.technologies',
'members.avatarMedia',
'repositories.goodFirstIssues',
'calls' => fn ($q) => $q->open()->with('technologies'),
])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->firstOrFail();
return PublicProjectResource::make($project)->detailed();
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* Shared behaviour for the public, unauthenticated endpoints.
*
* These endpoints serve the public frontend. They are deliberately readable by
* anyone: everything they return is already published on the public website,
* so requiring a token would add ceremony without adding protection. What is
* *not* published is gated by the preview secret instead.
*/
abstract class PublicController extends Controller
{
/**
* Whether this request may see unpublished content, as decided by
* App\Http\Middleware\AllowPreview.
*/
protected function isPreview(Request $request): bool
{
return (bool) $request->attributes->get('preview', false);
}
/**
* Page size, capped so a public caller cannot ask for everything at once.
*/
protected function perPage(Request $request, int $default = 20, int $max = 100): int
{
return min(max($request->integer('per_page', $default), 1), $max);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicClientResource;
use App\Http\Resources\PublicApi\PublicServiceResource;
use App\Http\Resources\PublicApi\PublicTalkResource;
use App\Http\Resources\PublicApi\PublicTestimonialResource;
use App\Models\Client;
use App\Models\Service;
use App\Models\Talk;
use App\Models\Testimonial;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The pieces a landing page is made of.
*
* Four collections rather than one "home page" payload, and no ordering of
* sections anywhere in here: what gets composed, and in what order, is the
* frontend's business. This side only knows what exists.
*/
class ShowcaseController extends PublicController
{
/**
* GET /api/public/v1/services
*/
public function services(Request $request): AnonymousResourceCollection
{
$services = Service::query()
->with(['technologies'])
->withCount('projects')
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
->orderBy('position')
->get();
return PublicServiceResource::collection($services);
}
/**
* GET /api/public/v1/services/{slug}
*
* Resolved by slug within the active locale, like every other record here.
*/
public function service(Request $request, string $slug): PublicServiceResource
{
$service = Service::query()
->with([
// The work that backs the offer, and what people said about
// it: a service page with neither is a promise.
'projects' => fn ($q) => $q->published()->with(['technologies', 'coverMedia']),
'technologies',
'testimonials' => fn ($q) => $q->public()->with(['client.logoMedia', 'avatarMedia'])->orderBy('position'),
])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->firstOrFail();
return PublicServiceResource::make($service)->detailed();
}
/**
* GET /api/public/v1/clients
*
* The wall of logos. Organisations that asked not to appear are absent
* whatever else is true of them.
*/
public function clients(Request $request): AnonymousResourceCollection
{
$clients = Client::query()
->with('logoMedia')
->withCount(['projects' => fn (Builder $q) => $q->published()])
->public()
// A client with nothing published behind it would be a logo the
// site cannot justify showing.
->when($request->boolean('with_projects', true), fn (Builder $q) => $q
->whereHas('projects', fn (Builder $p) => $p->published()))
->orderBy('position')
->orderBy('name')
->get();
return PublicClientResource::collection($clients);
}
/**
* GET /api/public/v1/testimonials
*/
public function testimonials(Request $request): AnonymousResourceCollection
{
$testimonials = Testimonial::query()
->with(['client.logoMedia', 'avatarMedia'])
->public()
->when($request->filled('client'), fn (Builder $q) => $q
->whereHas('client', fn (Builder $c) => $c->where('slug', $request->string('client'))))
->when($request->filled('service'), fn (Builder $q) => $q
->whereHas('service', fn (Builder $s) => $s
->where('slug->'.app()->getLocale(), $request->string('service'))))
->orderBy('position')
->orderByDesc('given_at')
->get();
return PublicTestimonialResource::collection($testimonials);
}
/**
* GET /api/public/v1/talks
*/
public function talks(Request $request): AnonymousResourceCollection
{
$talks = Talk::query()
->with('member')
->public()
->when($request->boolean('with_video'), fn (Builder $q) => $q->whereNotNull('video_url'))
->recentFirst()
->limit(min($request->integer('limit', 50), 100))
->get();
return PublicTalkResource::collection($talks);
}
}

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicIssueResource;
use App\Http\Resources\PublicApi\PublicTechnologyResource;
use App\Models\Call;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Repository;
use App\Models\RepositoryIssue;
use App\Models\Service;
use App\Models\Technology;
use App\Support\ContentVersion;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Site-wide endpoints that do not belong to a single content type.
*/
class SiteController extends PublicController
{
/**
* GET /api/public/v1/site
*
* What the frontend needs once, on any page: which languages exist, and
* the headline numbers a collective's home page tends to show.
*/
public function index(): JsonResponse
{
return response()->json([
'data' => [
'locales' => collect(config('locales.supported'))
->map(fn (array $meta, string $code) => [
'code' => $code,
'name' => $meta['native'] ?? $code,
'flag' => $meta['flag'] ?? null,
'is_default' => $code === config('locales.default'),
])
->values(),
'default_locale' => config('locales.default'),
/*
* A number that moves whenever published content does. Keep it
* as part of your cache key and everything downstream becomes
* stale the moment the site really changed, instead of when a
* timer says so.
*/
'content_version' => ContentVersion::current(),
// So the frontend hides its forms when the inbox is closed,
// rather than rendering one that would be refused on submit.
'submissions_enabled' => (bool) config('site.submissions.enabled', true),
'stats' => [
'projects' => Project::query()->published()->count(),
'active_projects' => Project::query()->published()->where('status', 'active')->count(),
'members' => Member::query()->public()->current()->count(),
'open_calls' => Call::query()->open()->count(),
'services' => Service::query()->published()->count(),
'clients' => Client::query()->public()->whereHas('projects', fn ($q) => $q->published())->count(),
'good_first_issues' => RepositoryIssue::query()
->where('is_good_first_issue', true)
->whereNull('assignee')
->whereHas('repository', fn ($q) => $q->publiclyVisible())
->count(),
'stars' => (int) Repository::query()->publiclyVisible()->sum('stars'),
],
],
]);
}
/**
* GET /api/public/v1/slugs
*
* Every published URL, grouped by type and locale. This is what the
* frontend builds its sitemap and static paths from — one request instead
* of paging through every collection.
*/
public function slugs(): JsonResponse
{
$entries = [];
foreach (Page::query()->published()->get(['id', 'slug', 'updated_at']) as $page) {
$entries['pages'][] = $this->entry($page, 'page');
}
foreach (Project::query()->published()->get(['id', 'slug', 'updated_at']) as $project) {
$entries['projects'][] = $this->entry($project, 'project');
}
foreach (Call::query()->open()->get(['id', 'slug', 'updated_at']) as $call) {
$entries['calls'][] = $this->entry($call, 'call');
}
foreach (Service::query()->published()->get(['id', 'slug', 'updated_at']) as $service) {
$entries['services'][] = $this->entry($service, 'service');
}
foreach (Member::query()->public()->current()->get(['id', 'slug', 'updated_at']) as $member) {
$entries['members'][] = [
'id' => $member->id,
'updated_at' => $member->updated_at?->toIso8601String(),
'alternates' => collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => SiteUrls::path('member', $l, $member->slug)])
->all(),
];
}
return response()->json(['data' => $entries]);
}
/**
* GET /api/public/v1/redirects
*
* Every URL that used to work and where it goes now, so the frontend can
* answer a 301 instead of a 404. Most of these were written by the panel
* itself the moment somebody renamed a slug — the only moment at which the
* old address was still known.
*
* Returned whole rather than looked up one at a time: there are never many,
* and an SSR frontend would otherwise ask this API on every 404 it serves.
*/
public function redirects(): JsonResponse
{
$redirects = Redirect::query()
->enabled()
->orderBy('from')
->get(['from', 'to', 'status', 'locale']);
return response()->json([
'data' => $redirects->map(fn (Redirect $redirect) => [
'from' => $redirect->from,
'to' => $redirect->to,
'status' => $redirect->status,
// Null means it applies whatever language the visitor is
// browsing in.
'locale' => $redirect->locale,
])->all(),
]);
}
/**
* GET /api/public/v1/radar
*
* The tech radar: what the collective adopts, is trialling, is assessing
* and is holding off on. Highly shareable and cheap to maintain.
*/
public function radar(): AnonymousResourceCollection
{
$technologies = Technology::query()
->onRadar()
->with('logoMedia')
->withCount(['projects', 'members'])
->orderBy('category')
->orderBy('position')
->orderBy('name')
->get();
return PublicTechnologyResource::collection($technologies);
}
/**
* GET /api/public/v1/technologies
*/
public function technologies(Request $request): AnonymousResourceCollection
{
$technologies = Technology::query()
->with('logoMedia')
->withCount(['projects', 'members'])
->when($request->filled('category'), fn ($q) => $q
->whereIn('category', (array) $request->input('category')))
// By default only technologies actually in use, so the list is not
// padded with tags nobody applied.
->when($request->boolean('in_use', true), fn ($q) => $q->has('projects'))
->orderBy('position')
->orderBy('name')
->get();
return PublicTechnologyResource::collection($technologies);
}
/**
* GET /api/public/v1/good-first-issues
*
* The single most effective thing on a collective's site: it turns a
* visitor who was only browsing into a contributor in one click.
*/
public function goodFirstIssues(Request $request): AnonymousResourceCollection
{
$issues = RepositoryIssue::query()
->with(['repository.project'])
->where(fn ($q) => $q
->where('is_good_first_issue', true)
->orWhere('is_help_wanted', $request->boolean('include_help_wanted', true)))
// An issue somebody already took is not an invitation.
->available()
->whereHas('repository', fn ($q) => $q->publiclyVisible()->where('is_archived', false))
->when($request->filled('language'), fn ($q) => $q
->whereHas('repository', fn ($r) => $r
->where('primary_language', $request->string('language'))))
->orderByDesc('issue_created_at')
->limit(min($request->integer('limit', 20), 100))
->get();
return PublicIssueResource::collection($issues);
}
/**
* @return array<string, mixed>
*/
protected function entry(Model $record, string $type): array
{
return [
'id' => $record->id,
'updated_at' => $record->updated_at?->toIso8601String(),
'alternates' => SiteUrls::alternates($record, $type),
];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Requests\PublicApi\ReportMissingUrlRequest;
use App\Http\Requests\PublicApi\ReportViewsRequest;
use App\Models\ContentView;
use App\Models\MissingUrl;
use App\Models\Redirect;
use App\Support\ContentTypes;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* The frontend reporting back.
*
* Everything else this API does is answer questions. These two endpoints are
* the direction nothing else travels in: the site telling the panel what
* happened out there, which is the half of every editorial decision that has
* been missing.
*
* Two things are worth knowing and neither can be discovered from here: which
* addresses failed, because a broken link only ever fails in front of a
* visitor, and which records were read, because the panel can say what is
* published and never whether anybody opened it.
*
* What is deliberately not collected in either: anything about a person. No
* address, no session, no user agent, no time of day. A failed URL and a daily
* tally per record answer the questions; a visitor log would answer different
* ones nobody here asked.
*/
class TelemetryController extends PublicController
{
/**
* POST /api/public/v1/telemetry/not-found
*
* Answers 202 rather than 201: nothing here is a resource the caller owns,
* and reports that were merged into an existing count or dropped as noise
* are still perfectly successful. The count of what was actually recorded
* comes back so the frontend can tell whether its filter matches ours.
*/
public function notFound(ReportMissingUrlRequest $request): JsonResponse
{
$recorded = 0;
foreach ($request->hits() as $hit) {
// Normalised exactly as a redirect is, because that is what these
// are eventually compared against: "/x/" and "/x?ref=1" are the
// same broken address and must not become two rows.
$path = Redirect::normalise((string) $hit['path']);
if ($this->isNoise($path)) {
continue;
}
MissingUrl::report(
$path,
// The frontend usually knows which language it was rendering.
// When it does not, the negotiated locale is the same answer:
// it comes from the Accept-Language the frontend forwarded.
filled($hit['locale'] ?? null) ? (string) $hit['locale'] : app()->getLocale(),
filled($hit['referrer'] ?? null) ? (string) $hit['referrer'] : null,
);
$recorded++;
}
return response()->json([
'data' => [
'received' => count($request->hits()),
'recorded' => $recorded,
],
], Response::HTTP_ACCEPTED)
->header('Cache-Control', 'no-store, private');
}
/**
* POST /api/public/v1/telemetry/views
*
* What was read. The panel has always known what exists and what is
* published, and never whether any of it gets opened — which is the fact
* behind most editorial decisions: what to translate next, whether the
* featured project is one anybody looks at, whether a page earns the work
* of keeping it accurate.
*
* There is no visitor in any of this. A report says a record was rendered
* in a language; what is stored is a daily total per record. No address,
* no session, no user agent, no time of day — nothing that could be
* assembled back into a person.
*/
public function views(ReportViewsRequest $request): JsonResponse
{
$recorded = 0;
foreach ($request->views() as $view) {
$locale = filled($view['locale'] ?? null)
? (string) $view['locale']
: app()->getLocale();
$record = ContentTypes::resolve((string) $view['type'], (string) $view['slug'], $locale);
// A slug that no longer resolves is a 404, and the endpoint next
// door exists to hear about those. Counting it here would attach
// somebody's reading to nothing.
if ($record === null) {
continue;
}
ContentView::record($record, $locale, (int) ($view['count'] ?? 1));
$recorded++;
}
return response()->json([
'data' => [
'received' => count($request->views()),
'recorded' => $recorded,
],
], Response::HTTP_ACCEPTED)
->header('Cache-Control', 'no-store, private');
}
/**
* A scan for something that was never here.
*
* Dropped rather than stored, and dropped on this side rather than left to
* the frontend: the list is only useful while a person is willing to read
* it, and one afternoon of a bot looking for WordPress would end that.
*/
protected function isNoise(string $path): bool
{
foreach ((array) config('site.telemetry.ignore', []) as $pattern) {
if (fnmatch((string) $pattern, $path, FNM_CASEFOLD)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\CategoryRequest;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CategoryController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Category::class);
$locale = app()->getLocale();
$categories = Category::query()
->withCount('pages')
->when($request->has('is_visible'), fn ($q) => $q->where('is_visible', $request->boolean('is_visible')))
->when($request->filled('search'), fn ($q) => $q->where("name->{$locale}", 'like', '%'.$request->string('search').'%'))
->orderBy('position')
->orderBy("name->{$locale}")
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return CategoryResource::collection($categories);
}
public function store(CategoryRequest $request): JsonResponse
{
$this->authorize('create', Category::class);
$category = new Category;
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
$category->save();
return CategoryResource::make($category)->response()->setStatusCode(201);
}
public function show(Category $category): CategoryResource
{
$this->authorize('view', $category);
return CategoryResource::make($category->loadCount('pages'));
}
public function update(CategoryRequest $request, Category $category): CategoryResource
{
$this->authorize('update', $category);
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
$category->save();
return CategoryResource::make($category->loadCount('pages'));
}
public function destroy(Category $category): JsonResponse
{
// CategoryPolicy refuses while the category still holds pages, so this
// answers 403 rather than orphaning content.
$this->authorize('delete', $category);
$category->delete();
return response()->json(status: 204);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
/**
* GET /api/v1/me — who the bearer token belongs to, and what they may do here.
*
* The identity part mirrors the OAuth server's /api/user. The `permissions`
* array is this application's own answer, and is what a client should drive its
* UI from: the identity server does not know about local roles.
*/
class MeController extends Controller
{
public function __invoke(Request $request): UserResource
{
return UserResource::make(
$request->user()->load('roles')
);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\PageStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\PageRequest;
use App\Http\Resources\PageResource;
use App\Models\Page;
use App\Support\TranslatablePayload;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PageController extends Controller
{
/**
* GET /api/v1/pages
*
* Filters: status, category_id, is_featured, search, translated_in, trashed
* Sorting: sort=-published_at (prefix with "-" for descending)
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Page::class);
$locale = app()->getLocale();
$pages = Page::query()
->with(['category', 'author'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
// Search inside the active locale's translation rather than across
// the whole JSON blob, so a Spanish query cannot match English text.
->when($request->filled('search'), fn ($q) => $q->where(
fn ($sub) => $sub
->where("title->{$locale}", 'like', '%'.$request->string('search').'%')
->orWhere("excerpt->{$locale}", 'like', '%'.$request->string('search').'%')
))
->when($request->filled('translated_in'), fn ($q) => $q->translatedIn((string) $request->string('translated_in')))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
// Callers without the publish permission only ever see live content.
->unless($request->user()->can('pages.publish'), fn ($q) => $q->published())
->tap(fn ($q) => $this->applySort($q, $request))
->paginate(perPage: min($request->integer('per_page', 15), 100))
->withQueryString();
return PageResource::collection($pages);
}
public function store(PageRequest $request): JsonResponse
{
$this->authorize('create', Page::class);
$page = new Page;
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
$page->author_id ??= $request->user()->id;
// A page created as published with no explicit date goes live now.
if ($page->status === PageStatus::Published && $page->published_at === null) {
$page->published_at = now();
}
$page->save();
return PageResource::make($page->load(['category', 'author']))
->response()
->setStatusCode(201);
}
public function show(Request $request, Page $page): PageResource
{
$this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author']));
}
public function update(PageRequest $request, Page $page): PageResource
{
$this->authorize('update', $page);
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
if ($page->status === PageStatus::Published && $page->published_at === null) {
$page->published_at = now();
}
$page->save();
return PageResource::make($page->load(['category', 'author']));
}
public function destroy(Page $page): JsonResponse
{
$this->authorize('delete', $page);
$page->delete();
return response()->json(status: 204);
}
public function restore(Page $page): PageResource
{
$this->authorize('restore', $page);
$page->restore();
return PageResource::make($page->load(['category', 'author']));
}
/**
* Whitelist of sortable columns. Anything else is ignored rather than
* passed to the query builder.
*
* @param Builder<Page> $query
*/
protected function applySort(Builder $query, Request $request): void
{
$sort = (string) $request->string('sort', '-created_at');
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$column = ltrim($sort, '-');
$sortable = ['created_at', 'updated_at', 'published_at', 'position', 'status'];
// Sorting by a translatable column targets the active locale's key.
if ($column === 'title') {
$query->orderBy('title->'.app()->getLocale(), $direction);
return;
}
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\RoleResource;
use App\Support\Permissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Spatie\Permission\Models\Role;
/**
* Read-only view of the local authorisation model, so clients can render
* permission-aware UI without hard-coding role names.
*/
class RoleController extends Controller
{
public function index(): AnonymousResourceCollection
{
$this->authorize('viewAny', Role::class);
$roles = Role::query()
->with('permissions')
->withCount('users')
->orderBy('name')
->get();
return RoleResource::collection($roles);
}
public function show(Role $role): RoleResource
{
$this->authorize('view', $role);
return RoleResource::make($role->load('permissions')->loadCount('users'));
}
/**
* The full permission catalogue, grouped by resource — useful for building
* a role editor outside the panel.
*/
public function catalogue(): JsonResponse
{
$this->authorize('viewAny', Role::class);
return response()->json(['data' => Permissions::grouped()]);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Auth\SyncPireonUser;
use App\Http\Controllers\Controller;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
/**
* POST /api/v1/auth/token
*
* A thin, rate-limited proxy over the identity server's password and
* refresh_token grants, so first-party clients (mobile apps, test suites) can
* obtain a token without embedding the OAuth server's URL and client secret.
*
* The panel's sign-in form uses the same grant directly, so
* `PIREON_PASSWORD_GRANT=false` closes both doors at once.
*/
class TokenController extends Controller
{
public function __construct(
protected PireonClient $client,
protected SyncPireonUser $sync,
) {}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'grant_type' => ['required', 'in:password,refresh_token'],
'username' => ['required_if:grant_type,password', 'string'],
'password' => ['required_if:grant_type,password', 'string'],
'refresh_token' => ['required_if:grant_type,refresh_token', 'string'],
'scope' => ['sometimes', 'string'],
]);
$scopes = isset($data['scope'])
? array_values(array_filter(explode(' ', $data['scope'])))
: null;
try {
$token = $data['grant_type'] === 'password'
? $this->client->requestPasswordToken($data['username'], $data['password'], $scopes)
: $this->client->refresh($data['refresh_token']);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
// An identity server that failed to answer has said nothing about
// these credentials, so it must not be reported as though it had.
if (! $e->isRejection()) {
return response()->json([
'message' => __('pireon.server_unavailable'),
], Response::HTTP_SERVICE_UNAVAILABLE);
}
// Otherwise a validation error rather than the upstream body, so
// we never echo details of the identity server back to a caller
// probing for valid accounts.
throw ValidationException::withMessages([
'username' => [__('auth.failed')],
])->status($e->status === 401 ? 401 : 422);
}
if (! $identity->isUsable()) {
return response()->json([
'message' => $identity->isApproved()
? __('pireon.inactive')
: __('pireon.pending_approval'),
], 403);
}
// Provision the local account now, so the very first API call after
// this one already has roles and a user row to authorise against.
$user = $this->sync->handle($identity);
return response()->json([
'access_token' => $token->accessToken,
'refresh_token' => $token->refreshToken,
'token_type' => $token->tokenType,
'expires_at' => $token->expiresAt?->toIso8601String(),
'scopes' => $token->scopes,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->roles->pluck('name')->values(),
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
],
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
/**
* Local user administration.
*
* Only fields this application owns are writable: the local role assignment,
* the active flag, and the UI locale. Names and emails belong to the identity
* server and are refreshed from it on every login, so editing them here would
* be undone — change those at oauth.pireon.dev instead.
*/
class UserController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', User::class);
$users = User::query()
->with('roles')
->when($request->filled('search'), fn ($q) => $q->where(
fn ($sub) => $sub
->where('name', 'like', '%'.$request->string('search').'%')
->orWhere('email', 'like', '%'.$request->string('search').'%')
))
->when($request->has('is_active'), fn ($q) => $q->where('is_active', $request->boolean('is_active')))
->when($request->filled('role'), fn ($q) => $q->whereHas(
'roles',
fn ($sub) => $sub->where('name', $request->string('role'))
))
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return UserResource::collection($users);
}
public function show(User $user): UserResource
{
$this->authorize('view', $user);
return UserResource::make($user->load('roles'));
}
public function update(Request $request, User $user): UserResource
{
$this->authorize('update', $user);
$data = $request->validate([
'is_active' => ['sometimes', 'boolean'],
'locale' => ['sometimes', 'nullable', Rule::in(array_keys(config('locales.supported')))],
'roles' => ['sometimes', 'array'],
'roles.*' => [Rule::exists('roles', 'name')],
]);
if (array_key_exists('roles', $data)) {
$this->authorize('update', Role::class);
// Only a super admin may hand out super_admin, and nobody may
// strip their own privileges out from under themselves.
if (in_array('super_admin', $data['roles'], true) && ! $request->user()->hasRole('super_admin')) {
abort(403, __('pireon.cannot_grant_super_admin'));
}
$user->syncRoles($data['roles']);
unset($data['roles']);
}
$user->fill($data)->save();
return UserResource::make($user->fresh()->load('roles'));
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Http\Controllers\Controller;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
/**
* Authorization Code + PKCE flow against the Pireon identity server.
*
* The browser never sees a password belonging to this application, and the
* authorization code alone is useless without the verifier we keep server-side.
*/
class PireonAuthController extends Controller
{
public function __construct(
protected PireonClient $client,
protected TokenStore $tokens,
protected SyncPireonUser $sync,
) {}
/**
* Step 1 — hand the browser off to the identity server.
*/
public function redirect(Request $request): RedirectResponse
{
if (Auth::check()) {
return redirect()->intended(filament()->getUrl());
}
try {
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
$request->session()->put('pireon.state', $state);
$request->session()->put('pireon.code_verifier', $verifier);
// Remember where the user was headed before the redirect, so the
// callback can send them back there.
if ($target = $request->query('intended')) {
$request->session()->put('url.intended', $target);
}
return redirect()->away($url);
}
/**
* Step 2 — the identity server sends the browser back with a code.
*/
public function callback(Request $request): RedirectResponse
{
// The user pressed "deny", or the server refused the request.
if ($request->filled('error')) {
return redirect()->route('login')->with('error', __('pireon.denied', [
'reason' => (string) $request->query('error_description', $request->query('error')),
]));
}
$state = $request->session()->pull('pireon.state');
$verifier = $request->session()->pull('pireon.code_verifier');
// CSRF protection for the callback: a code arriving with a state we
// did not issue is not the result of a login we started.
if (blank($state) || ! hash_equals((string) $state, (string) $request->query('state'))) {
return $this->fail(PireonException::invalidState(), route('login'));
}
if (blank($verifier) || blank($request->query('code'))) {
return $this->fail(PireonException::invalidState(), route('login'));
}
try {
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
if (! $identity->isUsable()) {
return redirect()->route('login')->with('error', $identity->isApproved()
? __('pireon.inactive')
: __('pireon.pending_approval'));
}
$user = $this->sync->handle($identity);
// Rotate the session id so a fixated pre-login session cannot be
// reused now that it carries an authenticated identity.
$request->session()->regenerate();
Auth::login($user, remember: true);
$this->tokens->put($token);
if ($user->locale) {
$request->session()->put(config('locales.session_key'), $user->locale);
}
return redirect()->intended(filament()->getUrl());
}
public function logout(Request $request): RedirectResponse
{
$this->tokens->forget();
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
protected function fail(PireonException $e, string $to): RedirectResponse
{
Log::warning('Pireon login failed', [
'message' => $e->getMessage(),
'status' => $e->status,
]);
return redirect()->to($to)->with('error', $e->getMessage());
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
// Gives controllers $this->authorize(), so the REST API enforces the same
// policies the Filament panel does.
use AuthorizesRequests;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Browsable documentation for both APIs.
*
* The specification itself has always been there, as a file
* ([`public/openapi.yaml`](public/openapi.yaml)); what was missing was
* somewhere to read it. The people who need it most write the public frontend
* and have no reason to hold a panel account, so this is readable without
* signing in — the specification describes endpoints, not secrets, and it was
* already served at a fixed public path.
*/
class DocsController extends Controller
{
public function __invoke(Request $request): View
{
if (! file_exists(public_path('openapi.yaml'))) {
throw new NotFoundHttpException('The API specification has not been published.');
}
return view('docs.api', [
'spec' => asset('openapi.yaml'),
// Beside the specification because it is the same contract, and
// whoever is here is the person who needs it: generated with
// `php artisan api:types`, absent if it was never generated.
'types' => file_exists(public_path('api-types.d.ts')) ? asset('api-types.d.ts') : null,
// "Try it out" must call the installation you are actually
// looking at, not the canonical one in the configuration — the
// same page is opened on a laptop, on staging and in production.
'apiBase' => url('/api'),
'server' => $request->getHost(),
'environment' => (string) config('app.env'),
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* Switches the active locale from the panel's language menu, persisting the
* choice on the user record so it survives into their next session.
*/
class LocaleController extends Controller
{
public function __invoke(Request $request, string $locale): RedirectResponse
{
abort_unless(array_key_exists($locale, config('locales.supported')), 404);
$request->session()->put(config('locales.session_key'), $locale);
if ($user = $request->user()) {
$user->forceFill(['locale' => $locale])->save();
}
return back();
}
}