contact.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. <?php
  2. declare(strict_types=1);
  3. header('Content-Type: application/json; charset=utf-8');
  4. header('Cache-Control: no-store');
  5. function respond(int $status, bool $ok, string $message): never
  6. {
  7. http_response_code($status);
  8. echo json_encode(['ok' => $ok, 'message' => $message], JSON_UNESCAPED_SLASHES);
  9. exit;
  10. }
  11. function loadContactEnvironment(): array
  12. {
  13. $values = [];
  14. foreach (['/etc/desktop-resume/contact.env', '/vagrant/.env'] as $path) {
  15. if (!is_readable($path)) {
  16. continue;
  17. }
  18. foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
  19. $line = trim($line);
  20. if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
  21. continue;
  22. }
  23. [$key, $value] = array_map('trim', explode('=', $line, 2));
  24. $values[$key] = trim($value, "\"'");
  25. }
  26. }
  27. return $values;
  28. }
  29. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  30. respond(405, false, 'Please submit the contact form.');
  31. }
  32. $contentType = $_SERVER['CONTENT_TYPE'] ?? '';
  33. if (str_contains($contentType, 'application/json')) {
  34. $input = json_decode(file_get_contents('php://input') ?: '{}', true);
  35. $input = is_array($input) ? $input : [];
  36. } else {
  37. $input = $_POST;
  38. }
  39. if (trim((string) ($input['website'] ?? '')) !== '') {
  40. respond(200, true, 'Thanks—your ticket is in the queue.');
  41. }
  42. $startedAt = (int) ($input['started_at'] ?? 0);
  43. if ($startedAt <= 0 || time() - $startedAt < 3 || time() - $startedAt > 86400) {
  44. respond(400, false, 'Please refresh the page and try again.');
  45. }
  46. $name = trim((string) ($input['name'] ?? ''));
  47. $email = trim((string) ($input['email'] ?? ''));
  48. $topic = trim((string) ($input['topic'] ?? ''));
  49. $message = trim((string) ($input['message'] ?? ''));
  50. $allowedTopics = ['Junior support opportunity', 'Interview conversation', 'Networking', 'Website feedback'];
  51. if ($name === '' || mb_strlen($name) > 80) {
  52. respond(422, false, 'Please enter your name.');
  53. }
  54. if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 160) {
  55. respond(422, false, 'Please enter a valid email address.');
  56. }
  57. if (!in_array($topic, $allowedTopics, true)) {
  58. respond(422, false, 'Please choose a ticket type.');
  59. }
  60. if (mb_strlen($message) < 20 || mb_strlen($message) > 3000) {
  61. respond(422, false, 'Please include a little more detail (20–3,000 characters).');
  62. }
  63. $rateKey = hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . '|desktop-contact');
  64. $rateFile = sys_get_temp_dir() . '/contact-' . $rateKey;
  65. $lastSent = is_file($rateFile) ? (int) file_get_contents($rateFile) : 0;
  66. if ($lastSent > time() - 60) {
  67. respond(429, false, 'That ticket just came through. Please wait a minute before sending another.');
  68. }
  69. $environment = loadContactEnvironment();
  70. $apiKey = getenv('RESEND_API_KEY') ?: ($environment['RESEND_API_KEY'] ?? $environment['RESEND_KEY'] ?? '');
  71. $from = getenv('RESEND_FROM') ?: ($environment['RESEND_FROM'] ?? 'Desktop Support Portfolio <contact@jadenportfolio.com>');
  72. if ($apiKey === '') {
  73. error_log('Contact form: RESEND_API_KEY is not configured.');
  74. respond(503, false, 'The message desk is temporarily offline. Please email me directly instead.');
  75. }
  76. $safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  77. $safeEmail = htmlspecialchars($email, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  78. $safeTopic = htmlspecialchars($topic, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  79. $safeMessage = nl2br(htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
  80. $ticket = 'WEB-' . strtoupper(substr(hash('sha256', $email . microtime()), 0, 8));
  81. $payload = [
  82. 'from' => $from,
  83. 'to' => ['praesidium909@gmail.com'],
  84. 'reply_to' => $email,
  85. 'subject' => "[$ticket] $topic — $name",
  86. 'html' => "<div style=\"font-family:Arial,sans-serif;max-width:640px;margin:auto;color:#172033\"><p style=\"color:#64748b\">New portfolio support ticket · <strong>$ticket</strong></p><h1 style=\"font-size:24px\">$safeTopic</h1><table style=\"width:100%;border-collapse:collapse;margin:24px 0\"><tr><td style=\"padding:10px;border-bottom:1px solid #e2e8f0;color:#64748b\">From</td><td style=\"padding:10px;border-bottom:1px solid #e2e8f0\"><strong>$safeName</strong></td></tr><tr><td style=\"padding:10px;border-bottom:1px solid #e2e8f0;color:#64748b\">Email</td><td style=\"padding:10px;border-bottom:1px solid #e2e8f0\">$safeEmail</td></tr></table><div style=\"background:#f8fafc;border-left:4px solid #0ea5e9;padding:20px;line-height:1.6\">$safeMessage</div><p style=\"color:#64748b;font-size:13px;margin-top:24px\">Reply directly to this email to respond.</p></div>",
  87. 'text' => "Ticket: $ticket\nType: $topic\nFrom: $name <$email>\n\n$message",
  88. ];
  89. $curl = curl_init('https://api.resend.com/emails');
  90. curl_setopt_array($curl, [
  91. CURLOPT_POST => true,
  92. CURLOPT_RETURNTRANSFER => true,
  93. CURLOPT_TIMEOUT => 15,
  94. CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Content-Type: application/json'],
  95. CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_SLASHES),
  96. ]);
  97. $response = curl_exec($curl);
  98. $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
  99. $error = curl_error($curl);
  100. curl_close($curl);
  101. if ($response === false || $status < 200 || $status >= 300) {
  102. error_log("Contact form: Resend failed with HTTP $status: $error");
  103. respond(502, false, 'The ticket could not be sent right now. Please use the email link instead.');
  104. }
  105. file_put_contents($rateFile, (string) time(), LOCK_EX);
  106. respond(200, true, "Ticket $ticket received. I’ll be in touch soon!");