Commit f548e948 authored by AGLANPC\aglan's avatar AGLANPC\aglan

phase0CodePlaced

parents
RewriteEngine On
RewriteBase /
# Force HTTPS (uncomment in production)
# RewriteCond %{HTTPS} off
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>
# Block access to sensitive files
<FilesMatch "\.(env|log|ini|conf|sql|md|json|lock|yml|yaml)$">
Require all denied
</FilesMatch>
# Block access to hidden files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Pass API requests directly to API files
RewriteCond %{REQUEST_URI} ^/api/ [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^api/(.+)$ api/$1.php [L,QSA]
# If not a real file or directory, route through index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
RewriteRule ^(.*)$ index.php?route=$1 [L,QSA]
# Disable directory listing
Options -Indexes
# PHP settings
<IfModule mod_php.c>
php_value upload_max_filesize 10M
php_value post_max_size 12M
php_value max_execution_time 60
php_value session.cookie_httponly 1
php_value session.cookie_samesite "Strict"
php_value session.use_strict_mode 1
</IfModule>
\ No newline at end of file
# Allow direct access to API PHP files
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# If request doesn't end in .php, add it
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
</IfModule>
# Security headers for API responses
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
</IfModule>
\ No newline at end of file
<?php
/**
* API: Login
* POST /api/auth/login
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
if (empty($username) || empty($password)) {
json_response(['success' => false, 'message' => 'Username and password required'], 422);
}
$result = Auth::attempt($username, $password, client_ip(), $_SERVER['HTTP_USER_AGENT'] ?? '');
if ($result['success']) {
$user = $result['user'];
json_response([
'success' => true,
'message' => $result['message'],
'user' => [
'id' => $user['id'],
'username' => $user['username'],
'name' => $user['full_name_ar'] ?? $user['username'],
'role' => $user['role_name'] ?? '',
],
]);
} else {
json_response([
'success' => false,
'message' => $result['message'],
], 401);
}
\ No newline at end of file
<?php
/**
* API: Logout
* POST /api/auth/logout
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
Auth::logout();
json_response(['success' => true, 'message' => 'Logged out']);
\ No newline at end of file
<?php
/**
* API: Confirm Password Reset
* POST /api/auth/password-reset-confirm
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) json_response(['success' => false, 'message' => 'Method not allowed'], 405);
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$token = $input['token'] ?? '';
$password = $input['password'] ?? '';
$confirm = $input['password_confirm'] ?? '';
if (empty($token)) {
json_response(['success' => false, 'message' => 'Token required'], 422);
}
if (strlen($password) < app_config('security.password_min_length')) {
json_response(['success' => false, 'message' => 'Password too short'], 422);
}
if ($password !== $confirm) {
json_response(['success' => false, 'message' => 'Passwords do not match'], 422);
}
$result = Auth::resetPassword($token, $password);
if ($result) {
json_response(['success' => true, 'message' => 'Password reset successfully']);
} else {
json_response(['success' => false, 'message' => 'Invalid or expired token'], 400);
}
\ No newline at end of file
<?php
/**
* API: Request Password Reset
* POST /api/auth/password-reset
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) json_response(['success' => false, 'message' => 'Method not allowed'], 405);
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$email = trim($input['email'] ?? '');
if (!validate_email($email)) {
json_response(['success' => false, 'message' => 'Invalid email'], 422);
}
Auth::generatePasswordResetToken($email);
// Always return success to prevent email enumeration
json_response(['success' => true, 'message' => 'If the email exists, a reset link has been sent']);
\ No newline at end of file
<?php
/**
* API: System Config / Preferences
* GET /api/config/get — get config values
* POST /api/config/get?action=toggle_sidebar — toggle sidebar state
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'toggle_sidebar' && is_post()) {
$current = Session::get('sidebar_collapsed', false);
Session::set('sidebar_collapsed', !$current);
json_response(['success' => true, 'collapsed' => !$current]);
}
// Return specific config values (non-sensitive)
$keys = input('keys', '', 'GET');
if ($keys) {
$keyList = explode(',', $keys);
$result = [];
foreach ($keyList as $key) {
$key = trim($key);
// Only return non-sensitive keys
if (!str_contains($key, 'password') && !str_contains($key, 'secret') && !str_contains($key, 'key')) {
$result[$key] = SystemConfig::get($key);
}
}
json_response(['success' => true, 'data' => $result]);
}
json_response(['success' => true, 'data' => []]);
\ No newline at end of file
<?php
/**
* API: System Config / Preferences
* GET /api/config/get — get config values
* POST /api/config/get?action=toggle_sidebar — toggle sidebar state
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'toggle_sidebar' && is_post()) {
csrf_require();
$current = Session::get('sidebar_collapsed', false);
Session::set('sidebar_collapsed', !$current);
json_response(['success' => true, 'collapsed' => !$current]);
}
// Return specific config values (non-sensitive)
$keys = input('keys', '', 'GET');
if ($keys) {
$keyList = explode(',', $keys);
$result = [];
foreach ($keyList as $key) {
$key = trim($key);
// Block sensitive keys
if (!str_contains($key, 'password') && !str_contains($key, 'secret') && !str_contains($key, 'key') && !str_contains($key, 'token')) {
$result[$key] = SystemConfig::get($key);
}
}
json_response(['success' => true, 'data' => $result]);
}
json_response(['success' => true, 'data' => []]);
\ No newline at end of file
<?php
/**
* API: Mark Notifications as Read
* POST /api/notifications/mark-read
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
if (!empty($input['all'])) {
$count = Notification::markAllAsRead(Auth::id());
json_response(['success' => true, 'marked' => $count]);
}
$id = isset($input['id']) ? (int)$input['id'] : 0;
if ($id > 0) {
$result = Notification::markAsRead($id, Auth::id());
json_response(['success' => $result]);
}
json_response(['success' => false, 'message' => 'No notification specified'], 422);
\ No newline at end of file
<?php
/**
* API: Mark Notifications as Read
* POST /api/notifications/mark-read
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
csrf_require();
$input = all_input();
if (!empty($input['all'])) {
$count = Notification::markAllAsRead(Auth::id());
json_response(['success' => true, 'marked' => $count]);
}
$id = isset($input['id']) ? (int)$input['id'] : 0;
if ($id > 0) {
$result = Notification::markAsRead($id, Auth::id());
json_response(['success' => $result]);
}
json_response(['success' => false, 'message' => 'No notification specified'], 422);
\ No newline at end of file
<?php
/**
* API: Poll Notifications
* GET /api/notifications/poll
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$notifications = Notification::getUnread(Auth::id(), 15);
$count = Notification::countUnread(Auth::id());
// Add time_ago to each notification
foreach ($notifications as &$n) {
$n['time_ago'] = time_ago($n['created_at']);
}
unset($n);
json_response([
'success' => true,
'notifications' => $notifications,
'count' => $count,
]);
\ No newline at end of file
<?php
/**
* API: Translations
* GET /api/translations/get?group=... — get translations by group
* POST /api/translations/get?action=set_locale — change locale
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'set_locale' && is_post()) {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$locale = $input['locale'] ?? '';
if (in_array($locale, Translation::getAvailableLocales())) {
Translation::setLocale($locale);
json_response(['success' => true, 'locale' => $locale]);
}
json_response(['success' => false, 'message' => 'Invalid locale'], 422);
}
// Return translations for a group
$group = input('group', '', 'GET');
if ($group) {
$translations = Translation::getGroup($group);
json_response(['success' => true, 'data' => $translations]);
}
// Return all translations
json_response(['success' => true, 'data' => Translation::getAll()]);
\ No newline at end of file
<?php
/**
* API: Translations
* GET /api/translations/get?group=... — get translations by group
* POST /api/translations/get?action=set_locale — change locale
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'set_locale' && is_post()) {
csrf_require();
$input = all_input();
$locale = $input['locale'] ?? '';
if (in_array($locale, Translation::getAvailableLocales())) {
Translation::setLocale($locale);
json_response(['success' => true, 'locale' => $locale]);
}
json_response(['success' => false, 'message' => 'Invalid locale'], 422);
}
// Return translations for a group
$group = input('group', '', 'GET');
if ($group) {
$translations = Translation::getGroup($group);
json_response(['success' => true, 'data' => $translations]);
}
// Return all translations
json_response(['success' => true, 'data' => Translation::getAll()]);
\ No newline at end of file
<?php
/**
* API: Serve Uploaded Files Securely
* GET /api/uploads/serve?path=members/photos/abc123.jpg
*
* This is the ONLY way to access uploaded files.
* Validates authentication, permission, and file existence.
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
http_response_code(401);
exit('Unauthorized');
}
$relativePath = input('path', '', 'GET');
if (empty($relativePath)) {
http_response_code(400);
exit('Missing path');
}
// Sanitize path — prevent directory traversal
$relativePath = str_replace(['..', "\0"], '', $relativePath);
$relativePath = ltrim($relativePath, '/');
// Validate path doesn't escape uploads directory
$fullPath = realpath(app_config('upload.path') . '/' . $relativePath);
$uploadsBase = realpath(app_config('upload.path'));
if (!$fullPath || !$uploadsBase || !str_starts_with($fullPath, $uploadsBase)) {
http_response_code(404);
exit('File not found');
}
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
exit('File not found');
}
// Determine MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($fullPath);
// Whitelist safe MIME types
$allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
if (!in_array($mimeType, $allowedMimes)) {
http_response_code(403);
exit('File type not allowed');
}
// Cache headers (1 hour for images, no cache for documents)
$isImage = str_starts_with($mimeType, 'image/');
if ($isImage) {
header('Cache-Control: private, max-age=3600');
} else {
header('Cache-Control: no-store, no-cache, must-revalidate');
}
// Serve the file
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($fullPath));
header('Content-Disposition: inline; filename="' . basename($fullPath) . '"');
header('X-Content-Type-Options: nosniff');
readfile($fullPath);
exit;
\ No newline at end of file
/* ============================================================
Login Page Styles
============================================================ */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a5276 0%, #2c3e50 50%, #1e3a52 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.login-header {
text-align: center;
padding: 32px 32px 20px;
background: linear-gradient(135deg, #1a5276, #2c3e50);
color: #fff;
}
.login-header-icon {
font-size: 48px;
margin-bottom: 12px;
color: #27ae60;
}
.login-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
}
.login-header p {
font-size: 13px;
opacity: 0.8;
margin: 4px 0 0;
}
.login-body {
padding: 32px;
}
.login-body .form-control {
padding: 12px 16px;
font-size: 14px;
border-radius: 8px;
}
.login-body .form-label {
font-weight: 600;
font-size: 13px;
}
.login-body .btn-primary {
width: 100%;
padding: 12px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
background: #1a5276;
border: none;
margin-top: 8px;
}
.login-body .btn-primary:hover {
background: #154360;
}
.login-footer {
text-align: center;
padding: 0 32px 24px;
}
.login-footer a {
font-size: 13px;
color: #1a5276;
}
.login-error {
background: #fce4ec;
color: #c0392b;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
text-align: center;
}
.login-success {
background: #e8f5e9;
color: #27ae60;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
text-align: center;
}
@media (max-width: 480px) {
.login-card {
border-radius: 12px;
}
.login-body {
padding: 24px;
}
}
\ No newline at end of file
This diff is collapsed.
/* ============================================================
Print Stylesheet — hides all chrome, shows only content
============================================================ */
@media print {
.app-sidebar,
.app-navbar,
.app-footer,
.app-breadcrumb,
.sidebar-overlay,
.page-header-actions,
.btn,
.alert,
.notification-dropdown,
.dataTables_filter,
.dataTables_length,
.dataTables_paginate,
.dataTables_info,
.no-print {
display: none !important;
}
.app-main {
margin: 0 !important;
}
.app-content {
padding: 0 !important;
}
body {
background: #fff !important;
color: #000 !important;
font-size: 12pt;
}
.card {
border: none !important;
box-shadow: none !important;
}
.table {
border: 1px solid #000;
}
.table th,
.table td {
border: 1px solid #333 !important;
padding: 4px 6px;
}
/* Receipt print styling */
.receipt-print {
width: 80mm;
margin: 0 auto;
font-family: 'Courier New', monospace;
font-size: 10pt;
}
.receipt-print .receipt-header {
text-align: center;
border-bottom: 1px dashed #000;
padding-bottom: 8px;
margin-bottom: 8px;
}
.receipt-print .receipt-line {
display: flex;
justify-content: space-between;
margin: 4px 0;
}
.receipt-print .receipt-total {
border-top: 1px dashed #000;
padding-top: 8px;
font-weight: bold;
font-size: 12pt;
}
.receipt-print .receipt-footer {
text-align: center;
margin-top: 12px;
border-top: 1px dashed #000;
padding-top: 8px;
font-size: 9pt;
}
a[href]::after {
content: none !important;
}
}
\ No newline at end of file
/* ============================================================
RTL-specific overrides
============================================================ */
[dir="rtl"] .breadcrumb-item + .breadcrumb-item::before {
content: "\\";
}
[dir="rtl"] .sidebar-submenu-link {
padding-right: 44px;
padding-left: 16px;
}
[dir="rtl"] .dropdown-menu-end {
--bs-position: end;
}
[dir="rtl"] .me-2 { margin-right: 0 !important; margin-left: 0.5rem !important; }
[dir="rtl"] .ms-1 { margin-left: 0 !important; margin-right: 0.25rem !important; }
[dir="rtl"] .ms-2 { margin-left: 0 !important; margin-right: 0.5rem !important; }
[dir="rtl"] .text-start { text-align: right !important; }
[dir="rtl"] .text-end { text-align: left !important; }
[dir="rtl"] .float-start { float: right !important; }
[dir="rtl"] .float-end { float: left !important; }
[dir="rtl"] .btn-close {
margin-left: 0;
margin-right: auto;
}
[dir="rtl"] .form-check {
padding-right: 1.5em;
padding-left: 0;
}
[dir="rtl"] .form-check-input {
float: right;
margin-right: -1.5em;
margin-left: 0;
}
[dir="rtl"] table.dataTable thead .sorting::after,
[dir="rtl"] table.dataTable thead .sorting_asc::after,
[dir="rtl"] table.dataTable thead .sorting_desc::after {
left: 8px;
right: auto;
}
\ No newline at end of file
/**
* AL-ARCADE Club Management ERP — Main Application JS
*/
(function() {
'use strict';
const App = {
init() {
this.initSidebar();
this.initLanguageToggle();
this.initTooltips();
this.initConfirmDialogs();
this.initAutoHideAlerts();
NotificationManager.init();
},
// ── SIDEBAR ────────────────────────────
initSidebar() {
const toggleBtn = document.getElementById('sidebarToggleBtn');
const closeBtn = document.getElementById('sidebarCloseBtn');
const sidebar = document.getElementById('appSidebar');
const overlay = document.getElementById('sidebarOverlay');
const body = document.body;
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (window.innerWidth < 992) {
sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('show');
} else {
body.classList.toggle('sidebar-collapsed');
// Persist preference
fetch('/api/config/get.php?action=toggle_sidebar', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': AppConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
}
}).catch(() => {});
}
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('show');
});
}
if (overlay) {
overlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('show');
});
}
},
// ── LANGUAGE TOGGLE ────────────────────
initLanguageToggle() {
const btn = document.getElementById('langToggleBtn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
const current = btn.dataset.current;
const newLang = current === 'ar' ? 'en' : 'ar';
fetch('/api/translations/get.php?action=set_locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': AppConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ locale: newLang })
}).then(() => {
window.location.reload();
}).catch(() => {
window.location.reload();
});
});
},
// ── TOOLTIPS ───────────────────────────
initTooltips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
},
// ── CONFIRM DIALOGS ────────────────────
initConfirmDialogs() {
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-confirm]');
if (!target) return;
e.preventDefault();
const message = target.dataset.confirm;
const href = target.getAttribute('href') || target.dataset.href;
Swal.fire({
title: AppConfig.isRtl ? 'تأكيد' : 'Confirm',
text: message,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#1a5276',
cancelButtonColor: '#6c757d',
confirmButtonText: AppConfig.isRtl ? 'نعم' : 'Yes',
cancelButtonText: AppConfig.isRtl ? 'إلغاء' : 'Cancel',
}).then((result) => {
if (result.isConfirmed && href) {
window.location.href = href;
}
});
});
},
// ── AUTO-HIDE ALERTS ───────────────────
initAutoHideAlerts() {
document.querySelectorAll('.alert-dismissible').forEach(alert => {
setTimeout(() => {
const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
bsAlert.close();
}, 5000);
});
}
};
// ── TOAST UTILITY ──────────────────────
window.Toast = {
show(message, type = 'info') {
const icons = { success: 'check-circle', error: 'times-circle', warning: 'exclamation-triangle', info: 'info-circle' };
const colors = { success: '#27ae60', error: '#e74c3c', warning: '#f39c12', info: '#3498db' };
Swal.fire({
toast: true,
position: AppConfig.isRtl ? 'top-start' : 'top-end',
icon: type === 'error' ? 'error' : type,
title: message,
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
});
},
success(msg) { this.show(msg, 'success'); },
error(msg) { this.show(msg, 'error'); },
warning(msg) { this.show(msg, 'warning'); },
info(msg) { this.show(msg, 'info'); },
};
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => App.init());
window.App = App;
})();
\ No newline at end of file
/**
* DataTables default configuration.
*/
(function() {
'use strict';
const isRtl = document.documentElement.dir === 'rtl';
// Arabic language for DataTables
const arLanguage = {
"processing": "جاري المعالجة...",
"lengthMenu": "عرض _MENU_ سجلات",
"zeroRecords": "لم يتم العثور على نتائج",
"emptyTable": "لا توجد بيانات",
"info": "عرض _START_ إلى _END_ من _TOTAL_ سجل",
"infoEmpty": "عرض 0 إلى 0 من 0 سجل",
"infoFiltered": "(تصفية من _MAX_ سجل)",
"search": "بحث:",
"paginate": {
"first": "الأول",
"last": "الأخير",
"next": "التالي",
"previous": "السابق"
}
};
const enLanguage = {
"processing": "Processing...",
"lengthMenu": "Show _MENU_ entries",
"zeroRecords": "No matching records found",
"emptyTable": "No data available",
"info": "Showing _START_ to _END_ of _TOTAL_ entries",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "(filtered from _MAX_ total entries)",
"search": "Search:",
"paginate": {
"first": "First",
"last": "Last",
"next": "Next",
"previous": "Previous"
}
};
// Set DataTables defaults
if ($.fn.dataTable) {
$.extend(true, $.fn.dataTable.defaults, {
language: isRtl ? arLanguage : enLanguage,
pageLength: 25,
lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
processing: true,
responsive: true,
stateSave: true,
stateDuration: 3600,
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>rtip',
order: [],
drawCallback: function() {
// Re-init tooltips after draw
this.api().columns.adjust();
}
});
}
/**
* Initialize a DataTable with standard config.
* Usage: AppDT.init('#myTable', { ...overrides })
*/
window.AppDT = {
init(selector, options = {}) {
const $table = $(selector);
if (!$table.length) return null;
const defaults = {
autoWidth: false,
columnDefs: [
{ targets: 'no-sort', orderable: false },
{ targets: 'text-center', className: 'text-center' }
]
};
return $table.DataTable({ ...defaults, ...options });
}
};
})();
\ No newline at end of file
/**
* Notification Polling & Display Manager.
*/
const NotificationManager = {
pollInterval: null,
pollDelay: 30000, // 30 seconds
init() {
this.loadNotifications();
this.startPolling();
this.bindEvents();
},
async loadNotifications() {
try {
const data = await Utils.ajax('/api/notifications/poll.php');
if (data.success) {
this.renderNotifications(data.notifications || []);
this.updateBadge(data.count || 0);
}
} catch (e) {
// Silently fail — notification polling shouldn't break the app
}
},
renderNotifications(notifications) {
const container = document.getElementById('notifList');
if (!container) return;
if (notifications.length === 0) {
const isRtl = window.AppConfig?.isRtl;
container.innerHTML = `
<div class="text-center py-3 text-muted">
<i class="fas fa-bell-slash"></i>
<p class="mb-0 small">${isRtl ? 'لا توجد إشعارات' : 'No notifications'}</p>
</div>`;
return;
}
const isRtl = window.AppConfig?.isRtl;
container.innerHTML = notifications.map(n => {
const title = isRtl ? (n.title_ar || n.title_en || '') : (n.title_en || n.title_ar || '');
const body = isRtl ? (n.body_ar || n.body_en || '') : (n.body_en || n.body_ar || '');
const timeAgo = n.time_ago || '';
return `
<div class="notification-item ${n.read_at ? '' : 'unread'}" data-id="${n.id}">
<div class="notification-item-icon">
<i class="fas fa-bell"></i>
</div>
<div class="notification-item-content">
<div class="notification-item-title">${this.escapeHtml(title)}</div>
<div class="small text-muted">${this.escapeHtml(body).substring(0, 80)}</div>
<div class="notification-item-time">${this.escapeHtml(timeAgo)}</div>
</div>
</div>`;
}).join('');
},
updateBadge(count) {
const badge = document.getElementById('notifBadge');
if (count > 0) {
if (badge) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = '';
} else {
const btn = document.getElementById('notifDropdownBtn');
if (btn) {
const span = document.createElement('span');
span.id = 'notifBadge';
span.className = 'position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger notif-badge';
span.textContent = count > 99 ? '99+' : count;
btn.appendChild(span);
}
}
} else if (badge) {
badge.style.display = 'none';
}
},
startPolling() {
this.pollInterval = setInterval(() => this.loadNotifications(), this.pollDelay);
},
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
},
bindEvents() {
// Mark all as read
const markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', async (e) => {
e.preventDefault();
try {
await Utils.post('/api/notifications/mark-read.php', { all: true });
this.loadNotifications();
} catch (err) {
// Ignore
}
});
}
// Mark individual as read
document.addEventListener('click', async (e) => {
const item = e.target.closest('.notification-item[data-id]');
if (!item) return;
const id = item.dataset.id;
try {
await Utils.post('/api/notifications/mark-read.php', { id: parseInt(id) });
item.classList.remove('unread');
this.loadNotifications();
} catch (err) {
// Ignore
}
});
},
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
};
window.NotificationManager = NotificationManager;
\ No newline at end of file
/**
* Utility functions for the application.
*/
const Utils = {
/**
* Make an AJAX request with CSRF token.
*/
async ajax(url, options = {}) {
const defaults = {
method: 'GET',
headers: {
'X-CSRF-TOKEN': window.AppConfig?.csrfToken || '',
'X-Requested-With': 'XMLHttpRequest',
},
};
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
defaults.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
const config = { ...defaults, ...options, headers: { ...defaults.headers, ...(options.headers || {}) } };
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw { status: response.status, data };
}
return data;
},
/**
* POST helper.
*/
async post(url, body = {}) {
return this.ajax(url, { method: 'POST', body });
},
/**
* Format money.
*/
formatMoney(amount) {
const cfg = window.AppConfig?.currency || {};
const num = parseFloat(amount) || 0;
const formatted = num.toFixed(cfg.decimals || 2)
.replace(/\B(?=(\d{3})+(?!\d))/g, cfg.thousands_sep || ',');
const symbol = window.AppConfig?.isRtl ? (cfg.symbol || 'ج.م') : (cfg.symbol_en || 'LE');
return formatted + ' ' + symbol;
},
/**
* Format date from ISO string.
*/
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
},
/**
* Debounce function.
*/
debounce(func, wait = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
/**
* Copy text to clipboard.
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
Toast.success(window.AppConfig?.isRtl ? 'تم النسخ' : 'Copied!');
} catch {
Toast.error(window.AppConfig?.isRtl ? 'فشل النسخ' : 'Copy failed');
}
},
/**
* Print a specific element.
*/
printElement(selector) {
const content = document.querySelector(selector);
if (!content) return;
const win = window.open('', '_blank');
win.document.write(`
<html dir="${window.AppConfig?.isRtl ? 'rtl' : 'ltr'}">
<head><title>Print</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap${window.AppConfig?.isRtl ? '.rtl' : ''}.min.css" rel="stylesheet">
<link href="${window.AppConfig?.baseUrl}/assets/css/print.css" rel="stylesheet">
<style>body{padding:20px;font-family:'Cairo',sans-serif;}</style>
</head><body>${content.innerHTML}</body></html>
`);
win.document.close();
win.onload = () => { win.print(); win.close(); };
},
/**
* Validate Egyptian National ID on client side.
*/
validateNationalId(id) {
id = id.trim();
if (!/^[23]\d{13}$/.test(id)) return { valid: false, error: 'Invalid format' };
const century = id[0] === '2' ? 1900 : 2000;
const year = century + parseInt(id.substring(1, 3));
const month = parseInt(id.substring(3, 5));
const day = parseInt(id.substring(5, 7));
const testDate = new Date(year, month - 1, day);
if (testDate.getFullYear() !== year || testDate.getMonth() !== month - 1 || testDate.getDate() !== day) {
return { valid: false, error: 'Invalid date in ID' };
}
const gender = parseInt(id[12]) % 2 === 1 ? 'male' : 'female';
const dob = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return { valid: true, dob, gender, govCode: id.substring(7, 9) };
},
/**
* Validate Egyptian mobile number.
*/
validateMobile(phone) {
return /^(?:\+?20)?0?1[0125]\d{8}$/.test(phone.replace(/[\s-]/g, ''));
},
/**
* Show loading overlay on a container.
*/
showLoading(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!el) return;
el.style.position = 'relative';
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
overlay.style.cssText = 'position:absolute;inset:0;background:rgba(255,255,255,0.7);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:inherit;';
el.appendChild(overlay);
},
/**
* Remove loading overlay.
*/
hideLoading(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!el) return;
const overlay = el.querySelector('.loading-overlay');
if (overlay) overlay.remove();
},
/**
* Serialize form to object.
*/
formToObject(formElement) {
const formData = new FormData(formElement);
const obj = {};
for (const [key, value] of formData.entries()) {
if (obj[key]) {
if (!Array.isArray(obj[key])) obj[key] = [obj[key]];
obj[key].push(value);
} else {
obj[key] = value;
}
}
return obj;
}
};
window.Utils = Utils;
\ No newline at end of file
This diff is collapsed.
Require all denied
\ No newline at end of file
<?php
/**
* Application Configuration
*/
return [
'name' => 'AL-ARCADE Club Management',
'name_ar' => 'نظام إدارة نادي الأركيد',
'version' => '1.0.0',
'env' => 'production', // production | development
'debug' => false,
'timezone' => 'Africa/Cairo',
'locale' => 'ar', // default locale
'fallback_locale' => 'ar',
'url' => 'https://club.al-arcade.com',
'charset' => 'UTF-8',
'session' => [
'name' => 'ALARCADE_SESS',
'lifetime' => 7200, // 2 hours in seconds
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
],
'security' => [
'max_login_attempts' => 5,
'lockout_duration' => 900, // 15 minutes
'password_min_length' => 8,
'csrf_token_lifetime' => 3600,
'password_reset_expiry' => 3600, // 1 hour
'session_regenerate' => 300, // Regenerate session ID every 5 min
],
'upload' => [
'max_size' => 10485760, // 10MB
'allowed_images' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'allowed_docs' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
'path' => ROOT_PATH . '/uploads',
],
'logging' => [
'enabled' => true,
'path' => ROOT_PATH . '/logs/app.log',
'level' => 'error', // debug | info | warning | error
],
'pagination' => [
'per_page' => 25,
'max_per_page' => 100,
],
'currency' => [
'code' => 'EGP',
'symbol' => 'ج.م',
'symbol_en' => 'LE',
'decimals' => 2,
'dec_point' => '.',
'thousands_sep' => ',',
],
'date_format' => [
'display' => 'd/m/Y',
'display_time' => 'd/m/Y H:i',
'db' => 'Y-m-d',
'db_time' => 'Y-m-d H:i:s',
],
];
\ No newline at end of file
<?php
/**
* Database Configuration
*/
return [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'alarcade_club',
'username' => 'alarcade_user',
'password' => 'CHANGE_THIS_IN_PRODUCTION',
'charset' => 'utf8mb4',
'collation'=> 'utf8mb4_unicode_ci',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
],
];
\ No newline at end of file
<?php
/**
* Sidebar Navigation Menu Configuration
* Defines ALL menu items for ALL phases.
* Visibility is controlled by permissions — items without
* required permission are hidden from unauthorized users.
* Phase 0 defines ALL menus. Future phases NEVER edit this file.
*/
return [
[
'id' => 'dashboard',
'title_ar' => 'لوحة التحكم',
'title_en' => 'Dashboard',
'icon' => 'fas fa-tachometer-alt',
'url' => '/dashboard',
'permission' => null,
'badge' => null,
'children' => [],
],
[
'id' => 'members',
'title_ar' => 'الأعضاء',
'title_en' => 'Members',
'icon' => 'fas fa-users',
'url' => '#',
'permission' => 'members.view',
'badge' => null,
'children' => [
['title_ar' => 'تسجيل عضو جديد', 'title_en' => 'New Registration', 'url' => '/members/registration', 'permission' => 'members.register', 'icon' => 'fas fa-user-plus'],
['title_ar' => 'قائمة الأعضاء', 'title_en' => 'Member List', 'url' => '/members/list', 'permission' => 'members.view', 'icon' => 'fas fa-list'],
['title_ar' => 'بحث متقدم', 'title_en' => 'Advanced Search', 'url' => '/members/search', 'permission' => 'members.view', 'icon' => 'fas fa-search'],
['title_ar' => 'التابعين', 'title_en' => 'Dependents', 'url' => '/members/dependents', 'permission' => 'members.dependents.view', 'icon' => 'fas fa-people-arrows'],
['title_ar' => 'طلبات العضوية', 'title_en' => 'Applications', 'url' => '/members/applications', 'permission' => 'members.applications.view', 'icon' => 'fas fa-file-alt'],
],
],
[
'id' => 'finance',
'title_ar' => 'الشؤون المالية',
'title_en' => 'Finance',
'icon' => 'fas fa-money-bill-wave',
'url' => '#',
'permission' => 'finance.view',
'badge' => null,
'children' => [
['title_ar' => 'الفواتير', 'title_en' => 'Invoices', 'url' => '/invoices/list', 'permission' => 'invoices.view', 'icon' => 'fas fa-file-invoice-dollar'],
['title_ar' => 'المدفوعات', 'title_en' => 'Payments', 'url' => '/payments/list', 'permission' => 'payments.view', 'icon' => 'fas fa-credit-card'],
['title_ar' => 'الإيصالات', 'title_en' => 'Receipts', 'url' => '/receipts/list', 'permission' => 'receipts.view', 'icon' => 'fas fa-receipt'],
['title_ar' => 'الأقساط', 'title_en' => 'Installments', 'url' => '/installments/list', 'permission' => 'installments.view', 'icon' => 'fas fa-calendar-check'],
['title_ar' => 'الخزينة', 'title_en' => 'Cashier', 'url' => '/cashier/index', 'permission' => 'cashier.view', 'icon' => 'fas fa-cash-register'],
],
],
[
'id' => 'cards',
'title_ar' => 'البطاقات',
'title_en' => 'Cards',
'icon' => 'fas fa-id-card',
'url' => '#',
'permission' => 'cards.view',
'badge' => null,
'children' => [
['title_ar' => 'إصدار بطاقة', 'title_en' => 'Issue Card', 'url' => '/cards/issue', 'permission' => 'cards.issue', 'icon' => 'fas fa-plus-circle'],
['title_ar' => 'قائمة البطاقات', 'title_en' => 'Card List', 'url' => '/cards/list', 'permission' => 'cards.view', 'icon' => 'fas fa-list'],
['title_ar' => 'الكارنيهات', 'title_en' => 'Carnets', 'url' => '/carnets/list', 'permission' => 'carnets.view', 'icon' => 'fas fa-address-card'],
],
],
[
'id' => 'renewals',
'title_ar' => 'التجديدات',
'title_en' => 'Renewals',
'icon' => 'fas fa-sync-alt',
'url' => '/renewals/list',
'permission' => 'renewals.view',
'badge' => null,
'children' => [],
],
[
'id' => 'violations',
'title_ar' => 'المخالفات',
'title_en' => 'Violations',
'icon' => 'fas fa-exclamation-triangle',
'url' => '/violations/list',
'permission' => 'violations.view',
'badge' => null,
'children' => [],
],
[
'id' => 'waiting_list',
'title_ar' => 'قوائم الانتظار',
'title_en' => 'Waiting List',
'icon' => 'fas fa-hourglass-half',
'url' => '/waiting-list/list',
'permission' => 'waiting_list.view',
'badge' => null,
'children' => [],
],
[
'id' => 'reports',
'title_ar' => 'التقارير',
'title_en' => 'Reports',
'icon' => 'fas fa-chart-bar',
'url' => '/reports/index',
'permission' => 'reports.view',
'badge' => null,
'children' => [],
],
[
'id' => 'notifications_mgmt',
'title_ar' => 'الإشعارات',
'title_en' => 'Notifications',
'icon' => 'fas fa-bell',
'url' => '/notifications/list',
'permission' => 'notifications.manage',
'badge' => null,
'children' => [],
],
[
'id' => 'gate',
'title_ar' => 'البوابة',
'title_en' => 'Gate Control',
'icon' => 'fas fa-door-open',
'url' => '/gate/index',
'permission' => 'gate.view',
'badge' => null,
'children' => [],
],
[
'id' => 'sports',
'title_ar' => 'الأنشطة الرياضية',
'title_en' => 'Sports',
'icon' => 'fas fa-futbol',
'url' => '/sports/list',
'permission' => 'sports.view',
'badge' => null,
'children' => [],
],
[
'id' => 'guests',
'title_ar' => 'الضيوف',
'title_en' => 'Guests',
'icon' => 'fas fa-user-friends',
'url' => '/guests/list',
'permission' => 'guests.view',
'badge' => null,
'children' => [],
],
// ── SYSTEM ADMIN ──
[
'id' => 'admin',
'title_ar' => 'إدارة النظام',
'title_en' => 'System Admin',
'icon' => 'fas fa-cogs',
'url' => '#',
'permission' => 'admin.access',
'badge' => null,
'children' => [
['title_ar' => 'المستخدمين', 'title_en' => 'Users', 'url' => '/admin/users', 'permission' => 'admin.users.view', 'icon' => 'fas fa-user-shield'],
['title_ar' => 'الأدوار والصلاحيات', 'title_en' => 'Roles & Permissions', 'url' => '/admin/roles', 'permission' => 'admin.roles.view', 'icon' => 'fas fa-user-lock'],
['title_ar' => 'إعدادات النظام', 'title_en' => 'System Config', 'url' => '/admin/config', 'permission' => 'admin.config.view', 'icon' => 'fas fa-sliders-h'],
['title_ar' => 'سجل المراجعة', 'title_en' => 'Audit Trail', 'url' => '/admin/audit', 'permission' => 'admin.audit.view', 'icon' => 'fas fa-history'],
['title_ar' => 'سلاسل الترقيم', 'title_en' => 'Number Series', 'url' => '/admin/number-series', 'permission' => 'admin.config.view', 'icon' => 'fas fa-sort-numeric-down'],
],
],
];
\ No newline at end of file
Require all denied
\ No newline at end of file
<?php
/**
* Audit Trail — logs every significant action into audit_trail table.
*/
class AuditTrail
{
/**
* Log an action.
*
* @param string $action e.g., INSERT, UPDATE, DELETE, LOGIN, LOGOUT, VIEW, EXPORT, PRINT
* @param string $tableName The table affected
* @param int|null $recordId The primary key of the record affected
* @param mixed $oldData Previous state (will be JSON-encoded)
* @param mixed $newData New state (will be JSON-encoded)
* @param string|null $description Human-readable description
*/
public static function log(
string $action,
string $tableName,
?int $recordId = null,
$oldData = null,
$newData = null,
?string $description = null
): void {
try {
$db = Database::getInstance();
$db->insert('audit_trail', [
'user_id' => Auth::id(),
'action' => $action,
'table_name' => $tableName,
'record_id' => $recordId,
'old_data' => $oldData !== null ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null,
'new_data' => $newData !== null ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? mb_substr($_SERVER['HTTP_USER_AGENT'], 0, 500) : null,
'description' => $description,
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// Audit logging should NEVER crash the application
app_log('error', 'AuditTrail::log failed: ' . $e->getMessage());
}
}
}
\ No newline at end of file
This diff is collapsed.
<?php
/**
* Database Singleton — PDO wrapper.
* The ONE AND ONLY way to talk to the database.
*/
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private function __construct()
{
$cfg = require ROOT_PATH . '/config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$cfg['host'],
$cfg['port'],
$cfg['database'],
$cfg['charset']
);
$this->pdo = new PDO($dsn, $cfg['username'], $cfg['password'], $cfg['options']);
}
private function __clone() {}
public function __wakeup() { throw new \RuntimeException('Cannot unserialize singleton'); }
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getPdo(): PDO
{
return $this->pdo;
}
/**
* Execute a raw query with bound parameters.
*/
public function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* SELECT multiple rows.
*/
public function select(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
/**
* SELECT single row.
*/
public function selectOne(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row !== false ? $row : null;
}
/**
* SELECT single scalar value.
*/
public function scalar(string $sql, array $params = [])
{
return $this->query($sql, $params)->fetchColumn();
}
/**
* INSERT into a table. Returns last insert ID.
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($c) => "`$c`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
/**
* UPDATE rows. Returns affected row count.
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$setParts = [];
$values = [];
foreach ($data as $col => $val) {
$setParts[] = "`{$col}` = ?";
$values[] = $val;
}
$sql = "UPDATE `{$table}` SET " . implode(', ', $setParts) . " WHERE {$where}";
$stmt = $this->query($sql, array_merge($values, $whereParams));
return $stmt->rowCount();
}
/**
* DELETE rows. Returns affected row count.
*/
public function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $whereParams)->rowCount();
}
/**
* Soft-delete rows by setting deleted_at.
*/
public function softDelete(string $table, string $where, array $whereParams = [], ?int $userId = null): int
{
$data = ['deleted_at' => date('Y-m-d H:i:s')];
if ($userId !== null) {
$data['updated_by'] = $userId;
}
return $this->update($table, $data, $where, $whereParams);
}
/**
* COUNT rows.
*/
public function count(string $table, string $where = '1=1', array $params = []): int
{
return (int) $this->scalar("SELECT COUNT(*) FROM `{$table}` WHERE {$where}", $params);
}
/**
* Check if a row exists.
*/
public function exists(string $table, string $where, array $params = []): bool
{
return $this->count($table, $where, $params) > 0;
}
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
public function commit(): bool
{
return $this->pdo->commit();
}
public function rollback(): bool
{
if ($this->pdo->inTransaction()) {
return $this->pdo->rollBack();
}
return false;
}
public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}
}
\ No newline at end of file
<?php
/**
* Database Singleton — PDO wrapper.
* The ONE AND ONLY way to talk to the database.
*/
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private int $transactionDepth = 0;
private function __construct()
{
$cfg = require ROOT_PATH . '/config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$cfg['host'],
$cfg['port'],
$cfg['database'],
$cfg['charset']
);
$this->pdo = new PDO($dsn, $cfg['username'], $cfg['password'], $cfg['options']);
}
private function __clone() {}
public function __wakeup() { throw new \RuntimeException('Cannot unserialize singleton'); }
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getPdo(): PDO
{
return $this->pdo;
}
/**
* Execute a raw query with bound parameters.
*/
public function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* SELECT multiple rows.
*/
public function select(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
/**
* SELECT single row.
*/
public function selectOne(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row !== false ? $row : null;
}
/**
* SELECT single scalar value.
*/
public function scalar(string $sql, array $params = [])
{
return $this->query($sql, $params)->fetchColumn();
}
/**
* INSERT into a table. Returns last insert ID.
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($c) => "`$c`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
/**
* UPDATE rows. Returns affected row count.
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$setParts = [];
$values = [];
foreach ($data as $col => $val) {
$setParts[] = "`{$col}` = ?";
$values[] = $val;
}
$sql = "UPDATE `{$table}` SET " . implode(', ', $setParts) . " WHERE {$where}";
$stmt = $this->query($sql, array_merge($values, $whereParams));
return $stmt->rowCount();
}
/**
* DELETE rows. Returns affected row count.
*/
public function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $whereParams)->rowCount();
}
/**
* Soft-delete rows by setting deleted_at.
*/
public function softDelete(string $table, string $where, array $whereParams = [], ?int $userId = null): int
{
$data = ['deleted_at' => date('Y-m-d H:i:s')];
if ($userId !== null) {
$data['updated_by'] = $userId;
}
return $this->update($table, $data, $where, $whereParams);
}
/**
* COUNT rows.
*/
public function count(string $table, string $where = '1=1', array $params = []): int
{
return (int) $this->scalar("SELECT COUNT(*) FROM `{$table}` WHERE {$where}", $params);
}
/**
* Check if a row exists.
*/
public function exists(string $table, string $where, array $params = []): bool
{
return $this->count($table, $where, $params) > 0;
}
/**
* Begin a transaction with SAVEPOINT support for nesting.
*/
public function beginTransaction(): bool
{
if ($this->transactionDepth === 0) {
$result = $this->pdo->beginTransaction();
} else {
$this->pdo->exec("SAVEPOINT TRANS_{$this->transactionDepth}");
$result = true;
}
$this->transactionDepth++;
return $result;
}
/**
* Commit a transaction (or release savepoint if nested).
*/
public function commit(): bool
{
$this->transactionDepth--;
if ($this->transactionDepth === 0) {
return $this->pdo->commit();
}
$this->pdo->exec("RELEASE SAVEPOINT TRANS_{$this->transactionDepth}");
return true;
}
/**
* Rollback a transaction (or rollback to savepoint if nested).
*/
public function rollback(): bool
{
$this->transactionDepth--;
if ($this->transactionDepth === 0) {
if ($this->pdo->inTransaction()) {
return $this->pdo->rollBack();
}
return false;
}
$this->pdo->exec("ROLLBACK TO SAVEPOINT TRANS_{$this->transactionDepth}");
return true;
}
/**
* Check if currently in a transaction.
*/
public function inTransaction(): bool
{
return $this->transactionDepth > 0;
}
/**
* Get the current transaction depth.
*/
public function getTransactionDepth(): int
{
return $this->transactionDepth;
}
public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}
}
\ No newline at end of file
<?php
/**
* Notification System — queues and retrieves notifications.
*/
class Notification
{
/**
* Queue a notification from a template.
*/
public static function queue(
string $templateCode,
string $recipientType,
int $recipientId,
array $data = [],
?int $createdBy = null
): ?int {
$db = Database::getInstance();
$template = $db->selectOne(
"SELECT * FROM notification_templates WHERE code = ? AND is_active = 1",
[$templateCode]
);
if (!$template) {
app_log('warning', "Notification template not found: {$templateCode}");
return null;
}
// Replace placeholders in title and body
$title = self::replacePlaceholders($template['title_ar'], $data);
$titleEn = self::replacePlaceholders($template['title_en'] ?? '', $data);
$body = self::replacePlaceholders($template['body_ar'], $data);
$bodyEn = self::replacePlaceholders($template['body_en'] ?? '', $data);
return $db->insert('notification_queue', [
'template_id' => $template['id'],
'recipient_type' => $recipientType,
'recipient_id' => $recipientId,
'title_ar' => $title,
'title_en' => $titleEn,
'body_ar' => $body,
'body_en' => $bodyEn,
'data' => !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null,
'channel' => $template['channel'] ?? 'system',
'priority' => $template['priority'] ?? 'normal',
'status' => 'pending',
'created_by' => $createdBy ?? Auth::id(),
'created_at' => date('Y-m-d H:i:s'),
]);
}
/**
* Get unread notifications for the current user.
*/
public static function getUnread(?int $userId = null, int $limit = 20): array
{
$userId = $userId ?? Auth::id();
if (!$userId) return [];
$db = Database::getInstance();
return $db->select(
"SELECT * FROM notification_queue
WHERE recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL AND channel = 'system'
ORDER BY created_at DESC
LIMIT ?",
[$userId, $limit]
);
}
/**
* Count unread notifications.
*/
public static function countUnread(?int $userId = null): int
{
$userId = $userId ?? Auth::id();
if (!$userId) return 0;
$db = Database::getInstance();
return (int) $db->scalar(
"SELECT COUNT(*) FROM notification_queue
WHERE recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL AND channel = 'system'",
[$userId]
);
}
/**
* Mark a notification as read.
*/
public static function markAsRead(int $notificationId, ?int $userId = null): bool
{
$userId = $userId ?? Auth::id();
$db = Database::getInstance();
return $db->update('notification_queue', [
'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND recipient_id = ?', [$notificationId, $userId]) > 0;
}
/**
* Mark all notifications as read for a user.
*/
public static function markAllAsRead(?int $userId = null): int
{
$userId = $userId ?? Auth::id();
$db = Database::getInstance();
return $db->update('notification_queue', [
'read_at' => date('Y-m-d H:i:s'),
], "recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL", [$userId]);
}
/**
* Replace :placeholders in a template string.
*/
private static function replacePlaceholders(string $text, array $data): string
{
foreach ($data as $key => $value) {
$text = str_replace(':' . $key, (string)$value, $text);
}
return $text;
}
}
\ No newline at end of file
<?php
/**
* Receipt Generation — creates and retrieves receipts.
*/
class Receipt
{
/**
* Create a receipt with items.
*
* @param array $data Receipt header data
* @param array $items Array of line items [['description'=>..., 'amount'=>...], ...]
* @return int Receipt ID
*/
public static function create(array $data, array $items = []): int
{
$db = Database::getInstance();
$db->beginTransaction();
try {
$receiptNumber = self::generateNumber();
$receiptId = $db->insert('receipts', [
'receipt_number' => $receiptNumber,
'member_id' => $data['member_id'] ?? null,
'receipt_type' => $data['receipt_type'] ?? 'payment',
'payment_method' => $data['payment_method'] ?? 'cash',
'total_amount' => $data['total_amount'],
'currency' => $data['currency'] ?? 'EGP',
'notes' => $data['notes'] ?? null,
'notes_ar' => $data['notes_ar'] ?? null,
'issued_by' => Auth::id(),
'issued_at' => date('Y-m-d H:i:s'),
'status' => 'issued',
'created_by' => Auth::id(),
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $index => $item) {
$db->insert('receipt_items', [
'receipt_id' => $receiptId,
'line_number' => $index + 1,
'description' => $item['description'] ?? '',
'description_ar' => $item['description_ar'] ?? $item['description'] ?? '',
'amount' => $item['amount'],
'fee_type' => $item['fee_type'] ?? null,
'reference_id' => $item['reference_id'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
$db->commit();
AuditTrail::log('INSERT', 'receipts', $receiptId, null,
['receipt_number' => $receiptNumber, 'total' => $data['total_amount']],
"Receipt created: {$receiptNumber}"
);
return $receiptId;
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}
}
/**
* Get receipt by ID with items.
*/
public static function getById(int $id): ?array
{
$db = Database::getInstance();
$receipt = $db->selectOne("SELECT * FROM receipts WHERE id = ?", [$id]);
if (!$receipt) return null;
$receipt['items'] = $db->select(
"SELECT * FROM receipt_items WHERE receipt_id = ? ORDER BY line_number",
[$id]
);
return $receipt;
}
/**
* Get receipt by number.
*/
public static function getByNumber(string $number): ?array
{
$db = Database::getInstance();
$receipt = $db->selectOne("SELECT * FROM receipts WHERE receipt_number = ?", [$number]);
if (!$receipt) return null;
$receipt['items'] = $db->select(
"SELECT * FROM receipt_items WHERE receipt_id = ? ORDER BY line_number",
[$receipt['id']]
);
return $receipt;
}
/**
* Generate next receipt number from number_series.
*/
public static function generateNumber(): string
{
return generate_number_series('receipt');
}
/**
* Cancel a receipt.
*/
public static function cancel(int $receiptId, string $reason): bool
{
$db = Database::getInstance();
$old = $db->selectOne("SELECT * FROM receipts WHERE id = ?", [$receiptId]);
if (!$old || $old['status'] === 'cancelled') return false;
$db->update('receipts', [
'status' => 'cancelled',
'cancellation_reason' => $reason,
'cancelled_by' => Auth::id(),
'cancelled_at' => date('Y-m-d H:i:s'),
'updated_by' => Auth::id(),
], 'id = ?', [$receiptId]);
AuditTrail::log('UPDATE', 'receipts', $receiptId,
['status' => $old['status']],
['status' => 'cancelled', 'reason' => $reason],
"Receipt cancelled: {$old['receipt_number']}"
);
return true;
}
}
\ No newline at end of file
<?php
/**
* Router — maps URLs to page files.
*/
class Router
{
private string $basePath;
private array $publicRoutes = ['login', 'password-reset', 'password-reset-confirm'];
public function __construct()
{
$this->basePath = ROOT_PATH . '/pages';
}
/**
* Dispatch the current request to the correct page file.
*/
public function dispatch(): void
{
$route = $this->getRoute();
// Empty route = dashboard
if ($route === '' || $route === '/') {
$route = 'dashboard';
}
$route = trim($route, '/');
$segments = explode('/', $route);
// Check if this is a public route
$isPublic = in_array($segments[0], $this->publicRoutes);
// Require authentication for non-public routes
if (!$isPublic) {
Auth::requireLogin();
}
// Resolve the page file
$file = $this->resolveFile($segments);
if ($file && file_exists($file)) {
// Extract route parameters
$GLOBALS['_route_segments'] = $segments;
$GLOBALS['_route_id'] = isset($segments[2]) && is_numeric($segments[2]) ? (int)$segments[2] : null;
$GLOBALS['_route_action'] = $segments[1] ?? 'index';
$GLOBALS['_route_module'] = $segments[0];
include $file;
} else {
http_response_code(404);
include $this->basePath . '/errors/404.php';
}
}
/**
* Resolve file path from URL segments.
*/
private function resolveFile(array $segments): ?string
{
$count = count($segments);
// Try exact match first: /module/action → /pages/module/action.php
if ($count >= 2) {
$candidate = $this->basePath . '/' . $segments[0] . '/' . $segments[1] . '.php';
if (file_exists($candidate)) return $candidate;
}
// 3 segments: /module/action/id → /pages/module/action.php (id as param)
if ($count >= 3) {
$candidate = $this->basePath . '/' . $segments[0] . '/' . $segments[1] . '.php';
if (file_exists($candidate)) return $candidate;
}
// 1 segment: /module → /pages/module/index.php
if ($count === 1) {
$candidate = $this->basePath . '/' . $segments[0] . '/index.php';
if (file_exists($candidate)) return $candidate;
// Or a direct file: /pages/module.php (for login, logout, etc.)
$candidate = $this->basePath . '/' . $segments[0] . '.php';
if (file_exists($candidate)) return $candidate;
}
// Dashboard widgets: /dashboard/widgets/name → /pages/dashboard/widgets/name.php
if ($count >= 3 && $segments[0] === 'dashboard' && $segments[1] === 'widgets') {
$candidate = $this->basePath . '/dashboard/widgets/' . $segments[2] . '.php';
if (file_exists($candidate)) return $candidate;
}
return null;
}
/**
* Extract the route from the request.
*/
private function getRoute(): string
{
if (isset($_GET['route'])) {
return $_GET['route'];
}
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$uri = parse_url($uri, PHP_URL_PATH);
$uri = rawurldecode($uri);
// Remove base directory if app isn't at document root
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
if ($scriptDir !== '/' && $scriptDir !== '\\') {
$uri = substr($uri, strlen($scriptDir));
}
return $uri ?: '/';
}
}
\ No newline at end of file
<?php
/**
* Session Manager — wraps PHP sessions with security hardening.
*/
class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started) {
return;
}
$cfg = app_config('session');
session_name($cfg['name']);
session_set_cookie_params([
'lifetime' => $cfg['lifetime'],
'path' => $cfg['path'],
'secure' => $cfg['secure'],
'httponly' => $cfg['httponly'],
'samesite' => $cfg['samesite'],
]);
session_start();
self::$started = true;
// Regenerate session ID periodically to prevent fixation
$now = time();
if (!isset($_SESSION['_last_regenerate'])) {
$_SESSION['_last_regenerate'] = $now;
} elseif ($now - $_SESSION['_last_regenerate'] > app_config('security.session_regenerate')) {
session_regenerate_id(true);
$_SESSION['_last_regenerate'] = $now;
}
}
public static function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function destroy(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
self::$started = false;
}
public static function regenerate(): void
{
session_regenerate_id(true);
$_SESSION['_last_regenerate'] = time();
}
/**
* Set a flash message (available for ONE subsequent request).
*/
public static function flash(string $key, $value): void
{
$_SESSION['_flash'][$key] = $value;
}
/**
* Get and clear a flash message.
*/
public static function getFlash(string $key, $default = null)
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
/**
* Get all flash messages and clear them.
*/
public static function getAllFlash(): array
{
$flash = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flash;
}
public static function id(): string
{
return session_id();
}
}
\ No newline at end of file
<?php
/**
* Session Manager — wraps PHP sessions with security hardening.
*/
class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started) {
return;
}
$cfg = app_config('session');
session_name($cfg['name']);
session_set_cookie_params([
'lifetime' => $cfg['lifetime'],
'path' => $cfg['path'],
'secure' => $cfg['secure'],
'httponly' => $cfg['httponly'],
'samesite' => $cfg['samesite'],
]);
session_start();
self::$started = true;
// Regenerate session ID periodically to prevent fixation
// BUT we must update the user_sessions table if the user is logged in
$now = time();
if (!isset($_SESSION['_last_regenerate'])) {
$_SESSION['_last_regenerate'] = $now;
} elseif ($now - $_SESSION['_last_regenerate'] > app_config('security.session_regenerate')) {
$oldSessionId = session_id();
session_regenerate_id(true);
$newSessionId = session_id();
$_SESSION['_last_regenerate'] = $now;
// Update user_sessions table if user is logged in
if (isset($_SESSION['user_id']) && $_SESSION['user_id']) {
try {
$db = Database::getInstance();
$db->update('user_sessions', [
'session_id' => $newSessionId,
], 'user_id = ? AND session_id = ?', [
$_SESSION['user_id'],
$oldSessionId
]);
} catch (\Throwable $e) {
// Don't crash on session update failure
}
}
}
}
public static function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function destroy(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
self::$started = false;
}
public static function regenerate(): void
{
$oldSessionId = session_id();
session_regenerate_id(true);
$newSessionId = session_id();
$_SESSION['_last_regenerate'] = time();
// Update user_sessions table
if (isset($_SESSION['user_id']) && $_SESSION['user_id']) {
try {
$db = Database::getInstance();
$db->update('user_sessions', [
'session_id' => $newSessionId,
], 'user_id = ? AND session_id = ?', [
$_SESSION['user_id'],
$oldSessionId
]);
} catch (\Throwable $e) {
// Don't crash — session might not be in DB yet (login flow)
}
}
}
/**
* Set a flash message (available for ONE subsequent request).
*/
public static function flash(string $key, $value): void
{
$_SESSION['_flash'][$key] = $value;
}
/**
* Get and clear a flash message.
*/
public static function getFlash(string $key, $default = null)
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
/**
* Get all flash messages and clear them.
*/
public static function getAllFlash(): array
{
$flash = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flash;
}
public static function id(): string
{
return session_id();
}
}
\ No newline at end of file
<?php
/**
* System Configuration — reads/writes from sys_config table.
*/
class SystemConfig
{
private static array $cache = [];
private static bool $loaded = false;
/**
* Load all config into memory.
*/
private static function loadAll(): void
{
if (self::$loaded) return;
$db = Database::getInstance();
$rows = $db->select("SELECT config_key, config_value, value_type FROM sys_config");
foreach ($rows as $row) {
self::$cache[$row['config_key']] = self::cast($row['config_value'], $row['value_type']);
}
self::$loaded = true;
}
/**
* Cast a string value to its appropriate PHP type.
*/
private static function cast(?string $value, ?string $type)
{
if ($value === null) return null;
return match ($type) {
'integer', 'int' => (int)$value,
'float', 'decimal' => (float)$value,
'boolean', 'bool' => in_array(strtolower($value), ['1', 'true', 'yes']),
'json' => json_decode($value, true),
default => $value,
};
}
/**
* Get a config value.
*/
public static function get(string $key, $default = null)
{
self::loadAll();
return self::$cache[$key] ?? $default;
}
/**
* Set a config value (persists to DB).
*/
public static function set(string $key, $value, ?int $userId = null): void
{
$db = Database::getInstance();
$strValue = is_bool($value) ? ($value ? '1' : '0') : (string)$value;
$exists = $db->exists('sys_config', 'config_key = ?', [$key]);
if ($exists) {
$old = self::get($key);
$db->update('sys_config', [
'config_value' => $strValue,
'updated_by' => $userId,
'updated_at' => date('Y-m-d H:i:s'),
], 'config_key = ?', [$key]);
AuditTrail::log('UPDATE', 'sys_config', null,
['key' => $key, 'value' => $old],
['key' => $key, 'value' => $strValue],
"Config changed: {$key}"
);
}
self::$cache[$key] = $value;
}
/**
* Get all config values (optionally filtered by prefix).
*/
public static function getAll(?string $prefix = null): array
{
self::loadAll();
if ($prefix === null) return self::$cache;
return array_filter(self::$cache, fn($k) => str_starts_with($k, $prefix), ARRAY_FILTER_USE_KEY);
}
/**
* Reload config from DB.
*/
public static function reload(): void
{
self::$loaded = false;
self::$cache = [];
self::loadAll();
}
}
\ No newline at end of file
<?php
/**
* Translation / i18n — pulls from sys_translations table.
*/
class Translation
{
private static array $strings = [];
private static string $locale = 'ar';
private static bool $loaded = false;
private static array $availableLocales = [];
/**
* Set the current locale.
*/
public static function setLocale(string $locale): void
{
$allowed = self::getAvailableLocales();
if (in_array($locale, $allowed)) {
self::$locale = $locale;
self::$loaded = false;
self::$strings = [];
Session::set('locale', $locale);
}
}
/**
* Get the current locale.
*/
public static function getLocale(): string
{
return self::$locale;
}
/**
* Is the current locale RTL?
*/
public static function isRtl(): bool
{
return self::$locale === 'ar';
}
/**
* Get available locales from DB.
*/
public static function getAvailableLocales(): array
{
if (!empty(self::$availableLocales)) {
return self::$availableLocales;
}
$db = Database::getInstance();
$rows = $db->select("SELECT code FROM sys_languages WHERE is_active = 1");
self::$availableLocales = array_column($rows, 'code');
if (empty(self::$availableLocales)) {
self::$availableLocales = ['ar', 'en'];
}
return self::$availableLocales;
}
/**
* Load translations for the current locale.
*/
private static function load(): void
{
if (self::$loaded) return;
$db = Database::getInstance();
$rows = $db->select(
"SELECT translation_key, translation_value FROM sys_translations WHERE lang_code = ?",
[self::$locale]
);
foreach ($rows as $row) {
self::$strings[$row['translation_key']] = $row['translation_value'];
}
self::$loaded = true;
}
/**
* Get a translated string, with optional replacements.
* Usage: __('welcome_message', ['name' => 'أحمد'])
*/
public static function get(string $key, array $replacements = []): string
{
self::load();
$str = self::$strings[$key] ?? $key;
foreach ($replacements as $placeholder => $value) {
$str = str_replace(':' . $placeholder, (string)$value, $str);
}
return $str;
}
/**
* Get all translations for a group prefix.
*/
public static function getGroup(string $prefix): array
{
self::load();
return array_filter(self::$strings, fn($k) => str_starts_with($k, $prefix), ARRAY_FILTER_USE_KEY);
}
/**
* Get all loaded translations.
*/
public static function getAll(): array
{
self::load();
return self::$strings;
}
}
\ No newline at end of file
<?php
/**
* Bootstrap — the ONE file that initializes everything.
* Included by index.php and every API endpoint.
*/
// Define root if not already defined
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__));
}
// Error handling
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Load core classes
require_once ROOT_PATH . '/core/helpers.php';
require_once ROOT_PATH . '/core/Database.php';
require_once ROOT_PATH . '/core/Session.php';
require_once ROOT_PATH . '/core/Auth.php';
require_once ROOT_PATH . '/core/SystemConfig.php';
require_once ROOT_PATH . '/core/Translation.php';
require_once ROOT_PATH . '/core/AuditTrail.php';
require_once ROOT_PATH . '/core/Notification.php';
require_once ROOT_PATH . '/core/Receipt.php';
require_once ROOT_PATH . '/core/Router.php';
// Timezone
date_default_timezone_set(app_config('timezone', 'Africa/Cairo'));
// Set custom error/exception handlers
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
app_log('error', "{$errstr} in {$errfile}:{$errline}", ['errno' => $errno]);
if (app_config('debug')) {
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
}
return true;
});
set_exception_handler(function (\Throwable $e) {
app_log('error', $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
if (is_ajax()) {
json_response(['success' => false, 'message' => 'Internal server error'], 500);
}
http_response_code(500);
if (file_exists(ROOT_PATH . '/pages/errors/500.php')) {
include ROOT_PATH . '/pages/errors/500.php';
} else {
echo '<h1>500 — Internal Server Error</h1>';
}
exit;
});
// Start session
Session::start();
// Restore locale from session
$sessionLocale = Session::get('locale', app_config('locale', 'ar'));
Translation::setLocale($sessionLocale);
// Set character encoding
mb_internal_encoding('UTF-8');
header('Content-Type: text/html; charset=UTF-8');
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<?php
// ADD THIS FUNCTION to core/helpers.php inside the FILE UPLOAD section,
// right after the get_upload_path() function:
/**
* Generate a URL to serve an uploaded file through the secure endpoint.
* Usage in templates: <img src="<?= upload_url('members/photos/abc.jpg') ?>">
*/
function upload_url(?string $relativePath): string
{
if (!$relativePath) return '';
return '/api/uploads/serve?path=' . urlencode($relativePath);
}
\ No newline at end of file
<?php
// THIS IS NOT A SEPARATE FILE — these lines go into core/helpers.php
// in the FILE UPLOAD section. They are already included in the
// replacement helpers.php above. Shown separately for clarity.
/**
* Generate a URL to serve an uploaded file through the secure endpoint.
* Usage in templates: <img src="<?= upload_url('members/photos/abc.jpg') ?>">
*/
function upload_url(?string $relativePath): string
{
if (!$relativePath) return '';
return '/api/uploads/serve?path=' . urlencode($relativePath);
}
\ No newline at end of file
<?php
/**
* AL-ARCADE Club Management ERP — Main Entry Point
* ALL page requests funnel through here.
*/
define('ROOT_PATH', __DIR__);
require_once ROOT_PATH . '/core/bootstrap.php';
$router = new Router();
$router->dispatch();
\ No newline at end of file
Deny from all
\ No newline at end of file
<?php
/**
* Dashboard — main landing page after login.
* Loads widgets from /pages/dashboard/widgets/*.php
*/
$pageTitle = is_rtl() ? 'لوحة التحكم' : 'Dashboard';
$breadcrumbs = [
['label' => is_rtl() ? 'الرئيسية' : 'Home', 'url' => '/dashboard'],
];
include ROOT_PATH . '/templates/header.php';
$user = current_user();
?>
<div class="container-fluid">
<div class="page-header">
<h1 class="page-header-title">
<?= e(is_rtl() ? 'مرحباً، ' . ($user['full_name_ar'] ?? $user['username']) : 'Welcome, ' . ($user['full_name'] ?? $user['username'])) ?>
</h1>
<div class="page-header-actions">
<span class="text-muted"><?= e(format_datetime(date('Y-m-d H:i:s'))) ?></span>
</div>
</div>
<!-- Dashboard Widgets Area -->
<div class="row" id="dashboardWidgets">
<?php
// Load all widget files from the widgets directory
$widgetsDir = ROOT_PATH . '/pages/dashboard/widgets';
if (is_dir($widgetsDir)) {
$widgetFiles = glob($widgetsDir . '/*.php');
sort($widgetFiles);
foreach ($widgetFiles as $widgetFile) {
include $widgetFile;
}
}
?>
</div>
</div>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
\ No newline at end of file
<?php
/**
* Dashboard Widget: System Info
* Phase 0 built-in widget — shows PHP version, DB status, etc.
*/
if (!has_permission('admin.access')) return;
$dbOk = true;
try {
db_scalar("SELECT 1");
} catch (\Throwable $e) {
$dbOk = false;
}
?>
<div class="col-lg-3 col-md-6">
<div class="stat-card bg-info-gradient">
<div class="stat-card-icon"><i class="fas fa-server"></i></div>
<div class="stat-card-value"><?= e(app_config('version')) ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'إصدار النظام' : 'System Version') ?></div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="stat-card <?= $dbOk ? 'bg-success-gradient' : 'bg-danger-gradient' ?>">
<div class="stat-card-icon"><i class="fas fa-database"></i></div>
<div class="stat-card-value"><?= $dbOk ? (is_rtl() ? 'متصل' : 'Online') : (is_rtl() ? 'منقطع' : 'Offline') ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'قاعدة البيانات' : 'Database') ?></div>
</div>
</div>
\ No newline at end of file
<?php
/**
* Dashboard Widget: Welcome / Quick Stats
* Phase 0 built-in widget.
*/
$totalUsers = db_count('users', 'deleted_at IS NULL AND is_active = 1');
?>
<!-- Welcome Widget -->
<div class="col-lg-3 col-md-6">
<div class="stat-card bg-primary-gradient">
<div class="stat-card-icon"><i class="fas fa-users"></i></div>
<div class="stat-card-value"><?= number_format($totalUsers) ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'المستخدمين النشطين' : 'Active Users') ?></div>
</div>
</div>
\ No newline at end of file
<?php
/**
* 403 Forbidden
*/
http_response_code(403);
$pageTitle = is_rtl() ? 'غير مصرح' : 'Forbidden';
if (Auth::check()) {
include ROOT_PATH . '/templates/header.php';
}
?>
<?php if (!Auth::check()): ?>
<!DOCTYPE html>
<html lang="<?= lang_attr() ?>" dir="<?= dir_attr() ?>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>403</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>body{font-family:'Cairo',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9;}</style>
</head><body>
<?php endif; ?>
<div class="container-fluid">
<div class="empty-state">
<div class="empty-state-icon"><i class="fas fa-lock"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#e74c3c;">403</h1>
<div class="empty-state-title"><?= e(is_rtl() ? 'غير مصرح لك بالوصول' : 'Access Denied') ?></div>
<p class="text-muted"><?= e(is_rtl() ? 'ليس لديك الصلاحية للوصول إلى هذه الصفحة.' : 'You do not have permission to access this page.') ?></p>
<a href="/dashboard" class="btn btn-primary btn-icon mt-3">
<i class="fas fa-home"></i> <?= e(is_rtl() ? 'العودة للرئيسية' : 'Back to Dashboard') ?>
</a>
</div>
</div>
<?php if (Auth::check()): ?>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
<?php else: ?>
</body></html>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* 404 Not Found
*/
http_response_code(404);
$pageTitle = is_rtl() ? 'الصفحة غير موجودة' : 'Page Not Found';
if (Auth::check()) {
include ROOT_PATH . '/templates/header.php';
}
?>
<?php if (!Auth::check()): ?>
<!DOCTYPE html>
<html lang="<?= lang_attr() ?>" dir="<?= dir_attr() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>body{font-family:'Cairo',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9;}</style>
</head>
<body>
<?php endif; ?>
<div class="container-fluid">
<div class="empty-state">
<div class="empty-state-icon"><i class="fas fa-search"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#1a5276;">404</h1>
<div class="empty-state-title"><?= e(is_rtl() ? 'الصفحة غير موجودة' : 'Page Not Found') ?></div>
<p class="text-muted"><?= e(is_rtl() ? 'الصفحة التي تبحث عنها غير موجودة أو تم نقلها.' : 'The page you are looking for does not exist or has been moved.') ?></p>
<a href="/dashboard" class="btn btn-primary btn-icon mt-3">
<i class="fas fa-home"></i> <?= e(is_rtl() ? 'العودة للرئيسية' : 'Back to Dashboard') ?>
</a>
</div>
</div>
<?php if (Auth::check()): ?>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
<?php else: ?>
</body></html>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* 500 Internal Server Error
*/
if (!http_response_code()) {
http_response_code(500);
}
?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 — Server Error</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Cairo', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #f1f5f9; text-align: center; }
</style>
</head>
<body>
<div>
<div style="font-size:48px;color:#64748b;margin-bottom:16px;"><i class="fas fa-exclamation-triangle"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#e74c3c;">500</h1>
<h3>حدث خطأ في الخادم</h3>
<p class="text-muted">نعتذر عن هذا الخطأ. يرجى المحاولة لاحقاً.</p>
<p class="text-muted">An internal server error occurred. Please try again later.</p>
<a href="/dashboard" class="btn btn-primary mt-3"><i class="fas fa-home"></i> العودة للرئيسية</a>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Login Page — standalone (no sidebar/navbar).
*/
// If already logged in, redirect to dashboard
if (Auth::check()) {
redirect('/dashboard');
}
$error = Session::getFlash('error');
$success = Session::getFlash('success');
// Handle POST login
if (is_post()) {
csrf_require();
$username = input('username', '', 'POST');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
$error = is_rtl() ? 'يرجى إدخال اسم المستخدم وكلمة المرور' : 'Please enter username and password';
} else {
$result = Auth::attempt($username, $password, client_ip(), $_SERVER['HTTP_USER_AGENT'] ?? '');
if ($result['success']) {
redirect('/dashboard');
} else {
$error = is_rtl() ? $result['message'] : $result['message_en'];
}
}
}
$direction = dir_attr();
$lang = lang_attr();
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(is_rtl() ? 'تسجيل الدخول' : 'Login') ?><?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></title>
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link href="<?= asset_url('css/login.css') ?>" rel="stylesheet">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-header-icon">
<i class="fas fa-trophy"></i>
</div>
<h1><?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></h1>
<p><?= e(is_rtl() ? 'تسجيل الدخول إلى لوحة التحكم' : 'Sign in to your dashboard') ?></p>
</div>
<div class="login-body">
<?php if ($error): ?>
<div class="login-error">
<i class="fas fa-exclamation-circle"></i> <?= e($error) ?>
</div>
<?php endif; ?>
<?php if ($success): ?>
<div class="login-success">
<i class="fas fa-check-circle"></i> <?= e($success) ?>
</div>
<?php endif; ?>
<form method="POST" action="/login" autocomplete="on">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="username"><?= e(is_rtl() ? 'اسم المستخدم' : 'Username') ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="username" name="username"
value="<?= e(input('username', '', 'POST')) ?>"
placeholder="<?= e(is_rtl() ? 'أدخل اسم المستخدم' : 'Enter your username') ?>"
required autofocus autocomplete="username">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="password"><?= e(is_rtl() ? 'كلمة المرور' : 'Password') ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password" name="password"
placeholder="<?= e(is_rtl() ? 'أدخل كلمة المرور' : 'Enter your password') ?>"
required autocomplete="current-password">
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i>
<?= e(is_rtl() ? 'تسجيل الدخول' : 'Sign In') ?>
</button>
</form>
</div>
<div class="login-footer">
<a href="/password-reset"><?= e(is_rtl() ? 'نسيت كلمة المرور؟' : 'Forgot your password?') ?></a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Logout handler.
*/
Auth::logout();
Session::start();
flash('success', is_rtl() ? 'تم تسجيل الخروج بنجاح' : 'Logged out successfully');
redirect('/login');
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Require all denied
\ No newline at end of file
<?php
/**
* Breadcrumb — auto-generated from URL segments, with overrides.
* Pages can set: $breadcrumbs = [['label' => '...', 'url' => '...'], ...]
*/
if (!isset($breadcrumbs) || empty($breadcrumbs)) {
// Auto-generate from URL
$segments = $GLOBALS['_route_segments'] ?? [];
$breadcrumbs = [['label' => is_rtl() ? 'الرئيسية' : 'Home', 'url' => '/dashboard']];
$path = '';
foreach ($segments as $seg) {
$path .= '/' . $seg;
$breadcrumbs[] = ['label' => ucfirst(str_replace('-', ' ', $seg)), 'url' => $path];
}
}
if (!empty($breadcrumbs)):
?>
<div class="container-fluid">
<nav aria-label="breadcrumb" class="app-breadcrumb">
<ol class="breadcrumb">
<?php foreach ($breadcrumbs as $i => $crumb): ?>
<?php $isLast = ($i === count($breadcrumbs) - 1); ?>
<?php if ($isLast): ?>
<li class="breadcrumb-item active" aria-current="page"><?= e($crumb['label']) ?></li>
<?php else: ?>
<li class="breadcrumb-item"><a href="<?= e($crumb['url']) ?>"><?= e($crumb['label']) ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ol>
</nav>
</div>
<?php endif; ?>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# Deny all direct access to uploaded files
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [F,L]
</IfModule>
# Fallback
<FilesMatch ".*">
Require all denied
</FilesMatch>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment