Initial commit: MSPE website - full site with admin panel, API, and public pages

This commit is contained in:
Krikorios
2026-02-22 02:24:15 +02:00
commit 7a90e63301
106 changed files with 477087 additions and 0 deletions
+10
View File
@@ -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>
+391
View File
@@ -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']);
}
}
}
}
+605
View File
@@ -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'));
}
}
+773
View File
@@ -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']));
}
+287
View File
@@ -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'
]);
}
+168
View File
@@ -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"]);
}
+273
View File
@@ -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;
}
+405
View File
@@ -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']);
}
+264
View File
@@ -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'
]);
}
+46
View File
@@ -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]);
+125
View File
@@ -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']);
}
+102
View File
@@ -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']);
}
+224
View File
@@ -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);
}
+123
View File
@@ -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'
]);
}
+140
View File
@@ -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']);
}
+141
View File
@@ -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']);
}
+183
View File
@@ -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);
}
}