Files
MSPE/Public HTML/api/config.php
T

774 lines
28 KiB
PHP
Executable File

<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Firebase\JWT\JWT as FirebaseJWT;
use Firebase\JWT\Key;
/**
* MSPE Website Configuration
*
* Database and application settings
* Reads from .env file for production credentials
*/
// ── Load .env ────────────────────────────────────────────────
function loadEnv($path) {
if (!file_exists($path)) return;
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes
if ((strlen($value) > 1) && (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))) {
$value = substr($value, 1, -1);
}
$_ENV[$key] = $value;
putenv("$key=$value");
}
}
// Try loading .env from project root, then from one level up (outside public_html)
loadEnv(__DIR__ . '/../.env');
loadEnv(dirname(__DIR__, 2) . '/.env');
// ── Helpers to read env values ───────────────────────────────
function env($key, $default = '') {
return $_ENV[$key] ?? getenv($key) ?: $default;
}
// ── Error reporting ──────────────────────────────────────────
$appEnv = env('APP_ENV', 'production');
if ($appEnv === 'production') {
error_reporting(0);
ini_set('display_errors', 0);
} else {
error_reporting(E_ALL);
ini_set('display_errors', 1);
}
// Timezone
date_default_timezone_set('UTC');
// ── Database Configuration ───────────────────────────────────
define('DB_TYPE', env('DB_TYPE', 'file'));
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_NAME', env('DB_NAME', 'mspe_website'));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
// ── Admin credentials ────────────────────────────────────────
define('ADMIN_USER', env('ADMIN_USER', 'admin'));
define('ADMIN_PASS', env('ADMIN_PASS', ''));
// ── JWT Secret ───────────────────────────────────────────────
define('JWT_SECRET', env('JWT_SECRET', 'change-me-in-env-file'));
if (JWT_SECRET === 'change-me-in-env-file' && $appEnv === 'production') {
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Server misconfigured']));
}
// ── Data directory (for JSON files, cache, etc.) ────────────
define('DATA_DIR', __DIR__ . '/../data/');
// ── Upload settings ──────────────────────────────────────────
define('UPLOAD_DIR', __DIR__ . '/../uploads/');
define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); // 5MB
define('ALLOWED_EXTENSIONS', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']);
// ── Site settings ────────────────────────────────────────────
define('SITE_NAME', env('SITE_NAME', 'MSPE'));
define('SITE_URL', env('SITE_URL', 'https://mspe.pro'));
define('ADMIN_EMAIL', env('ADMIN_EMAIL', 'info@mspe.pro'));
// ── SMTP settings (read from .env, used as defaults before admin settings override) ──
define('ENV_SMTP_HOST', env('SMTP_HOST', ''));
define('ENV_SMTP_PORT', env('SMTP_PORT', '465'));
define('ENV_SMTP_USER', env('SMTP_USER', ''));
define('ENV_SMTP_PASS', env('SMTP_PASS', ''));
define('ENV_SMTP_FROM_NAME', env('SMTP_FROM_NAME', 'MSPE'));
define('ENV_SMTP_FROM_EMAIL', env('SMTP_FROM_EMAIL', 'info@mspe.pro'));
// ── CORS Headers ─────────────────────────────────────────────
$allowedOrigins = [SITE_URL, 'https://www.mspe.pro'];
// Allow localhost for local development
if ($appEnv !== 'production') {
$allowedOrigins[] = 'http://localhost:8080';
$allowedOrigins[] = 'http://localhost:8000';
$allowedOrigins[] = 'http://127.0.0.1:8080';
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header('Access-Control-Allow-Origin: ' . $origin);
} else {
header('Access-Control-Allow-Origin: ' . SITE_URL);
}
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
header('Content-Type: application/json; charset=UTF-8');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// ── Security Headers ─────────────────────────────────────────
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 0');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
if ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
}
/**
* MySQL Database Helper
*/
class MySQLDB {
private $pdo;
public function __construct() {
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
}
/** Ensure the table exists (auto-create a generic key-value/json table) */
private function ensureTable($table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$this->pdo->exec("CREATE TABLE IF NOT EXISTS `$safe` (
`id` VARCHAR(64) NOT NULL PRIMARY KEY,
`data` JSON NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
public function getAll($table) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->query("SELECT `data` FROM `$safe` ORDER BY `created_at` ASC");
$rows = $stmt->fetchAll();
return array_map(function($r) { return json_decode($r['data'], true); }, $rows);
}
/**
* Get records with SQL-level pagination (LIMIT/OFFSET).
* Returns ['data' => [...], 'total' => int].
*/
public function getAllPaginated($table, $limit = 50, $offset = 0, $orderDir = 'ASC') {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$dir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$total = (int)$this->pdo->query("SELECT COUNT(*) FROM `{$safe}`")->fetchColumn();
$stmt = $this->pdo->prepare("SELECT `data` FROM `{$safe}` ORDER BY `created_at` {$dir} LIMIT ? OFFSET ?");
$stmt->execute([(int)$limit, (int)$offset]);
$rows = $stmt->fetchAll();
return [
'data' => array_map(function($r) { return json_decode($r['data'], true); }, $rows),
'total' => $total
];
}
public function get($table, $id) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->prepare("SELECT `data` FROM `$safe` WHERE `id` = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? json_decode($row['data'], true) : null;
}
public function insert($table, $data) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$data['id'] = $data['id'] ?? bin2hex(random_bytes(16));
$data['created_at'] = $data['created_at'] ?? date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare("INSERT INTO `$safe` (`id`, `data`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?)");
$stmt->execute([$data['id'], json_encode($data, JSON_UNESCAPED_UNICODE), $data['created_at'], $data['updated_at']]);
return $data;
}
public function update($table, $id, $data) {
$existing = $this->get($table, $id);
if (!$existing) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$data['id'] = $id;
$data['created_at'] = $existing['created_at'];
$data['updated_at'] = date('Y-m-d H:i:s');
$merged = array_merge($existing, $data);
$stmt = $this->pdo->prepare("UPDATE `$safe` SET `data` = ?, `updated_at` = ? WHERE `id` = ?");
$stmt->execute([json_encode($merged, JSON_UNESCAPED_UNICODE), $merged['updated_at'], $id]);
return $merged;
}
public function delete($table, $id) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->prepare("DELETE FROM `$safe` WHERE `id` = ?");
$stmt->execute([$id]);
return true;
}
public function query($table, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (empty($conditions)) {
return $this->getAll($table);
}
// Use MySQL JSON_EXTRACT for server-side filtering when possible
$where = [];
$params = [];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT `data` FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(function($r) { return json_decode($r['data'], true); }, $rows);
}
/** Count rows matching conditions (avoids loading all data into memory) */
public function count($table, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (empty($conditions)) {
return (int)$this->pdo->query("SELECT COUNT(*) FROM `{$safe}`")->fetchColumn();
}
$where = [];
$params = [];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
/** Count rows where a JSON date-like field is between two bounds (inclusive) */
public function countByDateRange($table, $dateField, $fromValue, $toValue, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$safeDateField = preg_replace('/[^a-zA-Z0-9_]/', '', $dateField);
$where = [
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeDateField}')) >= ?",
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeDateField}')) <= ?"
];
$params = [(string)$fromValue, (string)$toValue];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
/** Count rows where a single JSON field matches a comparison operator */
public function countByFieldComparison($table, $field, $operator, $value, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$safeField = preg_replace('/[^a-zA-Z0-9_]/', '', $field);
$allowedOps = ['=', '!=', '>', '>=', '<', '<='];
$op = in_array($operator, $allowedOps, true) ? $operator : '=';
$where = ["JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeField}')) {$op} ?"];
$params = [(string)$value];
foreach ($conditions as $key => $condValue) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$condValue;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
}
/**
* Simple JWT Helper
*/
class JWT {
public static function encode($payload, $ttlSeconds = 86400, $secret = JWT_SECRET) {
$payload['exp'] = time() + max(300, (int)$ttlSeconds);
return FirebaseJWT::encode($payload, $secret, 'HS256');
}
public static function decode($token, $secret = JWT_SECRET) {
try {
$decoded = FirebaseJWT::decode($token, new Key($secret, 'HS256'));
return (array) $decoded;
} catch (Exception $e) {
return null;
}
}
}
/**
* Authentication Check
*/
function checkAuth() {
// Prefer HttpOnly cookie token for admin authentication
$cookieToken = $_COOKIE['mspe_admin_token'] ?? '';
if (is_string($cookieToken) && $cookieToken !== '') {
$payload = JWT::decode($cookieToken);
if ($payload) {
return $payload;
}
}
// Fallback: Authorization header (for API clients)
$authHeader = '';
if (function_exists('getallheaders')) {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
if ($authHeader === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'];
}
if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$payload = JWT::decode($matches[1]);
if ($payload) {
return $payload;
}
}
return null;
}
/**
* Require Authentication
*/
function requireAuth() {
$user = checkAuth();
if (!$user) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit();
}
return $user;
}
/**
* JSON Response Helper
*/
function jsonResponse($data, $code = 200) {
http_response_code($code);
echo json_encode($data);
exit();
}
/**
* Sanitize Input (HTML Context)
* Note: For JavaScript context, use json_encode().
* For URL context, use urlencode().
*/
function sanitize($input) {
if (is_array($input)) {
return array_map('sanitize', $input);
}
// ENT_QUOTES | ENT_SUBSTITUTE handles both single/double quotes and invalid characters
return htmlspecialchars(trim($input ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Sanitize Rich Text (HTML Context)
* Allows only a whitelist of safe formatting tags and strips everything else.
* Removes all event handler attributes (onclick, onerror, etc.) from allowed tags.
*/
function sanitizeRichText($input) {
if (!is_string($input) || trim($input) === '') return '';
// Allow only safe formatting tags
$allowed = '<b><strong><i><em><u><br><p><ul><ol><li><a><span><h3><h4><h5><h6><sub><sup><blockquote>';
$cleaned = strip_tags(trim($input), $allowed);
// Remove any event handler attributes (on*) and dangerous attributes
$cleaned = preg_replace('/\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $cleaned);
// Remove javascript: and data: from href/src attributes
$cleaned = preg_replace('/\b(href|src)\s*=\s*(?:"(?:javascript|data):.*?"|\'(?:javascript|data):.*?\')/i', '$1=""', $cleaned);
// Remove style attributes that could contain expressions
$cleaned = preg_replace('/\s+style\s*=\s*(?:"[^"]*"|\'[^\']*\')/i', '', $cleaned);
return $cleaned;
}
/**
* Get Request Body
*/
function getRequestBody() {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
/**
* Basic CSRF mitigation for public POST endpoints.
* Allows only same-origin browser requests by validating Origin/Referer.
*/
function requireSameOriginRequest() {
$appEnv = env('APP_ENV', 'production');
$allowedOrigins = [
rtrim((string)SITE_URL, '/'),
'https://www.mspe.pro'
];
if ($appEnv !== 'production') {
$allowedOrigins[] = 'http://localhost:8080';
$allowedOrigins[] = 'http://localhost:8000';
$allowedOrigins[] = 'http://127.0.0.1:8080';
}
$origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($origin !== '') {
if (in_array(rtrim($origin, '/'), $allowedOrigins, true)) {
return;
}
jsonResponse(['success' => false, 'message' => 'Forbidden origin'], 403);
}
$referer = trim((string)($_SERVER['HTTP_REFERER'] ?? ''));
if ($referer !== '') {
foreach ($allowedOrigins as $allowedOrigin) {
if (stripos($referer, $allowedOrigin . '/') === 0 || rtrim($referer, '/') === $allowedOrigin) {
return;
}
}
jsonResponse(['success' => false, 'message' => 'Forbidden referer'], 403);
}
// Browser request with neither header is suspicious for public form submissions.
jsonResponse(['success' => false, 'message' => 'CSRF validation failed'], 403);
}
/**
* Read settings as key => value map
*/
function getSettingsMap() {
global $db;
$result = [];
$rows = $db->getAll('settings');
foreach ($rows as $row) {
if (isset($row['key'])) {
$result[$row['key']] = $row['value'] ?? '';
}
}
return $result;
}
/**
* Get a single setting with fallback
*/
function getSetting($key, $default = null) {
$settings = getSettingsMap();
return array_key_exists($key, $settings) ? $settings[$key] : $default;
}
/**
* Send email (mail() or SMTP based on admin settings)
*/
function sendEmail($to, $subject, $htmlBody, $plainBody = '', $replyTo = null) {
// Determine transport: admin setting → auto-detect from .env
$defaultTransport = (ENV_SMTP_HOST !== '') ? 'smtp' : 'mail';
$transport = strtolower((string)getSetting('email_transport', $defaultTransport));
$fromEmail = trim((string)getSetting('smtp_from_email', ENV_SMTP_FROM_EMAIL ?: ADMIN_EMAIL));
if ($fromEmail === '') {
$fromEmail = ADMIN_EMAIL;
}
$fromName = trim((string)getSetting('smtp_from_name', ENV_SMTP_FROM_NAME ?: SITE_NAME));
if ($fromName === '') {
$fromName = SITE_NAME;
}
if ($plainBody === '') {
$plainBody = trim(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $htmlBody)));
}
if ($transport === 'smtp') {
// Admin settings override .env values
$smtpHost = trim((string)getSetting('smtp_host', ENV_SMTP_HOST));
$smtpPort = (int)getSetting('smtp_port', ENV_SMTP_PORT ?: 465);
$smtpEncryption = strtolower((string)getSetting('smtp_encryption', ((int)($smtpPort) === 465 ? 'ssl' : 'tls')));
$smtpUser = trim((string)getSetting('smtp_username', ENV_SMTP_USER));
$smtpPass = (string)getSetting('smtp_password', ENV_SMTP_PASS);
if ($smtpHost !== '' && $smtpPort > 0 && $smtpUser !== '' && $smtpPass !== '') {
return sendEmailViaSmtp([
'host' => $smtpHost,
'port' => $smtpPort,
'encryption' => $smtpEncryption,
'username' => $smtpUser,
'password' => $smtpPass,
'from_email' => $fromEmail,
'from_name' => $fromName,
'to' => $to,
'subject' => $subject,
'html' => $htmlBody,
'plain' => $plainBody,
'reply_to' => $replyTo
]);
}
}
// Fallback to PHP mail()
$boundary = 'mspe_' . md5((string)microtime(true));
$headers = [];
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'From: ' . formatEmailAddress($fromName, $fromEmail);
if ($replyTo) {
$headers[] = 'Reply-To: ' . $replyTo;
}
$headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
$body = "--{$boundary}\r\n";
$body .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
$body .= $plainBody . "\r\n\r\n";
$body .= "--{$boundary}\r\n";
$body .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
$body .= $htmlBody . "\r\n\r\n";
$body .= "--{$boundary}--\r\n";
$ok = @mail($to, $subject, $body, implode("\r\n", $headers));
return [
'success' => $ok,
'message' => $ok ? 'Sent via mail()' : 'mail() failed'
];
}
function sendEmailViaSmtp($payload) {
$host = $payload['host'];
$port = (int)$payload['port'];
$encryption = $payload['encryption'];
$remote = $encryption === 'ssl' ? 'ssl://' . $host : $host;
$socket = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 20, STREAM_CLIENT_CONNECT);
if (!$socket) {
return ['success' => false, 'message' => 'SMTP connect failed: ' . $errstr];
}
stream_set_timeout($socket, 20);
$expect = function($codes) use ($socket) {
$response = '';
while (($line = fgets($socket, 515)) !== false) {
$response .= $line;
if (preg_match('/^\d{3}\s/', $line)) {
break;
}
}
$code = (int)substr($response, 0, 3);
if (!in_array($code, (array)$codes, true)) {
throw new Exception(trim($response));
}
return $response;
};
$send = function($command) use ($socket) {
fwrite($socket, $command . "\r\n");
};
try {
$expect([220]);
$send('EHLO ' . ($_SERVER['SERVER_NAME'] ?? parse_url(SITE_URL, PHP_URL_HOST) ?? 'mspe.pro'));
$expect([250]);
if ($encryption === 'tls') {
$send('STARTTLS');
$expect([220]);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new Exception('Failed to start TLS encryption');
}
$send('EHLO ' . ($_SERVER['SERVER_NAME'] ?? parse_url(SITE_URL, PHP_URL_HOST) ?? 'mspe.pro'));
$expect([250]);
}
$send('AUTH LOGIN');
$expect([334]);
$send(base64_encode($payload['username']));
$expect([334]);
$send(base64_encode($payload['password']));
$expect([235]);
$send('MAIL FROM:<' . $payload['from_email'] . '>');
$expect([250]);
$send('RCPT TO:<' . $payload['to'] . '>');
$expect([250, 251]);
$send('DATA');
$expect([354]);
$boundary = 'mspe_' . md5((string)microtime(true));
$headers = [];
$headers[] = 'From: ' . formatEmailAddress($payload['from_name'], $payload['from_email']);
$headers[] = 'To: ' . $payload['to'];
$headers[] = 'Subject: ' . $payload['subject'];
$headers[] = 'MIME-Version: 1.0';
if (!empty($payload['reply_to'])) {
$headers[] = 'Reply-To: ' . $payload['reply_to'];
}
$headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
$message = implode("\r\n", $headers) . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
$message .= $payload['plain'] . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
$message .= $payload['html'] . "\r\n\r\n";
$message .= "--{$boundary}--\r\n.";
fwrite($socket, $message . "\r\n");
$expect([250]);
$send('QUIT');
fclose($socket);
return ['success' => true, 'message' => 'Sent via SMTP'];
} catch (Exception $e) {
fclose($socket);
return ['success' => false, 'message' => 'SMTP send failed: ' . $e->getMessage()];
}
}
function formatEmailAddress($name, $email) {
$cleanName = str_replace(['"', "\r", "\n"], '', $name);
$cleanEmail = str_replace(["\r", "\n"], '', $email);
return sprintf('"%s" <%s>', $cleanName, $cleanEmail);
}
/**
* Handle File Upload
*/
function handleFileUpload($file, $subdir = '') {
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return ['success' => false, 'message' => 'No file uploaded'];
}
// Check file size
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['success' => false, 'message' => 'File too large'];
}
// Check extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ALLOWED_EXTENSIONS)) {
return ['success' => false, 'message' => 'File type not allowed'];
}
// Validate MIME type from file contents (not just extension)
$allowedMimeByExt = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'webp' => ['image/webp'],
'pdf' => ['application/pdf']
];
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedMime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
$allowedMimes = $allowedMimeByExt[$ext] ?? [];
if (!$detectedMime || !in_array($detectedMime, $allowedMimes, true)) {
return ['success' => false, 'message' => 'Invalid file content type'];
}
}
// Create upload directory
$uploadDir = UPLOAD_DIR . ($subdir ? $subdir . '/' : '');
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Generate unique filename (cryptographically random)
$filename = bin2hex(random_bytes(16)) . '_' . preg_replace('/[^a-zA-Z0-9.]/', '', $file['name']);
$filepath = $uploadDir . $filename;
if (move_uploaded_file($file['tmp_name'], $filepath)) {
return [
'success' => true,
'filename' => $filename,
'path' => 'uploads/' . ($subdir ? $subdir . '/' : '') . $filename
];
}
return ['success' => false, 'message' => 'Failed to save file'];
}
// ── Security Audit Logging ─────────────────────────────────────
function auditLog($event, $details = [], $userId = null) {
$entry = [
'timestamp' => date('c'),
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
'user_id' => $userId,
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
'details' => $details
];
error_log('[MSPE_AUDIT] ' . json_encode($entry, JSON_UNESCAPED_SLASHES));
}
// Initialize database based on DB_TYPE from .env
if (DB_TYPE === 'mysql') {
try {
$db = new MySQLDB();
} catch (Exception $e) {
error_log('MSPE MySQL connection failed: ' . $e->getMessage());
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Database connection failed']));
}
} else {
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Only MySQL is supported in production']));
}