-> wydaje podpisany token formularza * GET ?action=confirm&form=&token=.. -> potwierdza przesunięcie suwaka * POST (JSON) -> waliduje i wysyła zgłoszenie */ $config = require __DIR__ . '/config.php'; header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); header('Referrer-Policy: same-origin'); // --------------------------------------------------------------------------- // Definicje formularzy. Serwer przyjmuje wyłącznie te pola - cokolwiek innego // przyjdzie w JSON-ie, jest ignorowane. // --------------------------------------------------------------------------- function spm_forms(): array { return [ 'newsletter' => [ 'lang' => 'pl', 'label' => 'Newsletter', 'subject' => 'Zapis do newslettera', 'fields' => [ 'email' => ['label' => 'E-mail', 'type' => 'email', 'required' => true, 'max' => 190], ], ], 'newsletter-en' => [ 'lang' => 'en', 'label' => 'Newsletter (EN)', 'subject' => 'Kontakt z wersji angielskiej', 'fields' => [ 'email' => ['label' => 'E-mail', 'type' => 'email', 'required' => true, 'max' => 190], ], ], 'szkolenia' => [ 'lang' => 'pl', 'label' => 'Zgłoszenie - szkolenia i webinaria', 'subject' => 'Zapytanie o szkolenie', 'fields' => [ 'imie' => ['label' => 'Imię i nazwisko', 'type' => 'text', 'required' => true, 'max' => 120], 'firma' => ['label' => 'Przedsiębiorstwo', 'type' => 'text', 'required' => true, 'max' => 160], 'email' => ['label' => 'E-mail', 'type' => 'email', 'required' => true, 'max' => 190], 'telefon' => ['label' => 'Telefon', 'type' => 'text', 'required' => false, 'max' => 40], 'temat' => ['label' => 'Interesujący temat', 'type' => 'text', 'required' => false, 'max' => 160], 'wiadomosc' => ['label' => 'Wiadomość', 'type' => 'textarea', 'required' => false, 'max' => 4000], ], ], 'konsulting' => [ 'lang' => 'pl', 'label' => 'Zgłoszenie - konsulting', 'subject' => 'Prośba o rozmowę wstępną', 'fields' => [ 'imie' => ['label' => 'Imię i nazwisko', 'type' => 'text', 'required' => true, 'max' => 120], 'firma' => ['label' => 'Przedsiębiorstwo', 'type' => 'text', 'required' => true, 'max' => 160], 'email' => ['label' => 'E-mail', 'type' => 'email', 'required' => true, 'max' => 190], 'telefon' => ['label' => 'Telefon', 'type' => 'text', 'required' => false, 'max' => 40], 'zakres' => ['label' => 'Interesujący zakres', 'type' => 'text', 'required' => false, 'max' => 160], 'wiadomosc' => ['label' => 'Opis sytuacji', 'type' => 'textarea', 'required' => false, 'max' => 4000], ], ], ]; } // Treść zgody utrwalana razem ze zgłoszeniem - dowód, na co konkretnie // zgodził się nadawca w dniu wysyłki. function spm_consent_text(string $lang): string { if ($lang === 'en') { return 'I consent to SoftSPM processing my personal data in order to be contacted ' . 'about commercial and marketing matters, including offers of training, ' . 'consulting and software. I may withdraw this consent at any time ' . 'by writing to info@softspm.com.'; } return 'Wyrażam zgodę na przetwarzanie moich danych osobowych przez SoftSPM w celu ' . 'kontaktu handlowego i marketingowego, w tym przedstawienia oferty szkoleń, ' . 'konsultingu i aplikacji. Zgodę mogę wycofać w każdej chwili, pisząc na ' . 'info@softspm.com.'; } // --------------------------------------------------------------------------- // Narzędzia // --------------------------------------------------------------------------- function spm_respond(int $status, array $payload): void { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function spm_fail(int $status, string $code, string $message): void { spm_respond($status, ['ok' => false, 'error' => $code, 'message' => $message]); } function spm_b64u(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } function spm_sign(string $payload, string $secret): string { return spm_b64u(hash_hmac('sha256', $payload, $secret, true)); } /** Token ma postać: rodzaj.formularz.nonce.czas.podpis */ function spm_issue_token(string $kind, string $form, string $nonce, string $secret): string { $payload = $kind . '.' . $form . '.' . $nonce . '.' . time(); return $payload . '.' . spm_sign($payload, $secret); } /** @return array{nonce:string, ts:int}|null */ function spm_read_token(string $token, string $kind, string $form, string $secret): ?array { $parts = explode('.', $token); if (count($parts) !== 5) { return null; } [$gotKind, $gotForm, $nonce, $ts, $sig] = $parts; if ($gotKind !== $kind || $gotForm !== $form || !ctype_digit($ts)) { return null; } if (preg_match('/^[A-Za-z0-9_-]{8,64}$/', $nonce) !== 1) { return null; } $payload = $gotKind . '.' . $gotForm . '.' . $nonce . '.' . $ts; if (!hash_equals(spm_sign($payload, $secret), $sig)) { return null; } return ['nonce' => $nonce, 'ts' => (int) $ts]; } function spm_storage(array $config, string $sub): string { $dir = rtrim($config['storage_dir'], '/\\') . '/' . $sub; if (!is_dir($dir)) { @mkdir($dir, 0770, true); } // Katalog roboczy nie ma prawa być czytany przez przeglądarkę. $guard = rtrim($config['storage_dir'], '/\\') . '/.htaccess'; if (!is_file($guard)) { @file_put_contents($guard, "Require all denied\n\nDeny from all\n\n"); } return $dir; } /** Jednorazowość tokenu: nonce raz zużyty nie przechodzi drugi raz. */ function spm_nonce_used(array $config, string $nonce): bool { $dir = spm_storage($config, 'nonce'); $file = $dir . '/' . hash('sha256', $nonce) . '.used'; if (is_file($file)) { return true; } @file_put_contents($file, (string) time()); // Sprzątanie: co jakiś czas kasujemy wpisy starsze niż czas życia tokenu. if (random_int(1, 25) === 1) { $limit = time() - (int) $config['max_token_age'] - 3600; foreach ((glob($dir . '/*.used') ?: []) as $old) { if (@filemtime($old) < $limit) { @unlink($old); } } } return false; } function spm_client_ip(): string { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; return is_string($ip) ? $ip : '0.0.0.0'; } /** Limit zgłoszeń z jednego adresu IP - godzinowy i dobowy. */ function spm_rate_limited(array $config, string $ip): bool { $dir = spm_storage($config, 'rate'); $file = $dir . '/' . hash('sha256', $ip . '|' . $config['token_secret']) . '.json'; $now = time(); $stamps = []; if (is_file($file)) { $raw = @file_get_contents($file); if (is_string($raw) && $raw !== '') { $decoded = json_decode($raw, true); if (is_array($decoded)) { $stamps = array_values(array_filter($decoded, static fn($t) => is_int($t) && $t > $now - 86400)); } } } $lastHour = count(array_filter($stamps, static fn(int $t) => $t > $now - 3600)); if ($lastHour >= (int) $config['rate_limit']['per_hour'] || count($stamps) >= (int) $config['rate_limit']['per_day']) { return true; } $stamps[] = $now; @file_put_contents($file, json_encode($stamps), LOCK_EX); return false; } /** Zgłoszenie musi pochodzić ze strony SoftSPM, nie z obcego skryptu. */ function spm_origin_ok(array $config): bool { $candidates = []; foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) { if (!empty($_SERVER[$header]) && is_string($_SERVER[$header])) { $host = parse_url($_SERVER[$header], PHP_URL_HOST); if (is_string($host) && $host !== '') { $candidates[] = strtolower($host); } } } if ($candidates === []) { return false; } $allowed = array_map('strtolower', (array) $config['allowed_hosts']); foreach ($candidates as $host) { if (in_array($host, $allowed, true)) { return true; } } return false; } function spm_log(array $config, string $line): void { $dir = spm_storage($config, 'log'); @file_put_contents( $dir . '/' . gmdate('Y-m') . '.log', gmdate('c') . ' ' . $line . "\n", FILE_APPEND | LOCK_EX ); } /** Pojedyncza linia tekstu: bez znaków sterujących, bez łamania wiersza. */ function spm_clean_line(string $value): string { $value = str_replace(["\r", "\n", "\t"], ' ', $value); $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? ''; return trim(preg_replace('/\s{2,}/u', ' ', $value) ?? ''); } function spm_clean_text(string $value): string { $value = str_replace("\r\n", "\n", $value); $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? ''; return trim($value); } // --------------------------------------------------------------------------- // Start obsługi żądania // --------------------------------------------------------------------------- $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); if ($method === 'OPTIONS') { http_response_code(204); exit; } if ($config['token_secret'] === '' || $config['api_key'] === '') { spm_log($config, 'config-missing'); spm_fail(503, 'not_configured', 'Formularz nie jest jeszcze skonfigurowany. Napisz na info@softspm.com.'); } $forms = spm_forms(); // --- GET: wydanie tokenu i potwierdzenie suwaka ---------------------------- if ($method === 'GET') { $action = isset($_GET['action']) && is_string($_GET['action']) ? $_GET['action'] : ''; $form = isset($_GET['form']) && is_string($_GET['form']) ? $_GET['form'] : ''; if (!isset($forms[$form])) { spm_fail(400, 'unknown_form', 'Nieznany formularz.'); } if (!spm_origin_ok($config)) { spm_fail(403, 'bad_origin', 'Żądanie spoza strony SoftSPM.'); } if ($action === 'token') { $nonce = spm_b64u(random_bytes(12)); spm_respond(200, [ 'ok' => true, 'token' => spm_issue_token('t', $form, $nonce, $config['token_secret']), ]); } if ($action === 'confirm') { $token = isset($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : ''; $data = spm_read_token($token, 't', $form, $config['token_secret']); if ($data === null) { spm_fail(400, 'bad_token', 'Token formularza jest nieprawidłowy. Odśwież stronę.'); } if (time() - $data['ts'] > (int) $config['max_token_age']) { spm_fail(400, 'expired', 'Formularz był otwarty zbyt długo. Odśwież stronę.'); } spm_respond(200, [ 'ok' => true, 'proof' => spm_issue_token('c', $form, $data['nonce'], $config['token_secret']), ]); } spm_fail(400, 'bad_action', 'Nieznana operacja.'); } if ($method !== 'POST') { spm_fail(405, 'method_not_allowed', 'Dozwolone są wyłącznie żądania POST.'); } // --- POST: właściwa wysyłka ------------------------------------------------- // Formularz wysłany bez JavaScriptu leci tu jako zwykły POST przeglądarki. // Zamiast JSON-a z błędem pokazujemy stronę, którą da się przeczytać. $contentType = strtolower((string) ($_SERVER['CONTENT_TYPE'] ?? '')); if (strpos($contentType, 'application/json') === false) { http_response_code(400); header('Content-Type: text/html; charset=utf-8'); echo '' . 'Formularz SoftSPM' . '

Formularz wymaga włączonego JavaScriptu

' . '

Napisz do nas bezpośrednio na info@softspm.com. ' . 'Przepraszamy za kłopot.

' . '

Wróć do formularza

' . ''; exit; } $raw = file_get_contents('php://input'); if (!is_string($raw) || trim($raw) === '') { spm_fail(400, 'empty_payload', 'Puste żądanie.'); } $input = json_decode($raw, true); if (!is_array($input)) { spm_fail(400, 'invalid_json', 'Nieprawidłowy format danych.'); } $form = isset($input['form']) && is_string($input['form']) ? $input['form'] : ''; if (!isset($forms[$form])) { spm_fail(400, 'unknown_form', 'Nieznany formularz.'); } $def = $forms[$form]; $lang = $def['lang']; $isEn = $lang === 'en'; if (!spm_origin_ok($config)) { spm_log($config, 'reject origin form=' . $form); spm_fail(403, 'bad_origin', $isEn ? 'Request did not come from the SoftSPM site.' : 'Żądanie spoza strony SoftSPM.'); } // Pułapka na boty: pole ukryte przed człowiekiem. Wypełnione = automat. // Odpowiadamy sukcesem, żeby nie podpowiadać botowi, co go zdradziło. $trap = isset($input['firma_www']) && is_string($input['firma_www']) ? trim($input['firma_www']) : ''; if ($trap !== '') { spm_log($config, 'trap form=' . $form . ' ip=' . hash('sha256', spm_client_ip())); spm_respond(200, ['ok' => true, 'message' => $isEn ? 'Message sent.' : 'Wiadomość wysłana.']); } // Token formularza + potwierdzenie suwakiem. $token = isset($input['token']) && is_string($input['token']) ? $input['token'] : ''; $proof = isset($input['proof']) && is_string($input['proof']) ? $input['proof'] : ''; $tokenData = spm_read_token($token, 't', $form, $config['token_secret']); $proofData = spm_read_token($proof, 'c', $form, $config['token_secret']); if ($tokenData === null || $proofData === null || !hash_equals($tokenData['nonce'], $proofData['nonce'])) { spm_log($config, 'reject token form=' . $form); spm_fail(400, 'bad_token', $isEn ? 'Verification failed. Refresh the page and slide the control again.' : 'Weryfikacja nie powiodła się. Odśwież stronę i przesuń suwak ponownie.'); } $now = time(); if ($now - $tokenData['ts'] > (int) $config['max_token_age'] || $now - $proofData['ts'] > (int) $config['max_proof_age']) { spm_fail(400, 'expired', $isEn ? 'The form has expired. Refresh the page and try again.' : 'Formularz stracił ważność. Odśwież stronę i spróbuj ponownie.'); } $minSlide = (int) $config['min_slide_seconds']; if ($now - $tokenData['ts'] < (int) $config['min_fill_seconds'] || $proofData['ts'] < $tokenData['ts'] || ($minSlide > 0 && $proofData['ts'] - $tokenData['ts'] < $minSlide)) { spm_log($config, 'reject speed form=' . $form); spm_fail(429, 'too_fast', $isEn ? 'That was too quick. Try again in a moment.' : 'Zgłoszenie wysłano zbyt szybko. Spróbuj ponownie za chwilę.'); } if (spm_nonce_used($config, $tokenData['nonce'])) { spm_fail(409, 'already_sent', $isEn ? 'This form has already been sent. Refresh the page to send another message.' : 'To zgłoszenie zostało już wysłane. Odśwież stronę, aby wysłać kolejne.'); } // Zgoda marketingowa - wymagana, sprawdzana także po stronie serwera. $consent = $input['zgoda'] ?? false; if ($consent !== true && $consent !== 'true' && $consent !== 1 && $consent !== '1' && $consent !== 'on') { spm_fail(400, 'consent_required', $isEn ? 'Consent to be contacted is required.' : 'Zgoda na kontakt jest wymagana.'); } if (spm_rate_limited($config, spm_client_ip())) { spm_log($config, 'reject rate form=' . $form . ' ip=' . hash('sha256', spm_client_ip())); spm_fail(429, 'rate_limited', $isEn ? 'Too many messages from this address. Please write to info@softspm.com.' : 'Zbyt wiele zgłoszeń z tego adresu. Napisz na info@softspm.com.'); } // --- Walidacja pól ---------------------------------------------------------- $values = []; $replyTo = ''; foreach ($def['fields'] as $name => $rule) { $value = $input[$name] ?? ''; if (!is_string($value)) { $value = ''; } $value = $rule['type'] === 'textarea' ? spm_clean_text($value) : spm_clean_line($value); if ($value === '') { if (!empty($rule['required'])) { spm_fail(400, 'missing_field', $isEn ? sprintf('Field "%s" is required.', $rule['label']) : sprintf('Pole „%s" jest wymagane.', $rule['label'])); } $values[$name] = ''; continue; } if (mb_strlen($value) > (int) $rule['max']) { spm_fail(400, 'field_too_long', $isEn ? sprintf('Field "%s" is too long.', $rule['label']) : sprintf('Pole „%s" jest za długie.', $rule['label'])); } if ($rule['type'] === 'email') { if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { spm_fail(400, 'invalid_email', $isEn ? 'The e-mail address is not valid.' : 'Adres e-mail jest nieprawidłowy.'); } $replyTo = $value; } $values[$name] = $value; } // --- Budowa wiadomości ------------------------------------------------------ // UWAGA: send_email_api.php sanityzuje treść przed osadzeniem jej we własnym // szablonie HTML (manual, punkt 7). Znaczniki wysłane stąd dotarłyby do skrzynki // jako widoczny tekst "

", więc wysyłamy czysty tekst. // // Nie wiemy, czy szablon po drugiej stronie zamienia znaki nowej linii na
, // dlatego układ jest odporny na oba warianty: każda pozycja to osobna linia // "Etykieta: wartość", która pozostaje czytelna nawet po sklejeniu w akapit. $topic = ''; foreach (['temat', 'zakres'] as $key) { if (!empty($values[$key])) { $topic = $values[$key]; break; } } $subject = trim($config['subject_prefix'] . ' ' . $def['subject']); if ($topic !== '') { $subject .= ' - ' . $topic; } if (mb_strlen($subject) > 250) { $subject = mb_substr($subject, 0, 247) . '...'; } $lines = []; $lines[] = $isEn ? 'New message from softspm.com' : 'Nowe zgłoszenie ze strony softspm.com'; $lines[] = ($isEn ? 'Form: ' : 'Formularz: ') . $def['label']; if ($replyTo !== '') { $lines[] = ($isEn ? 'Reply to: ' : 'Odpowiedz na: ') . $replyTo; } $lines[] = ''; $lines[] = $isEn ? '--- DETAILS ---' : '--- DANE Z FORMULARZA ---'; foreach ($def['fields'] as $name => $rule) { $value = $values[$name] ?? ''; if ($value === '') { continue; } if ($rule['type'] === 'textarea') { // Treść wieloliniowa dostaje własny blok, każdy wiersz z wcięciem, // żeby po sklejeniu akapitu dało się odróżnić ją od pozostałych pól. $lines[] = $rule['label'] . ':'; foreach (explode("\n", $value) as $row) { $lines[] = ' ' . $row; } continue; } $lines[] = $rule['label'] . ': ' . $value; } $lines[] = ''; $lines[] = $isEn ? '--- CONSENT GIVEN ON SUBMISSION ---' : '--- ZGODA UDZIELONA PRZY WYSYŁCE ---'; $lines[] = spm_consent_text($lang); $lines[] = ''; $lines[] = $isEn ? '--- METADATA ---' : '--- METRYKA ---'; $lines[] = ($isEn ? 'Page: ' : 'Strona: ') . (isset($input['page']) && is_string($input['page']) ? spm_clean_line(mb_substr($input['page'], 0, 300)) : '-'); $lines[] = ($isEn ? 'Date (UTC): ' : 'Data (UTC): ') . gmdate('Y-m-d H:i:s'); $lines[] = ($isEn ? 'IP address: ' : 'Adres IP: ') . spm_client_ip(); $message = implode("\n", $lines); // Zostawiamy zapas na szablon po stronie API (limit endpointu to 20000 znaków). if (mb_strlen($message) > 15000) { $message = mb_substr($message, 0, 15000) . "\n[...]"; } // --- Wysyłka przez api.softspm.pl ------------------------------------------ // Adresat pochodzi wyłącznie z konfiguracji serwera. Formularz może mieć // własną skrzynkę (config['recipients']), inaczej idzie na adres domyślny. $recipient = (string) $config['recipient']; if (isset($config['recipients'][$form]) && is_string($config['recipients'][$form]) && $config['recipients'][$form] !== '') { $recipient = $config['recipients'][$form]; } if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) { spm_log($config, 'config-bad-recipient form=' . $form); spm_fail(503, 'not_configured', $isEn ? 'The form is not configured yet. Please write to info@softspm.com.' : 'Formularz nie jest jeszcze skonfigurowany. Napisz na info@softspm.com.'); } $payload = json_encode([ 'to' => $recipient, 'subject' => $subject, 'message' => $message, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($payload === false) { spm_fail(500, 'encode_failed', $isEn ? 'Could not prepare the message.' : 'Nie udało się przygotować wiadomości.'); } $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $config['api_key'], ]; $status = 0; $response = ''; $netError = ''; if (function_exists('curl_init')) { $ch = curl_init($config['api_url']); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_TIMEOUT => (int) $config['timeout'], ]); $body = curl_exec($ch); if ($body === false) { $netError = curl_error($ch); } else { $response = (string) $body; } $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); } else { $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $payload, 'timeout' => (int) $config['timeout'], 'ignore_errors' => true, ], ]); $body = @file_get_contents($config['api_url'], false, $context); if ($body === false) { $netError = 'stream request failed'; } else { $response = (string) $body; foreach ($http_response_header ?? [] as $line) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { $status = (int) $m[1]; } } } } if ($status !== 200) { // Szczegóły zostają w logu serwera, klient dostaje komunikat bez detali. spm_log($config, sprintf( 'send-failed form=%s http=%d err=%s resp=%s', $form, $status, $netError !== '' ? $netError : '-', substr($response, 0, 300) )); spm_fail(502, 'send_failed', $isEn ? 'The message could not be sent. Please write to info@softspm.com.' : 'Nie udało się wysłać wiadomości. Napisz na info@softspm.com.'); } spm_log($config, 'sent form=' . $form . ' ip=' . hash('sha256', spm_client_ip())); spm_respond(200, [ 'ok' => true, 'message' => $isEn ? 'Thank you - your message has been sent. We reply within one working day.' : 'Dziękujemy - wiadomość została wysłana. Odpowiadamy w ciągu jednego dnia roboczego.', ]);