seedproject-web/api/public/controllers/contact.php

114 lines
4.2 KiB
PHP
Raw Normal View History

<?php
use App\Controllers\PublicController;
/**
* Contact form endpoint.
*
* POST /api/contact/submit accept a message, store it in cja_contact
*
* The router does `new Contact(...)`, so this is a GLOBAL class in
* public/controllers/ (not namespaced), matching account.php / health.php.
* The base class it extends is the namespaced one autoloaded by composer.
*
* Public, so it guards origin and rate-limits. Validation is server-side and
* authoritative; the browser's `required` attributes are a convenience only.
*/
class Contact extends PublicController
{
/** Subjects the form offers. The slug is stored; the label is display-only. */
private const SUBJECTS = [
'automation' => 'Automation',
'website-design' => 'Website Design',
'digital-marketing' => 'Digital Marketing',
'fractional-cto' => 'Fractional CTO',
'other' => 'Other',
];
public function submit(): void
{
// 5 submissions per 10 minutes per IP — generous for a human, useless
// for a script.
$this->guardPublic('contact_submit', 5, 600);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
$this->json(null, 405, ['code' => 'method_not_allowed', 'message' => 'POST only']);
}
$in = $this->readBody();
// Honeypot: a field hidden from humans. A bot fills everything, so any
// value here is spam. Answer 200 so it cannot tell it was caught, but
// write nothing.
if (trim((string) ($in['company'] ?? '')) !== '') {
$this->json(['received' => true]);
}
$name = trim((string) ($in['name'] ?? ''));
$email = trim((string) ($in['email'] ?? ''));
$subject = trim((string) ($in['subject'] ?? 'other'));
$message = trim((string) ($in['message'] ?? ''));
$errors = [];
if ($name === '' || mb_strlen($name) > 160) {
$errors['name'] = 'Please enter your name.';
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
$errors['email'] = 'Please enter a valid email address.';
}
if (!isset(self::SUBJECTS[$subject])) {
// Unknown subject is coerced, not rejected — never trust the
// client's list, but do not fail a real person over it either.
$subject = 'other';
}
if (mb_strlen($message) < 10) {
$errors['message'] = 'Please add a little more detail (10 characters or more).';
}
if (mb_strlen($message) > 5000) {
$errors['message'] = 'That message is a bit long — please keep it under 5000 characters.';
}
if ($errors) {
$this->json(null, 422, [
'code' => 'validation_failed',
'message' => 'Please check the highlighted fields.',
'fields' => $errors,
]);
}
\Db::insert('cja_contact', [
'name' => $name,
'email' => $email,
'subject' => $subject,
'message' => $message,
'status' => 'new',
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
'referrer' => mb_substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 255),
'created_at' => date('Y-m-d H:i:s'),
]);
// TODO: notify. A mailer helper exists (app/Helpers/Mailer.php, Brevo);
// wire it once a from-address and API key are configured. The row is
// saved regardless, so a mail outage never loses a lead.
$this->json([
'received' => true,
'message' => "Thanks — I'll get back to you shortly.",
]);
}
/** Read a JSON body, falling back to form-encoded POST. */
private function readBody(): array
{
$raw = file_get_contents('php://input');
if ($raw !== '' && $raw !== false) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
}
return $_POST ?: [];
}
}