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>
203 lines
7.0 KiB
PHP
203 lines
7.0 KiB
PHP
<?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');
|
|
}
|
|
}
|