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