203 lines
8.9 KiB
PHP
Executable File
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]);
|