Initial commit: MSPE website - full site with admin panel, API, and public pages
This commit is contained in:
Executable
+10
@@ -0,0 +1,10 @@
|
||||
# Block dev/test/seed scripts in production
|
||||
<FilesMatch "^(db_seed|db_test|email-test)\.php$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
Executable
+391
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Authentication API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$body = getRequestBody();
|
||||
|
||||
switch ($method) {
|
||||
case 'POST':
|
||||
$action = $body['action'] ?? 'login';
|
||||
|
||||
if ($action === 'login') {
|
||||
handleLogin($body);
|
||||
} elseif ($action === 'logout') {
|
||||
handleLogout();
|
||||
} elseif ($action === 'verify') {
|
||||
handleVerify();
|
||||
} elseif ($action === 'request_password_reset') {
|
||||
requestPasswordReset($body);
|
||||
} elseif ($action === 'reset_password') {
|
||||
resetPassword($body);
|
||||
} else {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid action'], 400);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function handleLogin($body) {
|
||||
global $db;
|
||||
|
||||
$username = $body['username'] ?? '';
|
||||
$password = $body['password'] ?? '';
|
||||
$rememberMe = !empty($body['remember']);
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Username and password required'], 400);
|
||||
}
|
||||
|
||||
// ── Rate limiting: max 10 attempts per IP per 15 minutes (DB-backed) ──
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$maxAttempts = 10;
|
||||
$windowSeconds = 900; // 15 minutes
|
||||
if (countRecentFailedLogins($ip, $windowSeconds) >= $maxAttempts) {
|
||||
jsonResponse(['success' => false, 'message' => 'Too many login attempts. Try again later.'], 429);
|
||||
}
|
||||
|
||||
// ── Per-username rate limiting: max 5 attempts per username per 15 minutes ──
|
||||
$maxUsernameAttempts = 5;
|
||||
if (countRecentFailedLoginsByUsername($username, $windowSeconds) >= $maxUsernameAttempts) {
|
||||
jsonResponse(['success' => false, 'message' => 'This account is temporarily locked. Try again later.'], 429);
|
||||
}
|
||||
|
||||
// Check override admin auth record first (if created via reset flow)
|
||||
$adminAuthRows = $db->getAll('admin_auth');
|
||||
$adminAuth = !empty($adminAuthRows) ? $adminAuthRows[0] : null;
|
||||
|
||||
if ($adminAuth && isset($adminAuth['username']) && isset($adminAuth['password_hash'])) {
|
||||
if ($username === $adminAuth['username'] && password_verify($password, $adminAuth['password_hash'])) {
|
||||
loginSuccessResponse('admin', $adminAuth['username'], 'admin', $rememberMe);
|
||||
}
|
||||
} else {
|
||||
// Fallback to config credentials (ADMIN_PASS must be a password hash)
|
||||
if ($username === ADMIN_USER && verifyConfigAdminPassword($password)) {
|
||||
loginSuccessResponse('admin', $username, 'admin', $rememberMe);
|
||||
}
|
||||
}
|
||||
|
||||
// Check against database users
|
||||
$users = $db->query('users', ['username' => $username]);
|
||||
|
||||
if (!empty($users)) {
|
||||
$user = array_values($users)[0];
|
||||
|
||||
if (password_verify($password, $user['password'])) {
|
||||
loginSuccessResponse($user['id'], $user['username'], $user['role'] ?? 'admin', $rememberMe);
|
||||
}
|
||||
}
|
||||
|
||||
recordFailedLogin();
|
||||
auditLog('login_failure', ['username' => $username]);
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
function recordFailedLogin() {
|
||||
global $db;
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$body = getRequestBody();
|
||||
$db->insert('login_attempts', [
|
||||
'ip_address' => $ip,
|
||||
'username' => sanitize($body['username'] ?? ''),
|
||||
'attempted_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
cleanupOldFailedLogins();
|
||||
}
|
||||
|
||||
function countRecentFailedLogins($ip, $windowSeconds) {
|
||||
global $db;
|
||||
|
||||
$cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [
|
||||
'ip_address' => $ip
|
||||
]);
|
||||
}
|
||||
|
||||
function countRecentFailedLoginsByUsername($username, $windowSeconds) {
|
||||
global $db;
|
||||
|
||||
if (trim($username) === '') return 0;
|
||||
|
||||
$cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [
|
||||
'username' => $username
|
||||
]);
|
||||
}
|
||||
|
||||
function cleanupOldFailedLogins() {
|
||||
global $db;
|
||||
|
||||
$attempts = $db->getAll('login_attempts');
|
||||
$cutoff = time() - (24 * 60 * 60);
|
||||
|
||||
foreach ($attempts as $attempt) {
|
||||
$attemptedAt = strtotime((string)($attempt['attempted_at'] ?? '1970-01-01 00:00:00'));
|
||||
if ($attemptedAt < $cutoff && !empty($attempt['id'])) {
|
||||
$db->delete('login_attempts', $attempt['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearAdminAuthCookie();
|
||||
jsonResponse(['success' => true, 'message' => 'Logged out successfully']);
|
||||
}
|
||||
|
||||
function handleVerify() {
|
||||
$user = checkAuth();
|
||||
|
||||
if ($user) {
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'user' => [
|
||||
'id' => $user['user_id'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid token'], 401);
|
||||
}
|
||||
|
||||
function loginSuccessResponse($id, $username, $role, $rememberMe = false) {
|
||||
$ttlSeconds = $rememberMe ? (30 * 24 * 60 * 60) : (12 * 60 * 60);
|
||||
$token = JWT::encode([
|
||||
'user_id' => $id,
|
||||
'username' => $username,
|
||||
'role' => $role
|
||||
], $ttlSeconds);
|
||||
|
||||
setAdminAuthCookie($token, $ttlSeconds);
|
||||
|
||||
auditLog('login_success', ['username' => $username, 'role' => $role, 'remember_me' => $rememberMe], $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Login successful',
|
||||
'user' => [
|
||||
'id' => $id,
|
||||
'username' => $username,
|
||||
'role' => $role
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
function verifyConfigAdminPassword($submittedPassword) {
|
||||
if (!is_string(ADMIN_PASS) || ADMIN_PASS === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$info = password_get_info(ADMIN_PASS);
|
||||
if (!empty($info['algo'])) {
|
||||
return password_verify($submittedPassword, ADMIN_PASS);
|
||||
}
|
||||
|
||||
error_log('MSPE security warning: ADMIN_PASS is not a password hash. Configure a password_hash() value in .env.');
|
||||
return false;
|
||||
}
|
||||
|
||||
function setAdminAuthCookie($token, $ttlSeconds) {
|
||||
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['SERVER_PORT'] ?? '') == 443)
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
|
||||
setcookie('mspe_admin_token', $token, [
|
||||
'expires' => time() + max(300, (int)$ttlSeconds),
|
||||
'path' => '/',
|
||||
'secure' => $isHttps,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict'
|
||||
]);
|
||||
}
|
||||
|
||||
function clearAdminAuthCookie() {
|
||||
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['SERVER_PORT'] ?? '') == 443)
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
|
||||
setcookie('mspe_admin_token', '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => $isHttps,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict'
|
||||
]);
|
||||
}
|
||||
|
||||
function requestPasswordReset($body) {
|
||||
global $db;
|
||||
|
||||
cleanupExpiredPasswordResets();
|
||||
|
||||
$identity = trim((string)($body['identity'] ?? $body['email'] ?? ''));
|
||||
if ($identity === '') {
|
||||
jsonResponse(['success' => false, 'message' => 'Email or username is required'], 400);
|
||||
}
|
||||
|
||||
$matched = findUserForPasswordReset($identity);
|
||||
|
||||
// Always return generic success to avoid username/email enumeration
|
||||
if (!$matched) {
|
||||
jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + (30 * 60));
|
||||
|
||||
$db->insert('password_resets', [
|
||||
'token' => $token,
|
||||
'user_type' => $matched['user_type'],
|
||||
'user_id' => $matched['user_id'],
|
||||
'email' => $matched['email'],
|
||||
'used' => false,
|
||||
'expires_at' => $expiresAt
|
||||
]);
|
||||
|
||||
$resetUrl = trim((string)getSetting('password_reset_url', SITE_URL . '/admin/reset-password.html'));
|
||||
if ($resetUrl === '') {
|
||||
$resetUrl = SITE_URL . '/admin/reset-password.html';
|
||||
}
|
||||
$separator = strpos($resetUrl, '?') === false ? '?' : '&';
|
||||
$resetLink = $resetUrl . $separator . 'token=' . urlencode($token);
|
||||
|
||||
$subject = 'Reset your admin password';
|
||||
$plain = "A password reset was requested for your admin account.\n\n";
|
||||
$plain .= "Reset link (valid for 30 minutes):\n{$resetLink}\n\n";
|
||||
$plain .= "If you did not request this, you can ignore this email.";
|
||||
|
||||
$html = '<h2>Password Reset</h2>'
|
||||
. '<p>A password reset was requested for your admin account.</p>'
|
||||
. '<p><a href="' . htmlspecialchars($resetLink) . '" style="display:inline-block;padding:10px 16px;background:#0ea5e9;color:#fff;text-decoration:none;border-radius:8px;">Reset Password</a></p>'
|
||||
. '<p style="font-size:13px;color:#64748b;">This link is valid for 30 minutes.</p>';
|
||||
|
||||
$send = sendEmail($matched['email'], $subject, $html, $plain);
|
||||
|
||||
if (!$send['success']) {
|
||||
error_log('MSPE password reset email failed: ' . ($send['message'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']);
|
||||
}
|
||||
|
||||
function resetPassword($body) {
|
||||
global $db;
|
||||
|
||||
cleanupExpiredPasswordResets();
|
||||
|
||||
$token = trim((string)($body['token'] ?? ''));
|
||||
$newPassword = (string)($body['new_password'] ?? '');
|
||||
|
||||
if ($token === '' || $newPassword === '') {
|
||||
jsonResponse(['success' => false, 'message' => 'Token and new password are required'], 400);
|
||||
}
|
||||
|
||||
if (strlen($newPassword) < 12) {
|
||||
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
|
||||
}
|
||||
|
||||
$resets = $db->query('password_resets', ['token' => $token]);
|
||||
if (empty($resets)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid or expired reset token'], 400);
|
||||
}
|
||||
|
||||
$reset = array_values($resets)[0];
|
||||
if (!empty($reset['used'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'This reset token has already been used'], 400);
|
||||
}
|
||||
|
||||
if (strtotime($reset['expires_at'] ?? '1970-01-01 00:00:00') < time()) {
|
||||
jsonResponse(['success' => false, 'message' => 'Reset token has expired'], 400);
|
||||
}
|
||||
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
|
||||
if (($reset['user_type'] ?? '') === 'default_admin') {
|
||||
$existingRows = $db->getAll('admin_auth');
|
||||
if (!empty($existingRows)) {
|
||||
$first = $existingRows[0];
|
||||
$db->update('admin_auth', $first['id'], [
|
||||
'username' => ADMIN_USER,
|
||||
'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL),
|
||||
'password_hash' => $hash
|
||||
]);
|
||||
} else {
|
||||
$db->insert('admin_auth', [
|
||||
'username' => ADMIN_USER,
|
||||
'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL),
|
||||
'password_hash' => $hash
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$userId = $reset['user_id'] ?? '';
|
||||
$user = $db->get('users', $userId);
|
||||
if (!$user) {
|
||||
jsonResponse(['success' => false, 'message' => 'User no longer exists'], 400);
|
||||
}
|
||||
$db->update('users', $userId, ['password' => $hash]);
|
||||
}
|
||||
|
||||
$db->update('password_resets', $reset['id'], ['used' => true]);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Password updated successfully']);
|
||||
}
|
||||
|
||||
function findUserForPasswordReset($identity) {
|
||||
global $db;
|
||||
|
||||
// Check DB users by email or username
|
||||
$users = $db->getAll('users');
|
||||
foreach ($users as $user) {
|
||||
if (($user['email'] ?? '') === $identity || ($user['username'] ?? '') === $identity) {
|
||||
return [
|
||||
'user_type' => 'db_user',
|
||||
'user_id' => $user['id'],
|
||||
'email' => $user['email']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback default admin account
|
||||
if ($identity === ADMIN_USER || $identity === ADMIN_EMAIL || $identity === getSetting('admin_email', ADMIN_EMAIL)) {
|
||||
return [
|
||||
'user_type' => 'default_admin',
|
||||
'user_id' => 'admin',
|
||||
'email' => getSetting('admin_email', ADMIN_EMAIL)
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanupExpiredPasswordResets() {
|
||||
global $db;
|
||||
|
||||
$rows = $db->getAll('password_resets');
|
||||
$now = time();
|
||||
$retentionCutoff = $now - (30 * 24 * 60 * 60);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$expiresAt = strtotime((string)($row['expires_at'] ?? '1970-01-01 00:00:00'));
|
||||
$createdAt = strtotime((string)($row['created_at'] ?? '1970-01-01 00:00:00'));
|
||||
$isUsed = !empty($row['used']);
|
||||
|
||||
if (($expiresAt > 0 && $expiresAt < $now) || ($isUsed && $createdAt < $retentionCutoff)) {
|
||||
if (!empty($row['id'])) {
|
||||
$db->delete('password_resets', $row['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+605
@@ -0,0 +1,605 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Calendar/Booking API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getBooking($id);
|
||||
} else {
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === 'availability') {
|
||||
getAvailability();
|
||||
} elseif ($action === 'public-availability') {
|
||||
getPublicAvailability();
|
||||
} elseif ($action === 'blocked-times') {
|
||||
requireAuth();
|
||||
getBlockedTimes();
|
||||
} elseif ($action === 'upcoming') {
|
||||
getUpcomingBookings();
|
||||
} else {
|
||||
getBookings();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === 'create-slot') {
|
||||
requireAuth();
|
||||
createAvailabilitySlot();
|
||||
} elseif ($action === 'block-time') {
|
||||
requireAuth();
|
||||
blockTime();
|
||||
} elseif ($action === 'book') {
|
||||
createBooking(true);
|
||||
} else {
|
||||
requireAuth();
|
||||
createBooking(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Booking ID required'], 400);
|
||||
}
|
||||
updateBooking($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
$type = $_GET['type'] ?? 'booking';
|
||||
if ($type === 'blocked') {
|
||||
deleteBlockedTime($id);
|
||||
} else {
|
||||
deleteBooking($id);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getBookings() {
|
||||
global $db;
|
||||
|
||||
requireAuth();
|
||||
|
||||
$bookings = $db->getAll('bookings');
|
||||
|
||||
$status = $_GET['status'] ?? null;
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
$dateFrom = $_GET['date_from'] ?? null;
|
||||
$dateTo = $_GET['date_to'] ?? null;
|
||||
|
||||
if ($status) {
|
||||
$bookings = array_filter($bookings, function($b) use ($status) {
|
||||
return $b['status'] === $status;
|
||||
});
|
||||
}
|
||||
|
||||
if ($dateFrom) {
|
||||
$bookings = array_filter($bookings, function($b) use ($dateFrom) {
|
||||
return $b['booking_date'] >= $dateFrom;
|
||||
});
|
||||
}
|
||||
|
||||
if ($dateTo) {
|
||||
$bookings = array_filter($bookings, function($b) use ($dateTo) {
|
||||
return $b['booking_date'] <= $dateTo;
|
||||
});
|
||||
}
|
||||
|
||||
usort($bookings, function($a, $b) {
|
||||
return strtotime($a['booking_date'] . ' ' . $a['booking_time']) - strtotime($b['booking_date'] . ' ' . $b['booking_time']);
|
||||
});
|
||||
|
||||
$total = count($bookings);
|
||||
$bookings = array_slice(array_values($bookings), $offset, $limit);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $bookings,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
}
|
||||
|
||||
function getBooking($id) {
|
||||
global $db;
|
||||
|
||||
requireAuth();
|
||||
|
||||
$booking = $db->get('bookings', $id);
|
||||
|
||||
if (!$booking) {
|
||||
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $booking
|
||||
]);
|
||||
}
|
||||
|
||||
function getAvailability() {
|
||||
global $db;
|
||||
|
||||
$slots = $db->getAll('availability_slots');
|
||||
$bookings = $db->getAll('bookings');
|
||||
|
||||
$dateFrom = $_GET['date_from'] ?? date('Y-m-d');
|
||||
$dateTo = $_GET['date_to'] ?? date('Y-m-d', strtotime('+30 days'));
|
||||
|
||||
$slots = array_filter($slots, function($slot) use ($dateFrom, $dateTo) {
|
||||
return $slot['date'] >= $dateFrom && $slot['date'] <= $dateTo && $slot['is_available'];
|
||||
});
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
$slotBookings = array_filter($bookings, function($booking) use ($slot) {
|
||||
return $booking['booking_date'] === $slot['date'] &&
|
||||
$booking['booking_time'] === $slot['time'] &&
|
||||
in_array($booking['status'], ['confirmed', 'pending']);
|
||||
});
|
||||
|
||||
$slot['booked'] = count($slotBookings) > 0;
|
||||
$slot['remaining'] = $slot['capacity'] - count($slotBookings);
|
||||
|
||||
$result[] = $slot;
|
||||
}
|
||||
|
||||
usort($result, function($a, $b) {
|
||||
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
|
||||
});
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public availability - shows only available/not available status
|
||||
* Does NOT expose any booking details, client info, or internal notes
|
||||
* This is specifically for the public calendar
|
||||
*/
|
||||
function getPublicAvailability() {
|
||||
global $db;
|
||||
|
||||
$slots = $db->getAll('availability_slots');
|
||||
$bookings = $db->getAll('bookings');
|
||||
$blockedTimes = $db->getAll('blocked_times');
|
||||
|
||||
$dateFrom = $_GET['date_from'] ?? date('Y-m-d');
|
||||
$dateTo = $_GET['date_to'] ?? date('Y-m-d', strtotime('+60 days'));
|
||||
|
||||
// Filter slots within date range that are marked as available
|
||||
$slots = array_filter($slots, function($slot) use ($dateFrom, $dateTo) {
|
||||
return $slot['date'] >= $dateFrom && $slot['date'] <= $dateTo && $slot['is_available'];
|
||||
});
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
// Count active bookings for this slot
|
||||
$slotBookings = array_filter($bookings, function($booking) use ($slot) {
|
||||
return $booking['booking_date'] === $slot['date'] &&
|
||||
$booking['booking_time'] === $slot['time'] &&
|
||||
in_array($booking['status'], ['confirmed', 'pending']);
|
||||
});
|
||||
|
||||
// Check if time is blocked by admin
|
||||
$isBlocked = false;
|
||||
foreach ($blockedTimes as $blocked) {
|
||||
if ($blocked['date'] === $slot['date'] && $blocked['time'] === $slot['time']) {
|
||||
$isBlocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$bookedCount = count($slotBookings);
|
||||
$isFullyBooked = $bookedCount >= ($slot['capacity'] ?? 1);
|
||||
|
||||
// Only return minimal info - just what the public needs
|
||||
$result[] = [
|
||||
'date' => $slot['date'],
|
||||
'time' => $slot['time'],
|
||||
'available' => !$isFullyBooked && !$isBlocked,
|
||||
// Don't expose: capacity, remaining, notes, booking details, etc.
|
||||
];
|
||||
}
|
||||
|
||||
usort($result, function($a, $b) {
|
||||
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
|
||||
});
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
function getUpcomingBookings() {
|
||||
global $db;
|
||||
|
||||
requireAuth();
|
||||
|
||||
$bookings = $db->getAll('bookings');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$upcoming = array_filter($bookings, function($b) use ($today) {
|
||||
return $b['booking_date'] >= $today && in_array($b['status'], ['confirmed', 'pending']);
|
||||
});
|
||||
|
||||
usort($upcoming, function($a, $b) {
|
||||
return strtotime($a['booking_date'] . ' ' . $a['booking_time']) - strtotime($b['booking_date'] . ' ' . $b['booking_time']);
|
||||
});
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => array_slice(array_values($upcoming), 0, 10)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a time slot (admin only)
|
||||
* This makes a specific date/time unavailable without creating a booking
|
||||
*/
|
||||
function blockTime() {
|
||||
global $db;
|
||||
|
||||
$data = getRequestBody();
|
||||
|
||||
if (empty($data['date']) || empty($data['time'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Date and time are required'], 400);
|
||||
}
|
||||
|
||||
$blockData = [
|
||||
'date' => sanitize($data['date']),
|
||||
'time' => sanitize($data['time']),
|
||||
'reason' => sanitize($data['reason'] ?? ''),
|
||||
'blocked_by' => 'admin',
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$blocked = $db->insert('blocked_times', $blockData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Time blocked successfully',
|
||||
'data' => $blocked
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocked times (admin only)
|
||||
*/
|
||||
function getBlockedTimes() {
|
||||
global $db;
|
||||
|
||||
$blockedTimes = $db->getAll('blocked_times');
|
||||
|
||||
// Sort by date and time
|
||||
usort($blockedTimes, function($a, $b) {
|
||||
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
|
||||
});
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $blockedTimes
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a blocked time (admin only)
|
||||
*/
|
||||
function deleteBlockedTime($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('blocked_times', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Blocked time not found'], 404);
|
||||
}
|
||||
|
||||
$db->delete('blocked_times', $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Blocked time removed successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
function createAvailabilitySlot() {
|
||||
global $db;
|
||||
|
||||
$data = getRequestBody();
|
||||
|
||||
if (empty($data['date']) || empty($data['time'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Date and time are required'], 400);
|
||||
}
|
||||
|
||||
$slotData = [
|
||||
'date' => sanitize($data['date']),
|
||||
'time' => sanitize($data['time']),
|
||||
'capacity' => (int)($data['capacity'] ?? 1),
|
||||
'is_available' => true,
|
||||
'notes' => sanitize($data['notes'] ?? '')
|
||||
];
|
||||
|
||||
$slot = $db->insert('availability_slots', $slotData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Availability slot created',
|
||||
'data' => $slot
|
||||
], 201);
|
||||
}
|
||||
|
||||
function createBooking($isPublicRequest = false) {
|
||||
global $db;
|
||||
|
||||
if ($isPublicRequest) {
|
||||
requireSameOriginRequest();
|
||||
|
||||
// Rate limiting: max 5 booking requests per IP per hour
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$recentCount = $db->countByDateRange('bookings', 'created_at', $oneHourAgo, $now, [
|
||||
'ip_address' => $ip
|
||||
]);
|
||||
if ($recentCount >= 5) {
|
||||
jsonResponse(['success' => false, 'message' => 'Too many booking requests. Please try again later.'], 429);
|
||||
}
|
||||
}
|
||||
|
||||
$data = getRequestBody();
|
||||
|
||||
$required = ['first_name', 'last_name', 'email', 'booking_date', 'booking_time'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($data[$field])) {
|
||||
jsonResponse(['success' => false, 'message' => ucfirst(str_replace('_', ' ', $field)) . ' is required'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
|
||||
}
|
||||
|
||||
$bookingDate = date('Y-m-d', strtotime($data['booking_date']));
|
||||
$today = date('Y-m-d');
|
||||
|
||||
if ($bookingDate < $today) {
|
||||
jsonResponse(['success' => false, 'message' => 'Cannot book appointments in the past'], 400);
|
||||
}
|
||||
|
||||
$slots = $db->query('availability_slots', [
|
||||
'date' => $bookingDate,
|
||||
'time' => $data['booking_time']
|
||||
]);
|
||||
|
||||
if (empty($slots) || !$slots[array_key_first($slots)]['is_available']) {
|
||||
jsonResponse(['success' => false, 'message' => 'This time slot is not available'], 400);
|
||||
}
|
||||
|
||||
$slot = array_values($slots)[0];
|
||||
$bookings = $db->query('bookings', [
|
||||
'booking_date' => $bookingDate,
|
||||
'booking_time' => $data['booking_time']
|
||||
]);
|
||||
|
||||
$activeBookings = array_filter($bookings, function($b) {
|
||||
return in_array($b['status'], ['confirmed', 'pending']);
|
||||
});
|
||||
|
||||
if (count($activeBookings) >= $slot['capacity']) {
|
||||
jsonResponse(['success' => false, 'message' => 'This time slot is fully booked'], 400);
|
||||
}
|
||||
|
||||
$bookingData = [
|
||||
'first_name' => sanitize($data['first_name']),
|
||||
'last_name' => sanitize($data['last_name']),
|
||||
'email' => sanitize($data['email']),
|
||||
'phone' => sanitize($data['phone'] ?? ''),
|
||||
'company' => sanitize($data['company'] ?? ''),
|
||||
'service_interest' => sanitize($data['service_interest'] ?? ''),
|
||||
'booking_date' => $bookingDate,
|
||||
'booking_time' => sanitize($data['booking_time']),
|
||||
'duration' => (int)($data['duration'] ?? 60),
|
||||
'message' => sanitize($data['message'] ?? ''),
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? ''
|
||||
];
|
||||
|
||||
$booking = $db->insert('bookings', $bookingData);
|
||||
|
||||
sendBookingConfirmationEmail($bookingData);
|
||||
|
||||
sendAdminBookingNotification($bookingData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Booking request submitted successfully. We will confirm your appointment shortly.',
|
||||
'data' => [
|
||||
'id' => $booking['id'],
|
||||
'date' => $booking['booking_date'],
|
||||
'time' => $booking['booking_time'],
|
||||
'status' => 'pending'
|
||||
]
|
||||
], 201);
|
||||
}
|
||||
|
||||
function updateBooking($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('bookings', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
|
||||
}
|
||||
|
||||
$data = getRequestBody();
|
||||
$allowed = ['status', 'notes'];
|
||||
|
||||
$updateData = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['status']) && $data['status'] === 'confirmed' && $existing['status'] !== 'confirmed') {
|
||||
sendBookingConfirmedEmail($existing);
|
||||
}
|
||||
|
||||
if (!empty($data['status']) && $data['status'] === 'cancelled' && $existing['status'] !== 'cancelled') {
|
||||
sendBookingCancelledEmail($existing);
|
||||
}
|
||||
|
||||
$booking = $db->update('bookings', $id, $updateData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Booking updated successfully',
|
||||
'data' => $booking
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteBooking($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('bookings', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
|
||||
}
|
||||
|
||||
$db->delete('bookings', $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Booking deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
function sendBookingConfirmationEmail($booking) {
|
||||
$to = $booking['email'];
|
||||
$subject = 'Booking Confirmation - ' . SITE_NAME;
|
||||
|
||||
$date = date('F j, Y', strtotime($booking['booking_date']));
|
||||
$time = date('g:i A', strtotime($booking['booking_time']));
|
||||
|
||||
$plain = "Thank you for booking a consultation with MSPE!\n\n";
|
||||
$plain .= "Booking Details:\n";
|
||||
$plain .= "Date: {$date}\n";
|
||||
$plain .= "Time: {$time}\n";
|
||||
$plain .= "Duration: {$booking['duration']} minutes\n\n";
|
||||
$plain .= "Your booking is currently pending confirmation. We will send you a confirmation email shortly.\n\n";
|
||||
$plain .= "If you need to reschedule or cancel, please contact us at " . ADMIN_EMAIL . "\n\n";
|
||||
$plain .= "Best regards,\nThe MSPE Team";
|
||||
|
||||
$html = '<h2>Booking Received</h2>'
|
||||
. '<p>Thank you for booking a consultation with MSPE!</p>'
|
||||
. '<p><strong>Date:</strong> ' . $date . '</p>'
|
||||
. '<p><strong>Time:</strong> ' . $time . '</p>'
|
||||
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
|
||||
. '<p>Your booking is currently pending confirmation.</p>';
|
||||
|
||||
$result = sendEmail($to, $subject, $html, $plain);
|
||||
if (!$result['success']) {
|
||||
error_log('MSPE booking confirmation email failed: ' . ($result['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
function sendBookingConfirmedEmail($booking) {
|
||||
$to = $booking['email'];
|
||||
$subject = 'Booking Confirmed - ' . SITE_NAME;
|
||||
|
||||
$date = date('F j, Y', strtotime($booking['booking_date']));
|
||||
$time = date('g:i A', strtotime($booking['booking_time']));
|
||||
|
||||
$plain = "Your consultation with MSPE has been confirmed!\n\n";
|
||||
$plain .= "Booking Details:\n";
|
||||
$plain .= "Date: {$date}\n";
|
||||
$plain .= "Time: {$time}\n";
|
||||
$plain .= "Duration: {$booking['duration']} minutes\n\n";
|
||||
$plain .= "We look forward to speaking with you!\n\n";
|
||||
$plain .= "If you need to reschedule, please contact us at " . ADMIN_EMAIL . "\n\n";
|
||||
$plain .= "Best regards,\nThe MSPE Team";
|
||||
|
||||
$html = '<h2>Booking Confirmed!</h2>'
|
||||
. '<p>Your consultation with MSPE has been confirmed.</p>'
|
||||
. '<p><strong>Date:</strong> ' . $date . '</p>'
|
||||
. '<p><strong>Time:</strong> ' . $time . '</p>'
|
||||
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
|
||||
. '<p>We look forward to speaking with you!</p>';
|
||||
|
||||
$result = sendEmail($to, $subject, $html, $plain);
|
||||
if (!$result['success']) {
|
||||
error_log('MSPE booking confirmed email failed: ' . ($result['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
function sendBookingCancelledEmail($booking) {
|
||||
$to = $booking['email'];
|
||||
$subject = 'Booking Cancelled - ' . SITE_NAME;
|
||||
|
||||
$plain = "Your consultation with MSPE has been cancelled.\n\n";
|
||||
$plain .= "We apologize for any inconvenience. If you would like to reschedule, please contact us at " . ADMIN_EMAIL . "\n\n";
|
||||
$plain .= "Best regards,\nThe MSPE Team";
|
||||
|
||||
$html = '<h2>Booking Cancelled</h2>'
|
||||
. '<p>Your consultation with MSPE has been cancelled.</p>'
|
||||
. '<p>If you would like to reschedule, please contact us at ' . ADMIN_EMAIL . '</p>';
|
||||
|
||||
$result = sendEmail($to, $subject, $html, $plain);
|
||||
if (!$result['success']) {
|
||||
error_log('MSPE booking cancelled email failed: ' . ($result['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
function sendAdminBookingNotification($booking) {
|
||||
$to = getSetting('admin_email', ADMIN_EMAIL);
|
||||
$subject = 'New Booking Request - ' . SITE_NAME;
|
||||
|
||||
$plain = "A new booking request has been submitted.\n\n";
|
||||
$plain .= "Client Details:\n";
|
||||
$plain .= "Name: {$booking['first_name']} {$booking['last_name']}\n";
|
||||
$plain .= "Email: {$booking['email']}\n";
|
||||
$plain .= "Phone: {$booking['phone']}\n";
|
||||
$plain .= "Company: {$booking['company']}\n\n";
|
||||
$plain .= "Booking Details:\n";
|
||||
$plain .= "Date: {$booking['booking_date']}\n";
|
||||
$plain .= "Time: {$booking['booking_time']}\n";
|
||||
$plain .= "Duration: {$booking['duration']} minutes\n";
|
||||
$plain .= "Service Interest: {$booking['service_interest']}\n";
|
||||
$plain .= "Message: {$booking['message']}\n";
|
||||
|
||||
$html = '<h2>New Booking Request</h2>'
|
||||
. '<p><strong>Name:</strong> ' . htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']) . '</p>'
|
||||
. '<p><strong>Email:</strong> ' . htmlspecialchars($booking['email']) . '</p>'
|
||||
. '<p><strong>Phone:</strong> ' . htmlspecialchars($booking['phone']) . '</p>'
|
||||
. '<p><strong>Company:</strong> ' . htmlspecialchars($booking['company']) . '</p>'
|
||||
. '<p><strong>Date:</strong> ' . $booking['booking_date'] . '</p>'
|
||||
. '<p><strong>Time:</strong> ' . $booking['booking_time'] . '</p>'
|
||||
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
|
||||
. '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($booking['message'])) . '</p>';
|
||||
|
||||
$result = sendEmail($to, $subject, $html, $plain, $booking['email']);
|
||||
if (!$result['success']) {
|
||||
error_log('MSPE admin booking notification failed: ' . ($result['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
Executable
+773
@@ -0,0 +1,773 @@
|
||||
<?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']));
|
||||
}
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Contact Form API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requireAuth();
|
||||
if ($id) {
|
||||
getMessage($id);
|
||||
} else {
|
||||
getMessages();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
submitMessage();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Message ID required'], 400);
|
||||
}
|
||||
updateMessage($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Message ID required'], 400);
|
||||
}
|
||||
deleteMessage($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getMessages() {
|
||||
global $db;
|
||||
|
||||
$messages = $db->getAll('messages');
|
||||
|
||||
// Apply filters
|
||||
$status = $_GET['status'] ?? null;
|
||||
$limit = (int)($_GET['limit'] ?? 20);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
if ($status) {
|
||||
$messages = array_filter($messages, function($m) use ($status) {
|
||||
return $m['status'] === $status;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
usort($messages, function($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
$total = count($messages);
|
||||
$unread = count(array_filter($messages, function($m) {
|
||||
return ($m['status'] ?? 'unread') === 'unread';
|
||||
}));
|
||||
|
||||
$messages = array_slice(array_values($messages), $offset, $limit);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $messages,
|
||||
'total' => $total,
|
||||
'unread' => $unread,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
}
|
||||
|
||||
function getMessage($id) {
|
||||
global $db;
|
||||
|
||||
$message = $db->get('messages', $id);
|
||||
|
||||
if (!$message) {
|
||||
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (($message['status'] ?? 'unread') === 'unread') {
|
||||
$db->update('messages', $id, ['status' => 'read']);
|
||||
$message['status'] = 'read';
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
function submitMessage() {
|
||||
global $db;
|
||||
|
||||
requireSameOriginRequest();
|
||||
|
||||
// Rate limiting: max 5 submissions per IP per hour
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$recentCount = $db->countByDateRange('messages', 'created_at', $oneHourAgo, $now, [
|
||||
'ip_address' => $ip
|
||||
]);
|
||||
|
||||
if ($recentCount >= 5) {
|
||||
jsonResponse(['success' => false, 'message' => 'Too many submissions. Please try again later.'], 429);
|
||||
}
|
||||
|
||||
$data = getRequestBody();
|
||||
|
||||
// ── Cloudflare Turnstile verification (active only when TURNSTILE_SECRET_KEY is set) ──
|
||||
$turnstileSecret = env('TURNSTILE_SECRET_KEY', '');
|
||||
if ($turnstileSecret !== '') {
|
||||
$turnstileToken = $data['cf-turnstile-response'] ?? '';
|
||||
if (empty($turnstileToken)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Human verification is required.'], 400);
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'secret' => $turnstileSecret,
|
||||
'response' => $turnstileToken,
|
||||
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
|
||||
])
|
||||
]);
|
||||
$tvResult = json_decode(curl_exec($ch), true);
|
||||
curl_close($ch);
|
||||
if (!($tvResult['success'] ?? false)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Human verification failed. Please try again.'], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
$required = ['first_name', 'last_name', 'email', 'message'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($data[$field])) {
|
||||
jsonResponse(['success' => false, 'message' => ucfirst(str_replace('_', ' ', $field)) . ' is required'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
|
||||
}
|
||||
|
||||
// Honeypot check (if implemented in form)
|
||||
if (!empty($data['website'])) {
|
||||
// Likely a bot
|
||||
jsonResponse(['success' => true, 'message' => 'Message sent successfully']);
|
||||
}
|
||||
|
||||
// Prepare message data
|
||||
$messageData = [
|
||||
'first_name' => sanitize($data['first_name']),
|
||||
'last_name' => sanitize($data['last_name']),
|
||||
'email' => sanitize($data['email']),
|
||||
'phone' => sanitize($data['phone'] ?? ''),
|
||||
'company' => sanitize($data['company'] ?? ''),
|
||||
'service' => sanitize($data['service'] ?? ''),
|
||||
'budget' => sanitize($data['budget'] ?? ''),
|
||||
'message' => sanitize($data['message']),
|
||||
'newsletter' => $data['newsletter'] ?? false,
|
||||
'status' => 'unread',
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? ''
|
||||
];
|
||||
|
||||
$message = $db->insert('messages', $messageData);
|
||||
|
||||
// Send email notification (optional)
|
||||
sendNotificationEmail($messageData);
|
||||
|
||||
// Handle newsletter subscription
|
||||
if (!empty($data['newsletter'])) {
|
||||
subscribeNewsletter($data['email'], $data['first_name'] . ' ' . $data['last_name']);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Thank you for your message! We\'ll be in touch soon.'
|
||||
], 201);
|
||||
}
|
||||
|
||||
function updateMessage($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('messages', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
|
||||
}
|
||||
|
||||
$data = getRequestBody();
|
||||
$allowed = ['status', 'notes'];
|
||||
|
||||
$updateData = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$message = $db->update('messages', $id, $updateData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Message updated successfully',
|
||||
'data' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteMessage($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('messages', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
|
||||
}
|
||||
|
||||
$db->delete('messages', $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Message deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
function sendNotificationEmail($data) {
|
||||
$to = getSetting('admin_email', getSetting('contact_email', ADMIN_EMAIL));
|
||||
$subject = 'New Contact Form Submission - ' . SITE_NAME;
|
||||
|
||||
$plain = "You have received a new message from your website contact form.\n\n";
|
||||
$plain .= "Name: {$data['first_name']} {$data['last_name']}\n";
|
||||
$plain .= "Email: {$data['email']}\n";
|
||||
$plain .= "Phone: {$data['phone']}\n";
|
||||
$plain .= "Company: {$data['company']}\n";
|
||||
$plain .= "Service Interest: {$data['service']}\n";
|
||||
$plain .= "Budget: {$data['budget']}\n\n";
|
||||
$plain .= "Message:\n{$data['message']}\n";
|
||||
|
||||
$html = '<h2>New Contact Form Submission</h2>'
|
||||
. '<p><strong>Name:</strong> ' . htmlspecialchars($data['first_name'] . ' ' . $data['last_name']) . '</p>'
|
||||
. '<p><strong>Email:</strong> ' . htmlspecialchars($data['email']) . '</p>'
|
||||
. '<p><strong>Phone:</strong> ' . htmlspecialchars($data['phone']) . '</p>'
|
||||
. '<p><strong>Company:</strong> ' . htmlspecialchars($data['company']) . '</p>'
|
||||
. '<p><strong>Service Interest:</strong> ' . htmlspecialchars($data['service']) . '</p>'
|
||||
. '<p><strong>Budget:</strong> ' . htmlspecialchars($data['budget']) . '</p>'
|
||||
. '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($data['message'])) . '</p>';
|
||||
|
||||
$result = sendEmail($to, $subject, $html, $plain, $data['email']);
|
||||
|
||||
if (!$result['success']) {
|
||||
error_log('MSPE contact email failed: ' . ($result['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeNewsletter($email, $name) {
|
||||
global $db;
|
||||
|
||||
// Check if already subscribed
|
||||
$existing = $db->query('subscribers', ['email' => $email]);
|
||||
if (!empty($existing)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->insert('subscribers', [
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Media API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
// Require auth for all media operations
|
||||
requireAuth();
|
||||
|
||||
// CSRF protection for write operations
|
||||
if ($method !== 'GET') {
|
||||
requireSameOriginRequest();
|
||||
}
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
getMedia();
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'delete') {
|
||||
deleteMediaBatch();
|
||||
} else {
|
||||
uploadMedia();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
deleteMedia($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getMedia() {
|
||||
global $db;
|
||||
// For a file-based system without a dedicated media DB table,
|
||||
// we can scan the uploads directory.
|
||||
// However, keeping a record in JSON is better for metadata.
|
||||
// Let's assume we have a 'media' table.
|
||||
|
||||
$media = $db->getAll('media');
|
||||
|
||||
// Sync with filesystem?
|
||||
// For simplicity, let's just return DB records.
|
||||
// If empty, one could scan dir, but we'll rely on upload recording.
|
||||
|
||||
$folder = $_GET['folder'] ?? null;
|
||||
$type = $_GET['type'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
|
||||
if ($folder) {
|
||||
$media = array_filter($media, function($m) use ($folder) {
|
||||
return ($m['folder'] ?? '') === $folder;
|
||||
});
|
||||
}
|
||||
|
||||
if ($type) {
|
||||
$media = array_filter($media, function($m) use ($type) {
|
||||
return strpos($m['type'], $type) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$media = array_filter($media, function($m) use ($search) {
|
||||
return stripos($m['name'], $search) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort recent first
|
||||
usort($media, function($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
jsonResponse(['success' => true, 'data' => array_values($media)]);
|
||||
}
|
||||
|
||||
function uploadMedia() {
|
||||
global $db;
|
||||
|
||||
if (empty($_FILES)) {
|
||||
jsonResponse(['success' => false, 'message' => 'No files uploaded'], 400);
|
||||
}
|
||||
|
||||
$folder = $_POST['folder'] ?? '';
|
||||
$uploaded = [];
|
||||
|
||||
foreach ($_FILES as $key => $file) {
|
||||
// Handle array uploads (multiple files)
|
||||
if (is_array($file['name'])) {
|
||||
foreach ($file['name'] as $idx => $name) {
|
||||
$singleFile = [
|
||||
'name' => $name,
|
||||
'type' => $file['type'][$idx],
|
||||
'tmp_name' => $file['tmp_name'][$idx],
|
||||
'error' => $file['error'][$idx],
|
||||
'size' => $file['size'][$idx]
|
||||
];
|
||||
$result = processUpload($singleFile, $folder);
|
||||
if ($result) $uploaded[] = $result;
|
||||
}
|
||||
} else {
|
||||
$result = processUpload($file, $folder);
|
||||
if ($result) $uploaded[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $uploaded]);
|
||||
}
|
||||
|
||||
function processUpload($file, $folder) {
|
||||
global $db;
|
||||
$res = handleFileUpload($file, $folder);
|
||||
|
||||
if ($res['success']) {
|
||||
$mediaItem = [
|
||||
'name' => $file['name'],
|
||||
'path' => $res['path'], // Relative path
|
||||
'full_url' => SITE_URL . '/' . $res['path'],
|
||||
'type' => $file['type'],
|
||||
'size' => $file['size'],
|
||||
'folder' => $folder,
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
return $db->insert('media', $mediaItem);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteMedia($id) {
|
||||
deleteMediaInternal($id);
|
||||
jsonResponse(['success' => true, 'message' => 'File deleted']);
|
||||
}
|
||||
|
||||
function deleteMediaInternal($id) {
|
||||
global $db;
|
||||
$item = $db->get('media', $id);
|
||||
|
||||
if ($item) {
|
||||
// Delete physical file
|
||||
$filepath = __DIR__ . '/../' . $item['path'];
|
||||
if (file_exists($filepath)) {
|
||||
unlink($filepath);
|
||||
}
|
||||
$db->delete('media', $id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deleteMediaBatch() {
|
||||
$ids = getRequestBody()['ids'] ?? [];
|
||||
$deleted = 0;
|
||||
foreach ($ids as $id) {
|
||||
if (deleteMediaInternal($id)) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
jsonResponse(['success' => true, 'message' => "{$deleted} file(s) deleted"]);
|
||||
}
|
||||
Executable
+273
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE News/Articles API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getArticle($id);
|
||||
} else {
|
||||
getArticles();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
if (!empty($_POST['id'])) {
|
||||
updateArticle($_POST['id']);
|
||||
} else {
|
||||
createArticle();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article ID required'], 400);
|
||||
}
|
||||
updateArticle($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article ID required'], 400);
|
||||
}
|
||||
deleteArticle($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getArticles() {
|
||||
global $db;
|
||||
|
||||
$articles = $db->getAll('news');
|
||||
|
||||
// Apply filters
|
||||
$category = $_GET['category'] ?? null;
|
||||
$status = $_GET['status'] ?? null;
|
||||
$limit = (int)($_GET['limit'] ?? 10);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
if ($category) {
|
||||
$articles = array_filter($articles, function($a) use ($category) {
|
||||
return $a['category'] === $category;
|
||||
});
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$articles = array_filter($articles, function($a) use ($status) {
|
||||
return $a['status'] === $status;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
usort($articles, function($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
// For public API, only show published articles
|
||||
if (!checkAuth()) {
|
||||
$articles = array_filter($articles, function($a) {
|
||||
return ($a['status'] ?? 'draft') === 'published';
|
||||
});
|
||||
}
|
||||
|
||||
$total = count($articles);
|
||||
$articles = array_slice(array_values($articles), $offset, $limit);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $articles,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
}
|
||||
|
||||
function getArticle($id) {
|
||||
global $db;
|
||||
|
||||
$article = $db->get('news', $id);
|
||||
|
||||
if (!$article) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
|
||||
}
|
||||
|
||||
// Check if published or user is authenticated
|
||||
if (($article['status'] ?? 'draft') !== 'published' && !checkAuth()) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $article
|
||||
]);
|
||||
}
|
||||
|
||||
function createArticle() {
|
||||
global $db;
|
||||
|
||||
// Handle multipart form data or JSON
|
||||
if (!empty($_FILES)) {
|
||||
$data = $_POST;
|
||||
} else {
|
||||
$data = getRequestBody();
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (empty($data['title'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Title is required'], 400);
|
||||
}
|
||||
|
||||
// Sanitize text fields (allow safe HTML in content)
|
||||
$data['title'] = sanitize($data['title']);
|
||||
$data['excerpt'] = sanitize($data['excerpt'] ?? '');
|
||||
$data['category'] = sanitize($data['category'] ?? '');
|
||||
$data['author'] = sanitize($data['author'] ?? 'Admin');
|
||||
// Sanitize rich content: strip dangerous tags/attributes while allowing formatting
|
||||
if (!empty($data['content'])) {
|
||||
$data['content'] = sanitizeRichText($data['content']);
|
||||
}
|
||||
|
||||
// Handle image upload
|
||||
if (!empty($_FILES['featured_image'])) {
|
||||
$upload = handleFileUpload($_FILES['featured_image'], 'news');
|
||||
if ($upload['success']) {
|
||||
$data['featured_image'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug if not provided
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = generateSlug($data['title']);
|
||||
} else {
|
||||
$data['slug'] = generateSlug($data['slug']);
|
||||
}
|
||||
|
||||
$data['slug'] = ensureUniqueNewsSlug($data['slug']);
|
||||
|
||||
// Set defaults
|
||||
$data['status'] = $data['status'] ?? 'draft';
|
||||
$data['author'] = $data['author'] ?? 'Admin';
|
||||
$data['views'] = 0;
|
||||
|
||||
$article = $db->insert('news', $data);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Article created successfully',
|
||||
'data' => $article
|
||||
], 201);
|
||||
}
|
||||
|
||||
function updateArticle($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('news', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
|
||||
}
|
||||
|
||||
// Handle multipart form data or JSON
|
||||
if (!empty($_FILES)) {
|
||||
$data = $_POST;
|
||||
} else {
|
||||
$data = getRequestBody();
|
||||
}
|
||||
|
||||
// Handle image upload
|
||||
if (!empty($_FILES['featured_image'])) {
|
||||
$upload = handleFileUpload($_FILES['featured_image'], 'news');
|
||||
if ($upload['success']) {
|
||||
$data['featured_image'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Update slug if title changed
|
||||
if (!empty($data['title']) && empty($data['slug'])) {
|
||||
$data['slug'] = generateSlug($data['title']);
|
||||
} elseif (!empty($data['slug'])) {
|
||||
$data['slug'] = generateSlug($data['slug']);
|
||||
}
|
||||
|
||||
if (!empty($data['slug'])) {
|
||||
$data['slug'] = ensureUniqueNewsSlug($data['slug'], $id);
|
||||
}
|
||||
|
||||
// Sanitize text fields
|
||||
if (!empty($data['title'])) $data['title'] = sanitize($data['title']);
|
||||
if (!empty($data['excerpt'])) $data['excerpt'] = sanitize($data['excerpt']);
|
||||
if (!empty($data['category'])) $data['category'] = sanitize($data['category']);
|
||||
if (!empty($data['author'])) $data['author'] = sanitize($data['author']);
|
||||
// Sanitize rich content: strip dangerous tags/attributes while allowing formatting
|
||||
if (!empty($data['content'])) {
|
||||
$data['content'] = sanitizeRichText($data['content']);
|
||||
}
|
||||
|
||||
$article = $db->update('news', $id, $data);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Article updated successfully',
|
||||
'data' => $article
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteArticle($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('news', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
|
||||
}
|
||||
|
||||
$db->delete('news', $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Article deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
function generateSlug($title) {
|
||||
$slug = strtolower($title);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
|
||||
$slug = trim($slug, '-');
|
||||
return $slug !== '' ? $slug : 'article';
|
||||
}
|
||||
|
||||
function ensureUniqueNewsSlug($baseSlug, $excludeId = null) {
|
||||
global $db;
|
||||
|
||||
$articles = $db->getAll('news');
|
||||
$used = [];
|
||||
foreach ($articles as $article) {
|
||||
if (!empty($excludeId) && ($article['id'] ?? null) === $excludeId) {
|
||||
continue;
|
||||
}
|
||||
$slug = (string)($article['slug'] ?? '');
|
||||
if ($slug !== '') {
|
||||
$used[$slug] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($used[$baseSlug])) {
|
||||
return $baseSlug;
|
||||
}
|
||||
|
||||
$i = 2;
|
||||
while (isset($used[$baseSlug . '-' . $i])) {
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $baseSlug . '-' . $i;
|
||||
}
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Pages API
|
||||
* Stores metadata, SEO fields, and section content for static pages.
|
||||
* Also manages global sections (header, footer, CTA, cookie banner).
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
$type = $_GET['type'] ?? null; // 'global' for global sections
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($type === 'global') {
|
||||
getGlobalSections();
|
||||
} elseif ($id) {
|
||||
getPage($id);
|
||||
} else {
|
||||
getPages();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
if ($type === 'global') {
|
||||
saveGlobalSection();
|
||||
} else {
|
||||
savePage();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// ── Page defaults ────────────────────────────────────────────
|
||||
function getDefaultPages() {
|
||||
return [
|
||||
[
|
||||
'slug' => 'home',
|
||||
'title' => 'Home Page',
|
||||
'icon' => 'fa-home',
|
||||
'url' => '/index.html',
|
||||
'description' => 'Main landing page with hero, services, and CTA sections',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'MSPE | Architects of Digital Resilience',
|
||||
'meta_description' => 'Next-generation cybersecurity, cloud innovation, and technology consulting.',
|
||||
'sections' => [
|
||||
['key' => 'hero', 'label' => 'Hero', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
|
||||
['name' => 'cta_text', 'type' => 'text', 'label' => 'CTA Button Text', 'value' => ''],
|
||||
['name' => 'cta_link', 'type' => 'text', 'label' => 'CTA Button Link', 'value' => ''],
|
||||
]],
|
||||
['key' => 'services', 'label' => 'Services', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Section Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Section Subheading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'stats', 'label' => 'Stats', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Section Heading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'cta', 'label' => 'CTA', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'CTA Heading', 'value' => ''],
|
||||
['name' => 'text', 'type' => 'textarea', 'label' => 'CTA Text', 'value' => ''],
|
||||
['name' => 'button_text', 'type' => 'text', 'label' => 'Button Text', 'value' => ''],
|
||||
['name' => 'button_link', 'type' => 'text', 'label' => 'Button Link', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'about',
|
||||
'title' => 'About Us',
|
||||
'icon' => 'fa-building',
|
||||
'url' => '/about.html',
|
||||
'description' => 'Company story, mission, values, and team information',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'About Us - MSPE',
|
||||
'meta_description' => 'Learn about MSPE — our story, mission, values, and the team behind your digital resilience.',
|
||||
'sections' => [
|
||||
['key' => 'story', 'label' => 'Story', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
|
||||
]],
|
||||
['key' => 'mission', 'label' => 'Mission', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
|
||||
]],
|
||||
['key' => 'values', 'label' => 'Values', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'team', 'label' => 'Team', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'services',
|
||||
'title' => 'Services',
|
||||
'icon' => 'fa-concierge-bell',
|
||||
'url' => '/services.html',
|
||||
'description' => 'Detailed service offerings and pricing plans',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'Services - MSPE',
|
||||
'meta_description' => 'Explore our cybersecurity, cloud, and IT support services.',
|
||||
'sections' => [
|
||||
['key' => 'it_support', 'label' => 'IT Support', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
|
||||
]],
|
||||
['key' => 'security', 'label' => 'Security', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
|
||||
]],
|
||||
['key' => 'cloud', 'label' => 'Cloud', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
|
||||
]],
|
||||
['key' => 'pricing', 'label' => 'Pricing', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'portfolio',
|
||||
'title' => 'Portfolio',
|
||||
'icon' => 'fa-briefcase',
|
||||
'url' => '/portfolio.html',
|
||||
'description' => 'Showcase of completed projects and case studies',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'Portfolio - MSPE',
|
||||
'meta_description' => 'See our completed projects, case studies, and client success stories.',
|
||||
'sections' => [
|
||||
['key' => 'projects', 'label' => 'Projects', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'case_studies', 'label' => 'Case Studies', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'clients', 'label' => 'Clients', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'news',
|
||||
'title' => 'News & Events',
|
||||
'icon' => 'fa-newspaper',
|
||||
'url' => '/news.html',
|
||||
'description' => 'Company news, blog posts, and upcoming events',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'News & Events - MSPE',
|
||||
'meta_description' => 'Stay updated with the latest MSPE news, tech insights, and events.',
|
||||
'sections' => [
|
||||
['key' => 'news_list', 'label' => 'News List', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'newsletter', 'label' => 'Newsletter', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'contact',
|
||||
'title' => 'Contact',
|
||||
'icon' => 'fa-envelope',
|
||||
'url' => '/contact.html',
|
||||
'description' => 'Contact form, location, and FAQ section',
|
||||
'status' => 'published',
|
||||
'meta_title' => 'Contact Us - MSPE',
|
||||
'meta_description' => 'Get in touch with MSPE for cybersecurity, cloud, and IT services.',
|
||||
'sections' => [
|
||||
['key' => 'form', 'label' => 'Form', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
|
||||
]],
|
||||
['key' => 'info_cards', 'label' => 'Info Cards', 'fields' => [
|
||||
['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'value' => ''],
|
||||
['name' => 'phone', 'type' => 'text', 'label' => 'Phone', 'value' => ''],
|
||||
['name' => 'email', 'type' => 'text', 'label' => 'Email', 'value' => ''],
|
||||
['name' => 'hours', 'type' => 'text', 'label' => 'Business Hours', 'value' => ''],
|
||||
]],
|
||||
['key' => 'faq', 'label' => 'FAQ', 'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function getDefaultGlobalSections() {
|
||||
return [
|
||||
[
|
||||
'slug' => 'header',
|
||||
'label' => 'Header / Navigation',
|
||||
'icon' => 'fa-bars',
|
||||
'description' => 'Logo, menu items, CTA button',
|
||||
'fields' => [
|
||||
['name' => 'cta_text', 'type' => 'text', 'label' => 'CTA Button Text', 'value' => ''],
|
||||
['name' => 'cta_link', 'type' => 'text', 'label' => 'CTA Button Link', 'value' => ''],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'footer',
|
||||
'label' => 'Footer',
|
||||
'icon' => 'fa-shoe-prints',
|
||||
'description' => 'Links, contact info, social media',
|
||||
'fields' => [
|
||||
['name' => 'tagline', 'type' => 'textarea', 'label' => 'Footer Tagline', 'value' => ''],
|
||||
['name' => 'copyright', 'type' => 'text', 'label' => 'Copyright Text', 'value' => ''],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'cta',
|
||||
'label' => 'CTA Section',
|
||||
'icon' => 'fa-bullhorn',
|
||||
'description' => 'Call-to-action banner',
|
||||
'fields' => [
|
||||
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
|
||||
['name' => 'text', 'type' => 'textarea', 'label' => 'Text', 'value' => ''],
|
||||
['name' => 'button_text', 'type' => 'text', 'label' => 'Button Text', 'value' => ''],
|
||||
['name' => 'button_link', 'type' => 'text', 'label' => 'Button Link', 'value' => ''],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'cookie',
|
||||
'label' => 'Cookie Banner',
|
||||
'icon' => 'fa-cookie',
|
||||
'description' => 'GDPR compliance notice',
|
||||
'fields' => [
|
||||
['name' => 'message', 'type' => 'textarea', 'label' => 'Banner Message', 'value' => ''],
|
||||
['name' => 'button_text', 'type' => 'text', 'label' => 'Accept Button Text', 'value' => ''],
|
||||
['name' => 'link_text', 'type' => 'text', 'label' => 'Policy Link Text', 'value' => ''],
|
||||
['name' => 'link_url', 'type' => 'text', 'label' => 'Policy URL', 'value' => ''],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────
|
||||
|
||||
function getPages() {
|
||||
global $db;
|
||||
$storedPages = $db->getAll('pages');
|
||||
$storedMap = [];
|
||||
foreach ($storedPages as $p) {
|
||||
$slug = $p['slug'] ?? '';
|
||||
if ($slug) $storedMap[$slug] = $p;
|
||||
}
|
||||
|
||||
$pages = getDefaultPages();
|
||||
foreach ($pages as &$page) {
|
||||
if (isset($storedMap[$page['slug']])) {
|
||||
$stored = $storedMap[$page['slug']];
|
||||
// Merge saved values into section fields
|
||||
if (!empty($stored['meta_title'])) $page['meta_title'] = $stored['meta_title'];
|
||||
if (!empty($stored['meta_description'])) $page['meta_description'] = $stored['meta_description'];
|
||||
if (isset($stored['status'])) $page['status'] = $stored['status'];
|
||||
if (!empty($stored['sections_data'])) {
|
||||
$savedData = $stored['sections_data']; // key => { fieldName => value }
|
||||
foreach ($page['sections'] as &$section) {
|
||||
if (isset($savedData[$section['key']])) {
|
||||
foreach ($section['fields'] as &$field) {
|
||||
if (isset($savedData[$section['key']][$field['name']])) {
|
||||
$field['value'] = $savedData[$section['key']][$field['name']];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($stored['updated_at'])) $page['updated_at'] = $stored['updated_at'];
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $pages]);
|
||||
}
|
||||
|
||||
function getPage($id) {
|
||||
global $db;
|
||||
$pages = getDefaultPages();
|
||||
$default = null;
|
||||
foreach ($pages as $p) {
|
||||
if ($p['slug'] === $id) { $default = $p; break; }
|
||||
}
|
||||
if (!$default) {
|
||||
jsonResponse(['success' => false, 'message' => 'Page not found'], 404);
|
||||
}
|
||||
|
||||
$stored = $db->query('pages', ['slug' => $id]);
|
||||
if (!empty($stored)) {
|
||||
$stored = array_values($stored)[0];
|
||||
if (!empty($stored['meta_title'])) $default['meta_title'] = $stored['meta_title'];
|
||||
if (!empty($stored['meta_description'])) $default['meta_description'] = $stored['meta_description'];
|
||||
if (isset($stored['status'])) $default['status'] = $stored['status'];
|
||||
if (!empty($stored['sections_data'])) {
|
||||
$savedData = $stored['sections_data'];
|
||||
foreach ($default['sections'] as &$section) {
|
||||
if (isset($savedData[$section['key']])) {
|
||||
foreach ($section['fields'] as &$field) {
|
||||
if (isset($savedData[$section['key']][$field['name']])) {
|
||||
$field['value'] = $savedData[$section['key']][$field['name']];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($stored['updated_at'])) $default['updated_at'] = $stored['updated_at'];
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $default]);
|
||||
}
|
||||
|
||||
function savePage() {
|
||||
global $db;
|
||||
$data = getRequestBody();
|
||||
|
||||
if (empty($data['slug'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Page slug required'], 400);
|
||||
}
|
||||
|
||||
$slug = sanitize($data['slug']);
|
||||
$record = [
|
||||
'slug' => $slug,
|
||||
'meta_title' => sanitize($data['meta_title'] ?? ''),
|
||||
'meta_description' => sanitize($data['meta_description'] ?? ''),
|
||||
'status' => in_array($data['status'] ?? '', ['published', 'draft']) ? $data['status'] : 'published',
|
||||
'sections_data' => $data['sections_data'] ?? [],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$existing = $db->query('pages', ['slug' => $slug]);
|
||||
if (!empty($existing)) {
|
||||
$id = array_values($existing)[0]['id'];
|
||||
$db->update('pages', $id, $record);
|
||||
} else {
|
||||
$db->insert('pages', $record);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Page saved successfully']);
|
||||
}
|
||||
|
||||
// ── Global sections ──────────────────────────────────────────
|
||||
|
||||
function getGlobalSections() {
|
||||
global $db;
|
||||
$stored = $db->query('pages', ['slug' => '__global__']);
|
||||
$savedData = [];
|
||||
if (!empty($stored)) {
|
||||
$savedData = array_values($stored)[0]['sections_data'] ?? [];
|
||||
}
|
||||
|
||||
$globals = getDefaultGlobalSections();
|
||||
foreach ($globals as &$section) {
|
||||
if (isset($savedData[$section['slug']])) {
|
||||
foreach ($section['fields'] as &$field) {
|
||||
if (isset($savedData[$section['slug']][$field['name']])) {
|
||||
$field['value'] = $savedData[$section['slug']][$field['name']];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $globals]);
|
||||
}
|
||||
|
||||
function saveGlobalSection() {
|
||||
global $db;
|
||||
$data = getRequestBody();
|
||||
|
||||
if (empty($data['section_slug'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Section slug required'], 400);
|
||||
}
|
||||
|
||||
$sectionSlug = sanitize($data['section_slug']);
|
||||
$fieldValues = $data['fields'] ?? [];
|
||||
|
||||
// Load existing global record
|
||||
$existing = $db->query('pages', ['slug' => '__global__']);
|
||||
$record = [];
|
||||
if (!empty($existing)) {
|
||||
$record = array_values($existing)[0];
|
||||
}
|
||||
|
||||
$sectionsData = $record['sections_data'] ?? [];
|
||||
$sectionsData[$sectionSlug] = $fieldValues;
|
||||
|
||||
$saveData = [
|
||||
'slug' => '__global__',
|
||||
'sections_data' => $sectionsData,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if (!empty($existing)) {
|
||||
$id = array_values($existing)[0]['id'];
|
||||
$db->update('pages', $id, $saveData);
|
||||
} else {
|
||||
$db->insert('pages', $saveData);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Global section saved']);
|
||||
}
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Portfolio API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getProject($id);
|
||||
} else {
|
||||
getProjects();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
if (!empty($_POST['id'])) {
|
||||
updateProject($_POST['id']);
|
||||
} else {
|
||||
createProject();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Project ID required'], 400);
|
||||
}
|
||||
updateProject($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Project ID required'], 400);
|
||||
}
|
||||
deleteProject($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getProjects() {
|
||||
global $db;
|
||||
|
||||
$projects = $db->getAll('portfolio');
|
||||
|
||||
// Apply filters
|
||||
$category = $_GET['category'] ?? null;
|
||||
$featured = $_GET['featured'] ?? null;
|
||||
$limit = (int)($_GET['limit'] ?? 20);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
if ($category) {
|
||||
$projects = array_filter($projects, function($p) use ($category) {
|
||||
return $p['category'] === $category;
|
||||
});
|
||||
}
|
||||
|
||||
if ($featured !== null) {
|
||||
$projects = array_filter($projects, function($p) use ($featured) {
|
||||
return ($p['featured'] ?? false) == ($featured === 'true' || $featured === '1');
|
||||
});
|
||||
}
|
||||
|
||||
// Public view: only published/active projects
|
||||
if (!checkAuth()) {
|
||||
$projects = array_filter($projects, function($p) {
|
||||
return ($p['status'] ?? 'published') === 'published';
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by order or date
|
||||
usort($projects, function($a, $b) {
|
||||
$orderA = $a['order'] ?? 999;
|
||||
$orderB = $b['order'] ?? 999;
|
||||
if ($orderA !== $orderB) {
|
||||
return $orderA - $orderB;
|
||||
}
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
$total = count($projects);
|
||||
$projects = array_slice(array_values($projects), $offset, $limit);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $projects,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
}
|
||||
|
||||
function getProject($id) {
|
||||
global $db;
|
||||
|
||||
$project = $db->get('portfolio', $id);
|
||||
|
||||
if (!$project) {
|
||||
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $project
|
||||
]);
|
||||
}
|
||||
|
||||
function createProject() {
|
||||
global $db;
|
||||
|
||||
// Handle multipart form data or JSON
|
||||
if (!empty($_FILES)) {
|
||||
$data = $_POST;
|
||||
} else {
|
||||
$data = getRequestBody();
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (empty($data['title'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Title is required'], 400);
|
||||
}
|
||||
|
||||
// Sanitize text fields (allow HTML in description)
|
||||
$data['title'] = sanitize($data['title']);
|
||||
$data['category'] = sanitize($data['category'] ?? '');
|
||||
$data['client'] = sanitize($data['client'] ?? '');
|
||||
$data['technologies'] = sanitize($data['technologies'] ?? '');
|
||||
// Sanitize description: allow only safe formatting tags
|
||||
$data['description'] = sanitizeRichText($data['description'] ?? '');
|
||||
|
||||
// Handle image upload
|
||||
if (!empty($_FILES['image'])) {
|
||||
$upload = handleFileUpload($_FILES['image'], 'portfolio');
|
||||
if ($upload['success']) {
|
||||
$data['image'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse results JSON (JS sends as a serialised string)
|
||||
$data['results'] = json_decode($data['results'] ?? '[]', true) ?? [];
|
||||
|
||||
// Gallery: merge existing-keep list + newly uploaded files
|
||||
$gallery = json_decode($_POST['gallery_keep'] ?? '[]', true) ?? [];
|
||||
if (!empty($_FILES['gallery_images']['tmp_name'])) {
|
||||
foreach ($_FILES['gallery_images']['tmp_name'] as $key => $tmpName) {
|
||||
if ($_FILES['gallery_images']['error'][$key] !== UPLOAD_ERR_OK) continue;
|
||||
$file = [
|
||||
'name' => $_FILES['gallery_images']['name'][$key],
|
||||
'type' => $_FILES['gallery_images']['type'][$key],
|
||||
'tmp_name' => $tmpName,
|
||||
'error' => $_FILES['gallery_images']['error'][$key],
|
||||
'size' => $_FILES['gallery_images']['size'][$key],
|
||||
];
|
||||
$upload = handleFileUpload($file, 'portfolio');
|
||||
if ($upload['success']) {
|
||||
$gallery[] = $upload['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['gallery'] = $gallery;
|
||||
|
||||
// Set defaults
|
||||
$data['featured'] = $data['featured'] ?? false;
|
||||
$data['order'] = (int)($data['order'] ?? 0);
|
||||
$data['status'] = $data['status'] ?? 'published';
|
||||
|
||||
$project = $db->insert('portfolio', $data);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Project created successfully',
|
||||
'data' => $project
|
||||
], 201);
|
||||
}
|
||||
|
||||
function updateProject($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('portfolio', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
|
||||
}
|
||||
|
||||
// Handle multipart form data or JSON
|
||||
if (!empty($_FILES)) {
|
||||
$data = $_POST;
|
||||
} else {
|
||||
$data = getRequestBody();
|
||||
}
|
||||
|
||||
// Handle image upload
|
||||
if (!empty($_FILES['image'])) {
|
||||
$upload = handleFileUpload($_FILES['image'], 'portfolio');
|
||||
if ($upload['success']) {
|
||||
$data['image'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize editable text fields
|
||||
if (isset($data['title'])) $data['title'] = sanitize($data['title']);
|
||||
if (isset($data['category'])) $data['category'] = sanitize($data['category']);
|
||||
if (isset($data['client'])) $data['client'] = sanitize($data['client']);
|
||||
if (isset($data['technologies'])) $data['technologies'] = sanitize($data['technologies']);
|
||||
if (isset($data['description'])) $data['description'] = sanitizeRichText($data['description']);
|
||||
|
||||
// Parse results JSON
|
||||
if (isset($data['results'])) {
|
||||
$data['results'] = json_decode($data['results'], true) ?? $existing['results'] ?? [];
|
||||
}
|
||||
|
||||
// Gallery: merge existing-keep list + newly uploaded files
|
||||
if (array_key_exists('gallery_keep', $_POST)) {
|
||||
$gallery = json_decode($_POST['gallery_keep'], true) ?? [];
|
||||
if (!empty($_FILES['gallery_images']['tmp_name'])) {
|
||||
foreach ($_FILES['gallery_images']['tmp_name'] as $key => $tmpName) {
|
||||
if ($_FILES['gallery_images']['error'][$key] !== UPLOAD_ERR_OK) continue;
|
||||
$file = [
|
||||
'name' => $_FILES['gallery_images']['name'][$key],
|
||||
'type' => $_FILES['gallery_images']['type'][$key],
|
||||
'tmp_name' => $tmpName,
|
||||
'error' => $_FILES['gallery_images']['error'][$key],
|
||||
'size' => $_FILES['gallery_images']['size'][$key],
|
||||
];
|
||||
$upload = handleFileUpload($file, 'portfolio');
|
||||
if ($upload['success']) {
|
||||
$gallery[] = $upload['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['gallery'] = $gallery;
|
||||
}
|
||||
|
||||
$project = $db->update('portfolio', $id, $data);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Project updated successfully',
|
||||
'data' => $project
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteProject($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('portfolio', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
|
||||
}
|
||||
|
||||
$db->delete('portfolio', $id);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Project deleted successfully'
|
||||
]);
|
||||
}
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* Public Site Settings API (safe subset only)
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$all = getSettingsMap();
|
||||
|
||||
$allowedKeys = [
|
||||
'site_name',
|
||||
'site_tagline',
|
||||
'site_description',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'contact_address',
|
||||
'business_hours',
|
||||
'support_hours',
|
||||
'maps_url',
|
||||
'social_facebook',
|
||||
'social_linkedin',
|
||||
'social_twitter',
|
||||
'social_instagram',
|
||||
'social_youtube',
|
||||
'social_github',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'accent_color'
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($allowedKeys as $key) {
|
||||
if (isset($all[$key]) && $all[$key] !== '') {
|
||||
$data[$key] = $all[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($data['contact_email']) || $data['contact_email'] === '') {
|
||||
$data['contact_email'] = ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $data]);
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Services API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getService($id);
|
||||
} else {
|
||||
getServices();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
$postData = !empty($_POST) ? $_POST : getRequestBody();
|
||||
if (!empty($postData['id'])) {
|
||||
updateService($postData['id']);
|
||||
} else {
|
||||
createService();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Service ID required'], 400);
|
||||
}
|
||||
updateService($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
deleteService($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getServices() {
|
||||
global $db;
|
||||
$services = $db->getAll('services');
|
||||
|
||||
// Sort by order
|
||||
usort($services, function($a, $b) {
|
||||
return ($a['order'] ?? 99) - ($b['order'] ?? 99);
|
||||
});
|
||||
|
||||
// Public view: only active
|
||||
if (!checkAuth()) {
|
||||
$services = array_filter($services, function($s) {
|
||||
return ($s['active'] ?? true) === true;
|
||||
});
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => array_values($services)]);
|
||||
}
|
||||
|
||||
function getService($id) {
|
||||
global $db;
|
||||
$service = $db->get('services', $id);
|
||||
if (!$service) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
jsonResponse(['success' => true, 'data' => $service]);
|
||||
}
|
||||
|
||||
function createService() {
|
||||
global $db;
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (empty($data['name'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Service name is required'], 400);
|
||||
}
|
||||
|
||||
// Sanitize text fields
|
||||
$data['name'] = sanitize($data['name']);
|
||||
$data['icon'] = sanitize($data['icon'] ?? '');
|
||||
$data['features'] = sanitize($data['features'] ?? '');
|
||||
$data['price'] = sanitize($data['price'] ?? '');
|
||||
// Sanitize description: allow only safe formatting tags (no <script>, <iframe>, event handlers etc.)
|
||||
$data['description'] = sanitizeRichText($data['description'] ?? '');
|
||||
|
||||
$data['active'] = isset($data['active']) ? filter_var($data['active'], FILTER_VALIDATE_BOOLEAN) : true;
|
||||
$data['order'] = (int)($data['order'] ?? 99);
|
||||
|
||||
$service = $db->insert('services', $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Service created', 'data' => $service]);
|
||||
}
|
||||
|
||||
function updateService($id) {
|
||||
global $db;
|
||||
$existing = $db->get('services', $id);
|
||||
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
// Sanitize editable text fields
|
||||
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
|
||||
if (isset($data['icon'])) $data['icon'] = sanitize($data['icon']);
|
||||
if (isset($data['features'])) $data['features'] = sanitize($data['features']);
|
||||
if (isset($data['price'])) $data['price'] = sanitize($data['price']);
|
||||
if (isset($data['description'])) $data['description'] = sanitizeRichText($data['description']);
|
||||
$data['active'] = isset($data['active']) ? filter_var($data['active'], FILTER_VALIDATE_BOOLEAN) : ($existing['active'] ?? true);
|
||||
|
||||
$service = $db->update('services', $id, $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Service updated', 'data' => $service]);
|
||||
}
|
||||
|
||||
function deleteService($id) {
|
||||
global $db;
|
||||
$existing = $db->get('services', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Service not found'], 404);
|
||||
}
|
||||
$db->delete('services', $id);
|
||||
jsonResponse(['success' => true, 'message' => 'Service deleted']);
|
||||
}
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Settings API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Require authentication for all settings operations
|
||||
requireAuth();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
getSettings();
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
saveSettings();
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
global $db;
|
||||
|
||||
// Check if settings file exists, if not return defaults
|
||||
$settings = $db->getAll('settings');
|
||||
$flatSettings = [];
|
||||
|
||||
// Convert from array of objects to key-value pairs if needed
|
||||
// or just return as is if stored as key-value object
|
||||
if (empty($settings)) {
|
||||
// Defaults
|
||||
$flatSettings = [
|
||||
'site_name' => 'MSPE',
|
||||
'site_tagline' => 'Architects of Digital Resilience',
|
||||
'site_description' => 'MSPE transforms complexity into competitive advantage.',
|
||||
'contact_email' => 'info@mspe.pro',
|
||||
'contact_phone' => '+961 78 782 023',
|
||||
'contact_address' => '',
|
||||
'business_hours' => 'Mon - Fri: 9AM - 6PM',
|
||||
'support_hours' => 'Mon - Fri: 9AM - 6PM (On-call by arrangement)',
|
||||
'admin_email' => 'info@mspe.pro',
|
||||
'email_transport' => 'mail',
|
||||
'smtp_from_name' => 'MSPE',
|
||||
'smtp_from_email' => 'info@mspe.pro',
|
||||
'smtp_host' => '',
|
||||
'smtp_port' => '587',
|
||||
'smtp_encryption' => 'tls',
|
||||
'smtp_username' => '',
|
||||
'smtp_password' => '',
|
||||
'password_reset_url' => SITE_URL . '/admin/reset-password.html',
|
||||
'primary_color' => '#0ea5e9',
|
||||
'secondary_color' => '#0d9488'
|
||||
];
|
||||
} else {
|
||||
// Assuming settings are stored as a single object in index 0 or key-value pairs
|
||||
// Let's assume key-value for simplicity in this file-based DB
|
||||
// But FileDB::getAll returns an indexed array of items.
|
||||
// So we'll store settings as: [ {key: 'site_name', value: 'MSPE'}, ... ]
|
||||
foreach ($settings as $setting) {
|
||||
if (isset($setting['key']) && isset($setting['value'])) {
|
||||
$flatSettings[$setting['key']] = $setting['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $flatSettings]);
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
global $db;
|
||||
requireSameOriginRequest();
|
||||
$data = getRequestBody();
|
||||
|
||||
if (empty($data)) {
|
||||
jsonResponse(['success' => false, 'message' => 'No data provided'], 400);
|
||||
}
|
||||
|
||||
// Get existing settings to update or insert
|
||||
$existingSettings = $db->getAll('settings');
|
||||
$existingMap = [];
|
||||
|
||||
foreach ($existingSettings as $index => $setting) {
|
||||
$existingMap[$setting['key']] = $setting['id'];
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (isset($existingMap[$key])) {
|
||||
// Update
|
||||
$db->update('settings', $existingMap[$key], ['value' => $value]);
|
||||
} else {
|
||||
// Insert
|
||||
$db->insert('settings', ['key' => $key, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Settings saved successfully']);
|
||||
}
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Dashboard Stats API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method !== 'GET') {
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// Require authentication for stats
|
||||
requireAuth();
|
||||
|
||||
$action = $_GET['action'] ?? 'all';
|
||||
$forceRefresh = !empty($_GET['refresh']);
|
||||
|
||||
switch ($action) {
|
||||
case 'all':
|
||||
getAllStats();
|
||||
break;
|
||||
case 'bookings':
|
||||
getBookingStats();
|
||||
break;
|
||||
case 'messages':
|
||||
getMessageStats();
|
||||
break;
|
||||
case 'news':
|
||||
getNewsStats();
|
||||
break;
|
||||
default:
|
||||
getAllStats();
|
||||
}
|
||||
|
||||
function getAllStats() {
|
||||
$cacheKey = 'stats_all_v1';
|
||||
if (!$GLOBALS['forceRefresh']) {
|
||||
$cached = getStatsCache($cacheKey, 30);
|
||||
if ($cached !== null) {
|
||||
jsonResponse($cached);
|
||||
}
|
||||
}
|
||||
|
||||
$bookingStats = calculateBookingStats();
|
||||
$messageStats = calculateMessageStats();
|
||||
$newsStats = calculateNewsStats();
|
||||
$subscriberStats = calculateSubscriberStats();
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'bookings' => $bookingStats,
|
||||
'messages' => $messageStats,
|
||||
'news' => $newsStats,
|
||||
'subscribers' => $subscriberStats
|
||||
]
|
||||
];
|
||||
|
||||
setStatsCache($cacheKey, $payload);
|
||||
jsonResponse($payload);
|
||||
}
|
||||
|
||||
function calculateBookingStats() {
|
||||
global $db;
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$weekStart = date('Y-m-d', strtotime('monday this week'));
|
||||
$weekEnd = date('Y-m-d', strtotime('sunday this week'));
|
||||
|
||||
$totalBookings = $db->count('bookings');
|
||||
$thisWeekCount = $db->countByDateRange('bookings', 'booking_date', $weekStart, $weekEnd);
|
||||
|
||||
// Last week's bookings (for trend)
|
||||
$lastWeekStart = date('Y-m-d', strtotime('monday last week'));
|
||||
$lastWeekEnd = date('Y-m-d', strtotime('sunday last week'));
|
||||
$lastWeekCount = $db->countByDateRange('bookings', 'booking_date', $lastWeekStart, $lastWeekEnd);
|
||||
|
||||
$pendingCount = $db->count('bookings', ['status' => 'pending']);
|
||||
$confirmedCount = $db->count('bookings', ['status' => 'confirmed']);
|
||||
|
||||
// JSON boolean true is compared as string "true" after JSON_UNQUOTE
|
||||
$availableSlotsCount = $db->countByFieldComparison('availability_slots', 'date', '>=', $today, [
|
||||
'is_available' => 'true'
|
||||
]);
|
||||
|
||||
$confirmationRate = $totalBookings > 0 ? round(($confirmedCount / $totalBookings) * 100) : 0;
|
||||
|
||||
// Calculate trend percentages
|
||||
$bookingTrend = $lastWeekCount > 0 ? round((($thisWeekCount - $lastWeekCount) / $lastWeekCount) * 100) : ($thisWeekCount > 0 ? 100 : 0);
|
||||
|
||||
return [
|
||||
'this_week' => $thisWeekCount,
|
||||
'pending' => $pendingCount,
|
||||
'confirmed' => $confirmedCount,
|
||||
'available_slots' => $availableSlotsCount,
|
||||
'confirmation_rate' => $confirmationRate,
|
||||
'trend' => $bookingTrend,
|
||||
'total' => $totalBookings
|
||||
];
|
||||
}
|
||||
|
||||
function calculateMessageStats() {
|
||||
global $db;
|
||||
|
||||
$total = $db->count('messages');
|
||||
$unread = $db->count('messages', ['status' => 'unread']);
|
||||
|
||||
$weekStart = date('Y-m-d', strtotime('monday this week'));
|
||||
$weekEnd = date('Y-m-d', strtotime('sunday this week'));
|
||||
$thisWeek = $db->countByDateRange('messages', 'created_at', $weekStart . ' 00:00:00', $weekEnd . ' 23:59:59');
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'unread' => $unread,
|
||||
'this_week' => $thisWeek
|
||||
];
|
||||
}
|
||||
|
||||
function calculateNewsStats() {
|
||||
global $db;
|
||||
|
||||
return [
|
||||
'total' => $db->count('news'),
|
||||
'published' => $db->count('news', ['status' => 'published']),
|
||||
'draft' => $db->count('news', ['status' => 'draft'])
|
||||
];
|
||||
}
|
||||
|
||||
function calculateSubscriberStats() {
|
||||
global $db;
|
||||
|
||||
return [
|
||||
'total' => $db->count('subscribers'),
|
||||
'active' => $db->count('subscribers', ['status' => 'active'])
|
||||
];
|
||||
}
|
||||
|
||||
function getBookingStats() {
|
||||
$cacheKey = 'stats_bookings_v1';
|
||||
if (!$GLOBALS['forceRefresh']) {
|
||||
$cached = getStatsCache($cacheKey, 30);
|
||||
if ($cached !== null) {
|
||||
jsonResponse($cached);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'data' => calculateBookingStats()
|
||||
];
|
||||
|
||||
setStatsCache($cacheKey, $payload);
|
||||
jsonResponse($payload);
|
||||
}
|
||||
|
||||
function getMessageStats() {
|
||||
$cacheKey = 'stats_messages_v1';
|
||||
if (!$GLOBALS['forceRefresh']) {
|
||||
$cached = getStatsCache($cacheKey, 30);
|
||||
if ($cached !== null) {
|
||||
jsonResponse($cached);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'data' => calculateMessageStats()
|
||||
];
|
||||
|
||||
setStatsCache($cacheKey, $payload);
|
||||
jsonResponse($payload);
|
||||
}
|
||||
|
||||
function getNewsStats() {
|
||||
$cacheKey = 'stats_news_v1';
|
||||
if (!$GLOBALS['forceRefresh']) {
|
||||
$cached = getStatsCache($cacheKey, 30);
|
||||
if ($cached !== null) {
|
||||
jsonResponse($cached);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'data' => calculateNewsStats()
|
||||
];
|
||||
|
||||
setStatsCache($cacheKey, $payload);
|
||||
jsonResponse($payload);
|
||||
}
|
||||
|
||||
function getStatsCache($cacheKey, $ttlSeconds) {
|
||||
$cacheDir = DATA_DIR . 'cache/';
|
||||
$cacheFile = $cacheDir . 'stats_' . md5($cacheKey) . '.json';
|
||||
|
||||
if (!file_exists($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$age = time() - (int)filemtime($cacheFile);
|
||||
if ($age > (int)$ttlSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($cacheFile);
|
||||
if ($content === false || $content === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($content, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
function setStatsCache($cacheKey, $payload) {
|
||||
$cacheDir = DATA_DIR . 'cache/';
|
||||
if (!is_dir($cacheDir)) {
|
||||
@mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
|
||||
$cacheFile = $cacheDir . 'stats_' . md5($cacheKey) . '.json';
|
||||
@file_put_contents($cacheFile, json_encode($payload), LOCK_EX);
|
||||
}
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Newsletter Subscription API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requireAuth();
|
||||
getSubscribers();
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
subscribe();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Subscriber ID required'], 400);
|
||||
}
|
||||
unsubscribe($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getSubscribers() {
|
||||
global $db;
|
||||
|
||||
$subscribers = $db->getAll('subscribers');
|
||||
|
||||
// Apply filters
|
||||
$status = $_GET['status'] ?? null;
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
if ($status) {
|
||||
$subscribers = array_filter($subscribers, function($s) use ($status) {
|
||||
return ($s['status'] ?? 'active') === $status;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
usort($subscribers, function($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
$total = count($subscribers);
|
||||
$subscribers = array_slice(array_values($subscribers), $offset, $limit);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => $subscribers,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
global $db;
|
||||
|
||||
requireSameOriginRequest();
|
||||
|
||||
$data = getRequestBody();
|
||||
|
||||
// Validate email
|
||||
$email = $data['email'] ?? '';
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Valid email address required'], 400);
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
$existing = $db->query('subscribers', ['email' => $email]);
|
||||
if (!empty($existing)) {
|
||||
$subscriber = array_values($existing)[0];
|
||||
if (($subscriber['status'] ?? 'active') === 'active') {
|
||||
jsonResponse(['success' => true, 'message' => 'You are already subscribed!']);
|
||||
} else {
|
||||
// Reactivate subscription
|
||||
$db->update('subscribers', $subscriber['id'], ['status' => 'active']);
|
||||
jsonResponse(['success' => true, 'message' => 'Welcome back! Your subscription has been reactivated.']);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
$subscriberData = [
|
||||
'email' => sanitize($email),
|
||||
'name' => sanitize($data['name'] ?? ''),
|
||||
'status' => 'active',
|
||||
'source' => $data['source'] ?? 'website'
|
||||
];
|
||||
|
||||
$subscriber = $db->insert('subscribers', $subscriberData);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Thank you for subscribing! You\'ll receive our latest updates.'
|
||||
], 201);
|
||||
}
|
||||
|
||||
function unsubscribe($id) {
|
||||
global $db;
|
||||
|
||||
$existing = $db->get('subscribers', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Subscriber not found'], 404);
|
||||
}
|
||||
|
||||
// Soft delete by setting status to unsubscribed
|
||||
$db->update('subscribers', $id, ['status' => 'unsubscribed']);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Successfully unsubscribed'
|
||||
]);
|
||||
}
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Team API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getMember($id);
|
||||
} else {
|
||||
getMembers();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
$postData = !empty($_POST) ? $_POST : getRequestBody();
|
||||
if (!empty($postData['id'])) {
|
||||
updateMember($postData['id']);
|
||||
} else {
|
||||
createMember();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Member ID required'], 400);
|
||||
}
|
||||
updateMember($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
deleteMember($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getMembers() {
|
||||
global $db;
|
||||
$members = $db->getAll('team');
|
||||
|
||||
// Sort by order
|
||||
usort($members, function($a, $b) {
|
||||
return ($a['order'] ?? 99) - ($b['order'] ?? 99);
|
||||
});
|
||||
|
||||
// Public view: only active
|
||||
if (!checkAuth()) {
|
||||
$members = array_filter($members, function($m) {
|
||||
return ($m['status'] ?? 'active') === 'active';
|
||||
});
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => array_values($members)]);
|
||||
}
|
||||
|
||||
function getMember($id) {
|
||||
global $db;
|
||||
$member = $db->get('team', $id);
|
||||
if (!$member) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
jsonResponse(['success' => true, 'data' => $member]);
|
||||
}
|
||||
|
||||
function createMember() {
|
||||
global $db;
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (empty($data['name']) || empty($data['role'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Name and Role are required'], 400);
|
||||
}
|
||||
|
||||
// Sanitize text fields
|
||||
$data['name'] = sanitize($data['name']);
|
||||
$data['role'] = sanitize($data['role']);
|
||||
$data['email'] = sanitize($data['email'] ?? '');
|
||||
$data['linkedin'] = sanitize($data['linkedin'] ?? '');
|
||||
// Sanitize bio: allow only safe formatting tags
|
||||
$data['bio'] = sanitizeRichText($data['bio'] ?? '');
|
||||
|
||||
// Image upload
|
||||
if (!empty($_FILES['photo'])) {
|
||||
$upload = handleFileUpload($_FILES['photo'], 'team');
|
||||
if ($upload['success']) {
|
||||
$data['photo'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
$data['order'] = (int)($data['order'] ?? 99);
|
||||
$data['status'] = $data['status'] ?? 'active';
|
||||
|
||||
$member = $db->insert('team', $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Team member added', 'data' => $member]);
|
||||
}
|
||||
|
||||
function updateMember($id) {
|
||||
global $db;
|
||||
$existing = $db->get('team', $id);
|
||||
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (!empty($_FILES['photo'])) {
|
||||
$upload = handleFileUpload($_FILES['photo'], 'team');
|
||||
if ($upload['success']) {
|
||||
$data['photo'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize editable text fields
|
||||
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
|
||||
if (isset($data['role'])) $data['role'] = sanitize($data['role']);
|
||||
if (isset($data['email'])) $data['email'] = sanitize($data['email']);
|
||||
if (isset($data['linkedin'])) $data['linkedin'] = sanitize($data['linkedin']);
|
||||
if (isset($data['bio'])) $data['bio'] = sanitizeRichText($data['bio']);
|
||||
|
||||
$member = $db->update('team', $id, $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Team member updated', 'data' => $member]);
|
||||
}
|
||||
|
||||
function deleteMember($id) {
|
||||
global $db;
|
||||
$existing = $db->get('team', $id);
|
||||
if (!$existing) {
|
||||
jsonResponse(['success' => false, 'message' => 'Team member not found'], 404);
|
||||
}
|
||||
$db->delete('team', $id);
|
||||
jsonResponse(['success' => true, 'message' => 'Team member removed']);
|
||||
}
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Testimonials API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getTestimonial($id);
|
||||
} else {
|
||||
getTestimonials();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requireAuth();
|
||||
$postData = !empty($_POST) ? $_POST : getRequestBody();
|
||||
if (!empty($postData['id'])) {
|
||||
updateTestimonial($postData['id']);
|
||||
} else {
|
||||
createTestimonial();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Testimonial ID required'], 400);
|
||||
}
|
||||
updateTestimonial($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requireAuth();
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
deleteTestimonial($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getTestimonials() {
|
||||
global $db;
|
||||
$items = $db->getAll('testimonials');
|
||||
|
||||
// Filters
|
||||
$status = $_GET['status'] ?? null;
|
||||
if ($status) {
|
||||
$items = array_filter($items, function($i) use ($status) {
|
||||
return $i['status'] === $status;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
usort($items, function($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
|
||||
// Public view: only published
|
||||
if (!checkAuth()) {
|
||||
$items = array_filter($items, function($i) {
|
||||
return ($i['status'] ?? 'draft') === 'published';
|
||||
});
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => array_values($items)]);
|
||||
}
|
||||
|
||||
function getTestimonial($id) {
|
||||
global $db;
|
||||
$item = $db->get('testimonials', $id);
|
||||
if (!$item) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
jsonResponse(['success' => true, 'data' => $item]);
|
||||
}
|
||||
|
||||
function createTestimonial() {
|
||||
global $db;
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (empty($data['name']) || empty($data['text'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Name and Text are required'], 400);
|
||||
}
|
||||
|
||||
// Sanitize text fields
|
||||
$data['name'] = sanitize($data['name']);
|
||||
$data['company'] = sanitize($data['company'] ?? '');
|
||||
$data['role'] = sanitize($data['role'] ?? '');
|
||||
$data['text'] = sanitize($data['text']);
|
||||
|
||||
// Image upload
|
||||
if (!empty($_FILES['photo'])) {
|
||||
$upload = handleFileUpload($_FILES['photo'], 'testimonials');
|
||||
if ($upload['success']) {
|
||||
$data['photo'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
$data['status'] = $data['status'] ?? 'pending';
|
||||
$data['rating'] = (float)($data['rating'] ?? 5);
|
||||
|
||||
$item = $db->insert('testimonials', $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Testimonial added', 'data' => $item]);
|
||||
}
|
||||
|
||||
function updateTestimonial($id) {
|
||||
global $db;
|
||||
$existing = $db->get('testimonials', $id);
|
||||
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (!empty($_FILES['photo'])) {
|
||||
$upload = handleFileUpload($_FILES['photo'], 'testimonials');
|
||||
if ($upload['success']) {
|
||||
$data['photo'] = $upload['path'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize editable text fields
|
||||
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
|
||||
if (isset($data['company'])) $data['company'] = sanitize($data['company']);
|
||||
if (isset($data['role'])) $data['role'] = sanitize($data['role']);
|
||||
if (isset($data['text'])) $data['text'] = sanitize($data['text']);
|
||||
|
||||
$item = $db->update('testimonials', $id, $data);
|
||||
jsonResponse(['success' => true, 'message' => 'Testimonial updated', 'data' => $item]);
|
||||
}
|
||||
|
||||
function deleteTestimonial($id) {
|
||||
global $db;
|
||||
$db->delete('testimonials', $id);
|
||||
jsonResponse(['success' => true, 'message' => 'Testimonial deleted']);
|
||||
}
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* MSPE Admin Users API
|
||||
*/
|
||||
|
||||
require_once 'config.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
$currentUser = requireAuth();
|
||||
requireAdminRole($currentUser);
|
||||
|
||||
// CSRF protection for write operations
|
||||
if ($method !== 'GET') {
|
||||
requireSameOriginRequest();
|
||||
}
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
getUser($id);
|
||||
} else {
|
||||
getUsers();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$postData = !empty($_POST) ? $_POST : getRequestBody();
|
||||
if (!empty($postData['id'])) {
|
||||
updateUser($postData['id']);
|
||||
} else {
|
||||
createUser();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'User ID required'], 400);
|
||||
}
|
||||
updateUser($id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
|
||||
}
|
||||
deleteUser($id);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
function getUsers() {
|
||||
global $db;
|
||||
$users = $db->getAll('users');
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($users as &$user) {
|
||||
unset($user['password']);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'data' => array_values($users)]);
|
||||
}
|
||||
|
||||
function getUser($id) {
|
||||
global $db;
|
||||
$user = $db->get('users', $id);
|
||||
if (!$user) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
|
||||
unset($user['password']);
|
||||
jsonResponse(['success' => true, 'data' => $user]);
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
global $db;
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
|
||||
if (empty($data['username']) || empty($data['password']) || empty($data['email'])) {
|
||||
jsonResponse(['success' => false, 'message' => 'Username, Email and Password required'], 400);
|
||||
}
|
||||
|
||||
// Enforce minimum password strength
|
||||
if (strlen($data['password']) < 12) {
|
||||
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
$existing = $db->query('users', ['username' => $data['username']]);
|
||||
if (!empty($existing)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Username already exists'], 400);
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
$existingEmail = $db->query('users', ['email' => $data['email']]);
|
||||
if (!empty($existingEmail)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Email already in use'], 400);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
|
||||
}
|
||||
|
||||
// Whitelist allowed roles and statuses
|
||||
$allowedRoles = ['admin', 'editor'];
|
||||
$role = in_array($data['role'] ?? 'editor', $allowedRoles, true) ? $data['role'] : 'editor';
|
||||
$allowedStatuses = ['active', 'inactive'];
|
||||
$status = in_array($data['status'] ?? 'active', $allowedStatuses, true) ? $data['status'] : 'active';
|
||||
|
||||
$user = [
|
||||
'name' => sanitize($data['name'] ?? ''),
|
||||
'username' => sanitize($data['username']),
|
||||
'email' => sanitize($data['email']),
|
||||
'role' => $role,
|
||||
'status' => $status,
|
||||
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
'last_login' => null
|
||||
];
|
||||
|
||||
$created = $db->insert('users', $user);
|
||||
unset($created['password']);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'User created', 'data' => $created]);
|
||||
}
|
||||
|
||||
function updateUser($id) {
|
||||
global $db;
|
||||
$existing = $db->get('users', $id);
|
||||
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
|
||||
|
||||
$data = !empty($_POST) ? $_POST : getRequestBody();
|
||||
$updateData = [];
|
||||
|
||||
if (!empty($data['name'])) $updateData['name'] = sanitize($data['name']);
|
||||
if (!empty($data['email'])) {
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
|
||||
}
|
||||
$updateData['email'] = sanitize($data['email']);
|
||||
}
|
||||
if (!empty($data['role'])) {
|
||||
$allowedRoles = ['admin', 'editor'];
|
||||
$updateData['role'] = in_array($data['role'], $allowedRoles, true) ? $data['role'] : ($existing['role'] ?? 'editor');
|
||||
}
|
||||
if (!empty($data['status'])) {
|
||||
$allowedStatuses = ['active', 'inactive'];
|
||||
$updateData['status'] = in_array($data['status'], $allowedStatuses, true) ? $data['status'] : ($existing['status'] ?? 'active');
|
||||
}
|
||||
|
||||
// Password update
|
||||
if (!empty($data['password'])) {
|
||||
if (strlen($data['password']) < 12) {
|
||||
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
|
||||
}
|
||||
$updateData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$updated = $db->update('users', $id, $updateData);
|
||||
unset($updated['password']);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'User updated', 'data' => $updated]);
|
||||
}
|
||||
|
||||
function deleteUser($id) {
|
||||
global $db;
|
||||
global $currentUser;
|
||||
|
||||
// Prevent deleting self (simplistic check)
|
||||
if ($currentUser['user_id'] === $id) {
|
||||
jsonResponse(['success' => false, 'message' => 'Cannot delete yourself'], 400);
|
||||
}
|
||||
|
||||
$db->delete('users', $id);
|
||||
jsonResponse(['success' => true, 'message' => 'User deleted']);
|
||||
}
|
||||
|
||||
function requireAdminRole($user) {
|
||||
if (($user['role'] ?? '') !== 'admin') {
|
||||
jsonResponse(['success' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user