Files
floorithardwoodfloors.com/src/api/contact.php
T

203 lines
8.9 KiB
PHP
Executable File

<?php
/**
* Floor It Hardwood Floors: contact form handler.
*
* Pipeline:
* 1. Read JSON body (32KB cap)
* 2. Validate required fields
* 3. Honeypot + time-on-page checks
* 4. Altcha server-side verify
* 5. Sliding-window per-IP rate limit (file-backed in /var/www/html/src/api/data/rate-limits/)
* 6. POST to Resend, email to contact address
* 7. JSON response
*
* Configuration is read entirely from environment variables. Set these in
* .env or the runtime environment. No hardcoded keys in this file.
*/
declare(strict_types=1);
require_once __DIR__ . '/altcha.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// ─── Config from environment ────────────────────────────────────────
$RESEND_API_KEY = getenv('RESEND_API_KEY') ?: '';
$FROM_EMAIL = getenv('FROM_EMAIL') ?: 'noreply@floorithardwoods.com';
$TO_EMAIL = getenv('TO_EMAIL') ?: 'floorithardwoodfloors@gmail.com';
$RATE_LIMIT = (int)(getenv('RATE_LIMIT_PER_IP_PER_10MIN') ?: 5);
$TIME_MIN_SECONDS = (int)(getenv('TIME_MIN_SECONDS') ?: 3);
// ─── Helpers ────────────────────────────────────────────────────────
function send_json(array $data, int $status = 200): void {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
exit;
}
function fail(string $msg, int $status = 200): void {
send_json(['ok' => false, 'error' => $msg], $status);
}
function client_ip(): string {
$trust = (getenv('TRUST_PROXY') === '1');
if ($trust) {
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if ($fwd) return trim(explode(',', $fwd)[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
function rate_limit_check(string $ip, int $max, int $window_seconds): bool {
$dir = '/var/www/html/src/api/data/rate-limits';
if (!is_dir($dir)) @mkdir($dir, 0700, true);
$file = $dir . '/' . preg_replace('/[^a-zA-Z0-9._:-]/', '_', $ip);
$now = time();
$events = [];
if (is_file($file)) {
$raw = @file_get_contents($file);
$events = $raw ? array_filter(array_map('intval', explode("\n", $raw))) : [];
$events = array_filter($events, fn($t) => $now - $t <= $window_seconds);
}
if (count($events) >= $max) return false;
$events[] = $now;
@file_put_contents($file, implode("\n", $events), LOCK_EX);
return true;
}
function http_post_json(string $url, array $headers, array $body, int $timeout = 15): array {
$ch = curl_init($url);
$payload = json_encode($body, JSON_UNESCAPED_SLASHES);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers),
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $timeout,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return ['status' => $code, 'body' => $resp, 'err' => $err];
}
// ─── Method gate ────────────────────────────────────────────────────
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
fail('Method Not Allowed', 405);
}
// ─── Read + decode JSON body (capped to prevent DoS via huge payloads) ─
$MAX_BODY_BYTES = 32 * 1024; // 32 KB is plenty for this form
$raw = '';
$in = fopen('php://input', 'rb');
if ($in) {
$raw = stream_get_contents($in, $MAX_BODY_BYTES + 1);
fclose($in);
}
if (strlen($raw) > $MAX_BODY_BYTES) {
fail('Payload too large.', 413);
}
$body = json_decode($raw, true);
if (!is_array($body)) {
fail('Invalid request payload.');
}
$request_id = bin2hex(random_bytes(6));
$ip = client_ip();
// ─── Rate limit ─────────────────────────────────────────────────────
if (!rate_limit_check($ip, $RATE_LIMIT, 600)) {
error_log("[floorit.form] rate_limited request_id=$request_id ip=$ip");
fail('Too many requests. Please wait a few minutes.', 429);
}
// ─── Field extraction + validation ──────────────────────────────────
$name = trim((string)($body['name'] ?? ''));
$email = trim((string)($body['email'] ?? ''));
$phone = trim((string)($body['phone'] ?? ''));
$address = trim((string)($body['address'] ?? ''));
$sqft = trim((string)($body['sqft'] ?? ''));
$message = trim((string)($body['message'] ?? ''));
$website = trim((string)($body['website'] ?? '')); // honeypot
$form_loaded_at = trim((string)($body['form_loaded_at'] ?? ''));
$altcha_payload = trim((string)($body['altcha'] ?? ''));
$errors = [];
if (mb_strlen($name) < 2 || mb_strlen($name) > 80) $errors[] = 'name';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'email';
if (mb_strlen($phone) > 20) $errors[] = 'phone';
if (mb_strlen($address) < 5 || mb_strlen($address) > 200) $errors[] = 'address';
if ($sqft !== '' && (!ctype_digit($sqft) || (int)$sqft > 99999)) $errors[] = 'sqft';
if (mb_strlen($message) > 2000) $errors[] = 'message';
if ($errors) {
error_log("[floorit.form] validation_error request_id=$request_id fields=" . implode(',', $errors));
fail('Please check the form fields and try again.');
}
// ─── Honeypot ───────────────────────────────────────────────────────
if ($website !== '') {
error_log("[floorit.form] honeypot_triggered request_id=$request_id ip=$ip");
send_json(['ok' => true, 'ref' => $request_id]); // pretend success
}
// ─── Time-on-page ───────────────────────────────────────────────────
$flagged_review = false;
if ($form_loaded_at !== '' && ctype_digit(ltrim($form_loaded_at, '-'))) {
$loaded_ms = (int)$form_loaded_at;
$elapsed = (microtime(true) * 1000 - $loaded_ms) / 1000.0;
if ($elapsed < $TIME_MIN_SECONDS) $flagged_review = true;
}
// ─── Altcha verify ──────────────────────────────────────────────────
if (!VYC_Altcha::verify($altcha_payload)) {
error_log("[floorit.form] altcha_verification_failed request_id=$request_id");
fail('Spam check failed.');
}
// ─── Compose email body ─────────────────────────────────────────────
$subject_name = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $name);
$subject_prefix = $flagged_review ? '[REVIEW] ' : '';
$subject = "{$subject_prefix}New estimate request from {$subject_name}";
$text_body =
"A new estimate request came in through floorithardwoods.com.\n\n" .
"Name: {$name}\n" .
"Email: {$email}\n" .
"Phone: " . ($phone ?: 'not provided') . "\n" .
"Address: {$address}\n" .
"Sq Ft: " . ($sqft ?: 'not provided') . "\n\n" .
"Message:\n" . ($message ?: '(no message)') . "\n\n" .
"Submitted at: " . gmdate('Y-m-d\TH:i:s\Z') . "\n" .
"Request id: {$request_id}\n";
$html_body = nl2br(htmlspecialchars($text_body, ENT_QUOTES, 'UTF-8'));
// ─── Send via Resend ────────────────────────────────────────────────
if ($RESEND_API_KEY !== '') {
$r = http_post_json(
'https://api.resend.com/emails',
["Authorization: Bearer {$RESEND_API_KEY}"],
[
'from' => $FROM_EMAIL,
'to' => [$TO_EMAIL],
'reply_to' => $email,
'subject' => $subject,
'text' => $text_body,
'html' => $html_body,
]
);
if ($r['status'] >= 300) {
error_log("[floorit.form] resend_send_failed request_id=$request_id status={$r['status']} body=" . substr($r['body'] ?? '', 0, 300));
fail('Could not send the message. Please call (716) 602-1429 directly.');
}
$resend_id = (json_decode($r['body'] ?? '', true)['id'] ?? '');
error_log("[floorit.form] resend_send_ok request_id=$request_id resend_id=$resend_id");
} else {
// Pre-launch / no Resend key: log only, return success
error_log("[floorit.form] resend_skipped_no_key request_id=$request_id\nSubject: $subject\n$text_body");
}
send_json(['ok' => true, 'ref' => $request_id]);