Establishes the deploy baseline on main so the admin agent's publish/rollback has a clean starting point. Everything built to date: sumi-e brand system, homepage, projects (DB-driven case studies), resume, about, services + website-design detail, contact form + DB, changelog, favicon + share card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
111 lines
4.1 KiB
PHP
111 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
/**
|
|
* Contact form endpoint.
|
|
*
|
|
* POST /api/contact/submit — accept a message, store it in cja_contact
|
|
*
|
|
* Public, so it guards origin and rate-limits (5 per 10 minutes per IP —
|
|
* generous for a human, useless for a script). Validation is server-side and
|
|
* authoritative; the browser's `required` attributes are a convenience, not a
|
|
* control.
|
|
*/
|
|
class Contact extends PublicController
|
|
{
|
|
/** Subjects the form offers. The slug is stored; the label is display-only. */
|
|
private const SUBJECTS = [
|
|
'ai-training' => 'AI Training',
|
|
'workflow-automation' => 'Workflow Automation',
|
|
'software-development' => 'Software Development',
|
|
'website-design' => 'Website Design',
|
|
'fractional-cto' => 'Fractional CTO',
|
|
'other' => 'Other',
|
|
];
|
|
|
|
public function submit(): void
|
|
{
|
|
// 5 submissions per 10 minutes per IP.
|
|
$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 the bot 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 rather than 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 either way, 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 ?: [];
|
|
}
|
|
}
|