774 lines
28 KiB
PHP
Executable File
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']));
|
|
}
|