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]);