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