249 lines
7.6 KiB
PHP
249 lines
7.6 KiB
PHP
<?php
|
|
/**
|
|
* Shared mail helpers for Zettai Systems forms.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
const ZS_CONTACT_TO = 'solutions@zettaisystems.com';
|
|
const ZS_CAREERS_TO = 'careers@zettaisystems.com';
|
|
const ZS_FROM_NAME = 'Zettai Systems Website';
|
|
const ZS_FROM_EMAIL = 'noreply@zettaisystems.com';
|
|
const ZS_MAX_RESUME_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
const ZS_RL_MAX = 5;
|
|
const ZS_RL_WINDOW = 900; // 15 minutes
|
|
|
|
function zs_json_response(int $status, array $payload): void
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('X-Content-Type-Options: nosniff');
|
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
function zs_require_post(): void
|
|
{
|
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
|
zs_json_response(405, ['ok' => false, 'error' => 'Method not allowed.']);
|
|
}
|
|
}
|
|
|
|
function zs_request_host(): string
|
|
{
|
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
|
if (!is_string($host) || $host === '') {
|
|
return '';
|
|
}
|
|
return strtolower((string) preg_replace('/:\d+$/', '', $host));
|
|
}
|
|
|
|
function zs_require_csrf(): void
|
|
{
|
|
$host = zs_request_host();
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
if (is_string($origin) && $origin !== '') {
|
|
$originHost = parse_url($origin, PHP_URL_HOST);
|
|
if (!is_string($originHost) || strtolower($originHost) !== $host) {
|
|
zs_json_response(403, [
|
|
'ok' => false,
|
|
'error' => 'Security check failed. Please refresh the page and try again.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$cookie = $_COOKIE['zs_csrf'] ?? '';
|
|
$posted = $_POST['csrf_token'] ?? '';
|
|
if (
|
|
!is_string($cookie) ||
|
|
!is_string($posted) ||
|
|
$cookie === '' ||
|
|
$posted === '' ||
|
|
!hash_equals($cookie, $posted)
|
|
) {
|
|
zs_json_response(403, [
|
|
'ok' => false,
|
|
'error' => 'Security check failed. Please refresh the page and try again.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
function zs_rate_limit(string $bucket): void
|
|
{
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
if (!is_string($ip) || $ip === '') {
|
|
$ip = '0.0.0.0';
|
|
}
|
|
|
|
$dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'zs_form_rl';
|
|
if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$file = $dir . DIRECTORY_SEPARATOR . hash('sha256', $bucket . '|' . $ip);
|
|
$fh = @fopen($file, 'c+');
|
|
if ($fh === false) {
|
|
return;
|
|
}
|
|
|
|
flock($fh, LOCK_EX);
|
|
$raw = stream_get_contents($fh);
|
|
$hits = [];
|
|
if (is_string($raw) && $raw !== '') {
|
|
$decoded = json_decode($raw, true);
|
|
if (is_array($decoded)) {
|
|
$hits = $decoded;
|
|
}
|
|
}
|
|
|
|
$now = time();
|
|
$windowStart = $now - ZS_RL_WINDOW;
|
|
$fresh = [];
|
|
foreach ($hits as $stamp) {
|
|
if (is_int($stamp) && $stamp > $windowStart) {
|
|
$fresh[] = $stamp;
|
|
}
|
|
}
|
|
|
|
if (count($fresh) >= ZS_RL_MAX) {
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
zs_json_response(429, [
|
|
'ok' => false,
|
|
'error' => 'Too many submissions. Please wait a few minutes and try again.',
|
|
]);
|
|
}
|
|
|
|
$fresh[] = $now;
|
|
rewind($fh);
|
|
ftruncate($fh, 0);
|
|
fwrite($fh, json_encode($fresh));
|
|
fflush($fh);
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
}
|
|
|
|
function zs_field(string $key, int $max = 2000): string
|
|
{
|
|
$value = $_POST[$key] ?? '';
|
|
if (!is_string($value)) {
|
|
return '';
|
|
}
|
|
$value = trim(strip_tags($value));
|
|
if (mb_strlen($value) > $max) {
|
|
$value = mb_substr($value, 0, $max);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function zs_is_honeypot_filled(): bool
|
|
{
|
|
$hp = $_POST['website'] ?? $_POST['company_url'] ?? '';
|
|
return is_string($hp) && trim($hp) !== '';
|
|
}
|
|
|
|
function zs_valid_email(string $email): bool
|
|
{
|
|
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
|
|
}
|
|
|
|
function zs_send_mail(string $to, string $subject, string $textBody, string $replyTo = '', ?array $attachment = null): bool
|
|
{
|
|
$boundary = 'zs_' . bin2hex(random_bytes(12));
|
|
$fromHeader = sprintf('%s <%s>', ZS_FROM_NAME, ZS_FROM_EMAIL);
|
|
|
|
$headers = [
|
|
'MIME-Version: 1.0',
|
|
'From: ' . $fromHeader,
|
|
'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000',
|
|
'X-Mailer: ZettaiSystemsForms/1.0',
|
|
'X-Priority: 3',
|
|
];
|
|
|
|
if ($replyTo !== '' && zs_valid_email($replyTo)) {
|
|
$headers[] = 'Reply-To: ' . $replyTo;
|
|
}
|
|
|
|
if ($attachment === null) {
|
|
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
|
|
$body = $textBody;
|
|
} else {
|
|
$headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
|
|
$body = "--{$boundary}\r\n";
|
|
$body .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
$body .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
|
$body .= $textBody . "\r\n\r\n";
|
|
$body .= "--{$boundary}\r\n";
|
|
$body .= 'Content-Type: ' . $attachment['mime'] . '; name="' . $attachment['name'] . "\"\r\n";
|
|
$body .= "Content-Transfer-Encoding: base64\r\n";
|
|
$body .= 'Content-Disposition: attachment; filename="' . $attachment['name'] . "\"\r\n\r\n";
|
|
$body .= chunk_split(base64_encode($attachment['data'])) . "\r\n";
|
|
$body .= "--{$boundary}--";
|
|
}
|
|
|
|
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
|
$headerString = implode("\r\n", $headers);
|
|
$envelope = '-f' . ZS_FROM_EMAIL;
|
|
return @mail($to, $encodedSubject, $body, $headerString, $envelope);
|
|
}
|
|
|
|
function zs_resume_attachment(): ?array
|
|
{
|
|
if (!isset($_FILES['resume']) || !is_array($_FILES['resume'])) {
|
|
return null;
|
|
}
|
|
|
|
$file = $_FILES['resume'];
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
|
return null;
|
|
}
|
|
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
|
zs_json_response(400, ['ok' => false, 'error' => 'Resume upload failed. Please try again.']);
|
|
}
|
|
if (($file['size'] ?? 0) <= 0 || $file['size'] > ZS_MAX_RESUME_BYTES) {
|
|
zs_json_response(400, ['ok' => false, 'error' => 'Resume must be a PDF or Word file under 5 MB.']);
|
|
}
|
|
|
|
$original = (string) ($file['name'] ?? 'resume');
|
|
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
|
$allowed = [
|
|
'pdf' => 'application/pdf',
|
|
'doc' => 'application/msword',
|
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
];
|
|
if (!isset($allowed[$ext])) {
|
|
zs_json_response(400, ['ok' => false, 'error' => 'Resume must be a PDF or Word document (.pdf, .doc, .docx).']);
|
|
}
|
|
|
|
$tmp = (string) ($file['tmp_name'] ?? '');
|
|
if ($tmp === '' || !is_uploaded_file($tmp)) {
|
|
zs_json_response(400, ['ok' => false, 'error' => 'Invalid resume upload.']);
|
|
}
|
|
|
|
if (class_exists('finfo')) {
|
|
$detected = (new finfo(FILEINFO_MIME_TYPE))->file($tmp) ?: '';
|
|
$okMime = [
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/zip', // some hosts report .docx as zip
|
|
];
|
|
if ($detected !== '' && !in_array($detected, $okMime, true)) {
|
|
zs_json_response(400, ['ok' => false, 'error' => 'Resume must be a PDF or Word document (.pdf, .doc, .docx).']);
|
|
}
|
|
}
|
|
|
|
$data = file_get_contents($tmp);
|
|
if ($data === false) {
|
|
zs_json_response(500, ['ok' => false, 'error' => 'Could not read the uploaded resume.']);
|
|
}
|
|
|
|
$safeName = preg_replace('/[^A-Za-z0-9._-]/', '_', $original) ?: ('resume.' . $ext);
|
|
|
|
return [
|
|
'name' => $safeName,
|
|
'mime' => $allowed[$ext],
|
|
'data' => $data,
|
|
];
|
|
}
|