Security fixes n.a.v. pentest op noorlander.info
- CSRF token toegevoegd aan admin login formulier - Directory listing uitgeschakeld (Options -Indexes) - Secure/SameSite=Strict cookie verbeterd in AdminAuth - app.js.backup en source maps verwijderd - Version disclosure configureerbaar via config.json - .map en backup extensies geblokkeerd in .htaccess
This commit is contained in:
@@ -1,10 +1,19 @@
|
|||||||
|
# Disable directory listing globally
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
# Security - Block access to entire application
|
# Security - Block access to entire application
|
||||||
<Files ~ "^\.">
|
<Files ~ "^\.">
|
||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</Files>
|
</Files>
|
||||||
|
|
||||||
<FilesMatch "\.(php|ini|log|conf|config|md)$">
|
<FilesMatch "\.(php|ini|log|conf|config|md|map)$">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Block access to backup files
|
||||||
|
<FilesMatch "\.(backup|bak|old|orig|swp|save)$">
|
||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|||||||
+10
-2
@@ -39,12 +39,15 @@ class AdminAuth
|
|||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
$timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
|
$timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
|
||||||
|
$isHttps = !empty($_SERVER['HTTPS'])
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on');
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => $timeout,
|
'lifetime' => $timeout,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => isset($_SERVER['HTTPS']),
|
'secure' => $isHttps,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax'
|
'samesite' => 'Strict'
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -139,6 +142,11 @@ class AdminAuth
|
|||||||
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function regenerateCsrfToken(): void
|
||||||
|
{
|
||||||
|
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
// --- User Management ---
|
// --- User Management ---
|
||||||
|
|
||||||
public function getUsers(): array
|
public function getUsers(): array
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form method="POST" action="admin.php?route=login">
|
<form method="POST" action="admin.php?route=login">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|||||||
@@ -1027,7 +1027,7 @@ private function getGuidePage() {
|
|||||||
'author_git' => 'https://git.noorlander.info/E.Noorlander',
|
'author_git' => 'https://git.noorlander.info/E.Noorlander',
|
||||||
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
|
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
|
||||||
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
|
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
|
||||||
'cms_version' => isset($this->config['version_info']) ? 'v' . $this->config['version_info']['version'] : '',
|
'cms_version' => ($this->config['show_version'] ?? true) && isset($this->config['version_info']) ? 'v' . $this->config['version_info']['version'] : '',
|
||||||
// Theme colors
|
// Theme colors
|
||||||
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
|
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
|
||||||
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
|
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"name": "E. Noorlander",
|
"name": "E. Noorlander",
|
||||||
"website": "https:\/\/noorlander.info"
|
"website": "https:\/\/noorlander.info"
|
||||||
},
|
},
|
||||||
|
"show_version": false,
|
||||||
"features": {
|
"features": {
|
||||||
"auto_link_pages": true,
|
"auto_link_pages": true,
|
||||||
"search_enabled": true,
|
"search_enabled": true,
|
||||||
|
|||||||
+9
-10
@@ -1,23 +1,22 @@
|
|||||||
|
# Disable directory listing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
# Security - Block access to sensitive files and directories
|
# Security - Block access to sensitive files and directories
|
||||||
<Files ~ "^\.">
|
<Files ~ "^\.">
|
||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</Files>
|
</Files>
|
||||||
|
|
||||||
<FilesMatch "\.(php|ini|log|conf|config)$">
|
<FilesMatch "\.(php|ini|log|conf|config|map)$">
|
||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Block access to core directories
|
# Block access to backup files
|
||||||
<IfModule mod_authz_core.c>
|
<FilesMatch "\.(backup|bak|old|orig|swp|save)$">
|
||||||
<RequireAll>
|
Order allow,deny
|
||||||
Require all granted
|
Deny from all
|
||||||
<RequireNone>
|
</FilesMatch>
|
||||||
Require all denied
|
|
||||||
</RequireNone>
|
|
||||||
</RequireAll>
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# Directory protection
|
# Directory protection
|
||||||
<Directory ~ "^\.|/(config|templates|vendor|cache)/">
|
<Directory ~ "^\.|/(config|templates|vendor|cache)/">
|
||||||
|
|||||||
+14
-7
@@ -133,17 +133,24 @@ function handleLogin(AdminAuth $auth): void
|
|||||||
$error = '';
|
$error = '';
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$username = trim($_POST['username'] ?? '');
|
$csrfToken = $_POST['csrf_token'] ?? '';
|
||||||
$password = $_POST['password'] ?? '';
|
if (!$auth->verifyCsrf($csrfToken)) {
|
||||||
|
$error = 'Ongeldige CSRF token. Probeer opnieuw.';
|
||||||
|
} else {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
$result = $auth->login($username, $password);
|
$result = $auth->login($username, $password);
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
header('Location: admin.php?route=dashboard');
|
$auth->regenerateCsrfToken();
|
||||||
exit;
|
header('Location: admin.php?route=dashboard');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = $result['message'];
|
||||||
}
|
}
|
||||||
$error = $result['message'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$csrfToken = $auth->getCsrfToken();
|
||||||
require __DIR__ . '/../admin/templates/login.php';
|
require __DIR__ . '/../admin/templates/login.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,743 +0,0 @@
|
|||||||
// Main application JavaScript for CodePress CMS
|
|
||||||
// Enhanced with PWA support and accessibility features
|
|
||||||
|
|
||||||
// Initialize application when DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
console.log('CodePress CMS v1.5.0 initialized');
|
|
||||||
|
|
||||||
// Register Service Worker for PWA
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
.then(function(registration) {
|
|
||||||
console.log('Service Worker registered:', registration.scope);
|
|
||||||
})
|
|
||||||
.catch(function(error) {
|
|
||||||
console.log('Service Worker registration failed:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle nested dropdowns for touch devices
|
|
||||||
initializeDropdowns();
|
|
||||||
|
|
||||||
// Initialize accessibility features
|
|
||||||
initializeAccessibility();
|
|
||||||
|
|
||||||
// Initialize form validation
|
|
||||||
initializeFormValidation();
|
|
||||||
|
|
||||||
// Initialize PWA features
|
|
||||||
initializePWA();
|
|
||||||
|
|
||||||
// Initialize search enhancements
|
|
||||||
initializeSearch();
|
|
||||||
|
|
||||||
// Run accessibility tests in development
|
|
||||||
if (window.location.hostname === 'localhost') {
|
|
||||||
setTimeout(runAccessibilityTests, 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dropdown menu handling
|
|
||||||
function initializeDropdowns() {
|
|
||||||
const dropdownSubmenus = document.querySelectorAll('.dropdown-submenu');
|
|
||||||
|
|
||||||
dropdownSubmenus.forEach(function(submenu) {
|
|
||||||
const toggle = submenu.querySelector('.dropdown-toggle');
|
|
||||||
const dropdown = submenu.querySelector('.dropdown-menu');
|
|
||||||
|
|
||||||
if (toggle && dropdown) {
|
|
||||||
// Prevent default link behavior
|
|
||||||
toggle.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Close other submenus at the same level
|
|
||||||
const parent = submenu.parentElement;
|
|
||||||
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
|
||||||
if (sibling !== submenu) {
|
|
||||||
sibling.querySelector('.dropdown-menu').classList.remove('show');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle current submenu
|
|
||||||
dropdown.classList.toggle('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close submenu when clicking outside
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (!submenu.contains(e.target)) {
|
|
||||||
dropdown.classList.remove('show');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard navigation for dropdowns
|
|
||||||
toggle.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
dropdown.classList.toggle('show');
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
dropdown.classList.remove('show');
|
|
||||||
toggle.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accessibility enhancements
|
|
||||||
function initializeAccessibility() {
|
|
||||||
// High contrast mode detection
|
|
||||||
if (window.matchMedia && window.matchMedia('(prefers-contrast: high)').matches) {
|
|
||||||
document.documentElement.classList.add('high-contrast');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduced motion preference
|
|
||||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
||||||
document.documentElement.classList.add('reduced-motion');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus management
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
// Close modals with Escape
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const openModals = document.querySelectorAll('.modal.show');
|
|
||||||
openModals.forEach(modal => {
|
|
||||||
const bsModal = bootstrap.Modal.getInstance(modal);
|
|
||||||
if (bsModal) bsModal.hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdowns
|
|
||||||
const openDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
|
||||||
openDropdowns.forEach(dropdown => {
|
|
||||||
dropdown.classList.remove('show');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Announce dynamic content changes to screen readers
|
|
||||||
const observer = new MutationObserver(function(mutations) {
|
|
||||||
mutations.forEach(function(mutation) {
|
|
||||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
|
||||||
// Announce new content
|
|
||||||
announceToScreenReader('Content updated', 'polite');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus trap for modals
|
|
||||||
document.addEventListener('shown.bs.modal', function(e) {
|
|
||||||
const modal = e.target;
|
|
||||||
trapFocus(modal);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('hidden.bs.modal', function(e) {
|
|
||||||
const modal = e.target;
|
|
||||||
releaseFocusTrap(modal);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enhanced keyboard navigation
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
// Skip to content with Ctrl+Home
|
|
||||||
if (e.ctrlKey && e.key === 'Home') {
|
|
||||||
e.preventDefault();
|
|
||||||
const mainContent = document.getElementById('main-content');
|
|
||||||
if (mainContent) {
|
|
||||||
mainContent.focus();
|
|
||||||
mainContent.scrollIntoView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// PWA functionality
|
|
||||||
function initializePWA() {
|
|
||||||
// Install prompt handling
|
|
||||||
let deferredPrompt;
|
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
deferredPrompt = e;
|
|
||||||
|
|
||||||
// Show install button if desired
|
|
||||||
const installButton = document.createElement('button');
|
|
||||||
installButton.textContent = 'Install App';
|
|
||||||
installButton.className = 'btn btn-primary position-fixed bottom-0 end-0 m-3 d-none d-md-block';
|
|
||||||
installButton.style.zIndex = '1050';
|
|
||||||
|
|
||||||
installButton.addEventListener('click', function() {
|
|
||||||
deferredPrompt.prompt();
|
|
||||||
deferredPrompt.userChoice.then(function(choiceResult) {
|
|
||||||
if (choiceResult.outcome === 'accepted') {
|
|
||||||
console.log('User accepted the install prompt');
|
|
||||||
}
|
|
||||||
deferredPrompt = null;
|
|
||||||
document.body.removeChild(installButton);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(installButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Online/offline status
|
|
||||||
window.addEventListener('online', function() {
|
|
||||||
console.log('Connection restored');
|
|
||||||
showToast('Connection restored', 'success');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('offline', function() {
|
|
||||||
console.log('Connection lost');
|
|
||||||
showToast('You are offline', 'warning');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form validation and error handling
|
|
||||||
function initializeFormValidation() {
|
|
||||||
const forms = document.querySelectorAll('form');
|
|
||||||
|
|
||||||
forms.forEach(function(form) {
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
if (!validateForm(form)) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Focus first invalid field
|
|
||||||
const firstInvalid = form.querySelector('[aria-invalid="true"]');
|
|
||||||
if (firstInvalid) {
|
|
||||||
firstInvalid.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Real-time validation
|
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
inputs.forEach(function(input) {
|
|
||||||
input.addEventListener('blur', function() {
|
|
||||||
validateField(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
// Clear errors on input
|
|
||||||
clearFieldError(input);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate entire form
|
|
||||||
function validateForm(form) {
|
|
||||||
let isValid = true;
|
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
|
|
||||||
inputs.forEach(function(input) {
|
|
||||||
if (!validateField(input)) {
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate individual field
|
|
||||||
function validateField(field) {
|
|
||||||
const value = field.value.trim();
|
|
||||||
let isValid = true;
|
|
||||||
let errorMessage = '';
|
|
||||||
|
|
||||||
// Required field validation
|
|
||||||
if (field.hasAttribute('required') && !value) {
|
|
||||||
isValid = false;
|
|
||||||
errorMessage = 'This field is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email validation
|
|
||||||
if (field.type === 'email' && value) {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(value)) {
|
|
||||||
isValid = false;
|
|
||||||
errorMessage = 'Please enter a valid email address';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search field validation (minimum length)
|
|
||||||
if (field.type === 'search' && value && value.length < 2) {
|
|
||||||
isValid = false;
|
|
||||||
errorMessage = 'Please enter at least 2 characters';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update field state
|
|
||||||
field.setAttribute('aria-invalid', isValid ? 'false' : 'true');
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
showFieldError(field, errorMessage);
|
|
||||||
} else {
|
|
||||||
clearFieldError(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show field error
|
|
||||||
function showFieldError(field, message) {
|
|
||||||
// Remove existing error
|
|
||||||
clearFieldError(field);
|
|
||||||
|
|
||||||
// Create error message
|
|
||||||
const errorDiv = document.createElement('div');
|
|
||||||
errorDiv.className = 'invalid-feedback d-block';
|
|
||||||
errorDiv.setAttribute('role', 'alert');
|
|
||||||
errorDiv.setAttribute('aria-live', 'polite');
|
|
||||||
errorDiv.textContent = message;
|
|
||||||
|
|
||||||
// Add error class to field
|
|
||||||
field.classList.add('is-invalid');
|
|
||||||
|
|
||||||
// Insert error after field
|
|
||||||
field.parentNode.insertBefore(errorDiv, field.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear field error
|
|
||||||
function clearFieldError(field) {
|
|
||||||
field.classList.remove('is-invalid');
|
|
||||||
const errorDiv = field.parentNode.querySelector('.invalid-feedback');
|
|
||||||
if (errorDiv) {
|
|
||||||
errorDiv.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced search functionality
|
|
||||||
function initializeSearch() {
|
|
||||||
const searchInputs = document.querySelectorAll('input[type="search"]');
|
|
||||||
|
|
||||||
searchInputs.forEach(function(input) {
|
|
||||||
// Clear search on Escape
|
|
||||||
input.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
input.value = '';
|
|
||||||
input.blur();
|
|
||||||
announceToScreenReader('Search cleared', 'polite');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-focus search on '/' key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) {
|
|
||||||
e.preventDefault();
|
|
||||||
input.focus();
|
|
||||||
announceToScreenReader('Search input focused', 'polite');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Announce search results
|
|
||||||
input.addEventListener('input', debounce(function() {
|
|
||||||
if (input.value.length > 0) {
|
|
||||||
announceToScreenReader(`Searching for: ${input.value}`, 'polite');
|
|
||||||
}
|
|
||||||
}, 500));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toast notification system
|
|
||||||
function showToast(message, type = 'info') {
|
|
||||||
// Create toast container if it doesn't exist
|
|
||||||
let toastContainer = document.querySelector('.toast-container');
|
|
||||||
if (!toastContainer) {
|
|
||||||
toastContainer = document.createElement('div');
|
|
||||||
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
|
|
||||||
toastContainer.style.zIndex = '1060';
|
|
||||||
document.body.appendChild(toastContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create toast
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast align-items-center text-white bg-${type} border-0`;
|
|
||||||
toast.setAttribute('role', 'alert');
|
|
||||||
toast.setAttribute('aria-live', 'assertive');
|
|
||||||
toast.setAttribute('aria-atomic', 'true');
|
|
||||||
|
|
||||||
toast.innerHTML = `
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="toast-body">${message}</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
toastContainer.appendChild(toast);
|
|
||||||
|
|
||||||
// Initialize and show toast
|
|
||||||
const bsToast = new bootstrap.Toast(toast);
|
|
||||||
bsToast.show();
|
|
||||||
|
|
||||||
// Remove toast after it's hidden
|
|
||||||
toast.addEventListener('hidden.bs.toast', function() {
|
|
||||||
toast.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions for accessibility
|
|
||||||
function announceToScreenReader(message, priority = 'polite') {
|
|
||||||
// Remove existing announcements
|
|
||||||
const existing = document.querySelectorAll('[aria-live]');
|
|
||||||
existing.forEach(el => {
|
|
||||||
if (el !== document.querySelector('.sr-only[aria-live]')) el.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('aria-live', priority);
|
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
|
||||||
announcement.className = 'sr-only';
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
// Remove after announcement
|
|
||||||
setTimeout(() => {
|
|
||||||
if (announcement.parentNode) {
|
|
||||||
document.body.removeChild(announcement);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function trapFocus(element) {
|
|
||||||
const focusableElements = element.querySelectorAll(
|
|
||||||
'a[href], button, textarea, input[type="text"], input[type="search"], ' +
|
|
||||||
'input[type="email"], select, [tabindex]:not([tabindex="-1"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (focusableElements.length === 0) return null;
|
|
||||||
|
|
||||||
const firstElement = focusableElements[0];
|
|
||||||
const lastElement = focusableElements[focusableElements.length - 1];
|
|
||||||
|
|
||||||
function handleTab(e) {
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (document.activeElement === firstElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
lastElement.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
element.addEventListener('keydown', handleTab);
|
|
||||||
|
|
||||||
// Focus first element
|
|
||||||
firstElement.focus();
|
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return function() {
|
|
||||||
element.removeEventListener('keydown', handleTab);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseFocusTrap(element) {
|
|
||||||
// Focus trap is automatically released when event listener is removed
|
|
||||||
// This function can be extended for additional cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accessibility testing function
|
|
||||||
function runAccessibilityTests() {
|
|
||||||
console.log('🧪 Running Accessibility Tests...');
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
passed: 0,
|
|
||||||
failed: 0,
|
|
||||||
warnings: 0,
|
|
||||||
total: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test 1: Check for alt text on images
|
|
||||||
const images = document.querySelectorAll('img');
|
|
||||||
images.forEach(img => {
|
|
||||||
results.total++;
|
|
||||||
if (!img.hasAttribute('alt') && !img.hasAttribute('role') && img.getAttribute('role') !== 'presentation') {
|
|
||||||
console.warn('⚠️ Image missing alt text:', img.src);
|
|
||||||
results.warnings++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: Check for form labels
|
|
||||||
const inputs = document.querySelectorAll('input:not([type="hidden"]), select, textarea');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
results.total++;
|
|
||||||
const label = document.querySelector(`label[for="${input.id}"]`);
|
|
||||||
const ariaLabel = input.getAttribute('aria-label');
|
|
||||||
const ariaLabelledBy = input.getAttribute('aria-labelledby');
|
|
||||||
|
|
||||||
if (!label && !ariaLabel && !ariaLabelledBy) {
|
|
||||||
console.error('❌ Form control missing label:', input.name || input.id);
|
|
||||||
results.failed++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 3: Check heading hierarchy
|
|
||||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
||||||
let lastLevel = 0;
|
|
||||||
|
|
||||||
headings.forEach(heading => {
|
|
||||||
results.total++;
|
|
||||||
const level = parseInt(heading.tagName.charAt(1));
|
|
||||||
|
|
||||||
if (level - lastLevel > 1 && lastLevel !== 0) {
|
|
||||||
console.warn('⚠️ Skipped heading level:', heading.textContent.trim().substring(0, 50));
|
|
||||||
results.warnings++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLevel = level;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 4: Check ARIA landmarks
|
|
||||||
results.total++;
|
|
||||||
const landmarks = document.querySelectorAll('[role="banner"], [role="main"], [role="complementary"], [role="contentinfo"], header, main, aside, footer');
|
|
||||||
const uniqueRoles = new Set();
|
|
||||||
|
|
||||||
landmarks.forEach(element => {
|
|
||||||
const role = element.getAttribute('role') || element.tagName.toLowerCase();
|
|
||||||
uniqueRoles.add(role);
|
|
||||||
});
|
|
||||||
|
|
||||||
const requiredRoles = ['banner', 'main', 'contentinfo'];
|
|
||||||
let hasRequired = true;
|
|
||||||
|
|
||||||
requiredRoles.forEach(role => {
|
|
||||||
if (!uniqueRoles.has(role)) {
|
|
||||||
console.error(`❌ Missing ARIA landmark: ${role}`);
|
|
||||||
hasRequired = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasRequired) {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
results.failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 5: Check focus indicators
|
|
||||||
results.total++;
|
|
||||||
const focusableElements = document.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
||||||
|
|
||||||
if (focusableElements.length === 0) {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
// Check if focus styles are defined in CSS
|
|
||||||
const computedStyle = getComputedStyle(focusableElements[0]);
|
|
||||||
const outline = computedStyle.outline;
|
|
||||||
const boxShadow = computedStyle.boxShadow;
|
|
||||||
|
|
||||||
if (outline !== 'none' && outline !== '' && outline !== '0px none rgb(0, 0, 0)') {
|
|
||||||
results.passed++;
|
|
||||||
} else if (boxShadow && boxShadow !== 'none') {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Focus indicators may not be visible');
|
|
||||||
results.warnings++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log(`\n📊 Accessibility Test Results:`);
|
|
||||||
console.log(`✅ Passed: ${results.passed}`);
|
|
||||||
console.log(`❌ Failed: ${results.failed}`);
|
|
||||||
console.log(`⚠️ Warnings: ${results.warnings}`);
|
|
||||||
console.log(`📈 Success Rate: ${Math.round((results.passed / results.total) * 100)}%`);
|
|
||||||
|
|
||||||
if (results.failed === 0 && results.warnings === 0) {
|
|
||||||
console.log('🎉 All accessibility tests passed!');
|
|
||||||
} else if (results.failed === 0) {
|
|
||||||
console.log('👍 Accessibility compliant with minor warnings');
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ Accessibility issues found - review and fix');
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions for accessibility
|
|
||||||
function announceToScreenReader(message, priority = 'polite') {
|
|
||||||
// Remove existing announcements
|
|
||||||
const existing = document.querySelectorAll('[aria-live]');
|
|
||||||
existing.forEach(el => {
|
|
||||||
if (el !== document.querySelector('.sr-only[aria-live]')) el.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('aria-live', priority);
|
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
|
||||||
announcement.className = 'sr-only';
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
// Remove after announcement
|
|
||||||
setTimeout(() => {
|
|
||||||
if (announcement.parentNode) {
|
|
||||||
document.body.removeChild(announcement);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseFocusTrap(element) {
|
|
||||||
// Focus trap is automatically released when event listener is removed
|
|
||||||
// This function can be extended for additional cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accessibility testing function
|
|
||||||
function runAccessibilityTests() {
|
|
||||||
console.log('🧪 Running Accessibility Tests...');
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
passed: 0,
|
|
||||||
failed: 0,
|
|
||||||
warnings: 0,
|
|
||||||
total: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test 1: Check for alt text on images
|
|
||||||
const images = document.querySelectorAll('img');
|
|
||||||
images.forEach(img => {
|
|
||||||
results.total++;
|
|
||||||
if (!img.hasAttribute('alt') && !img.hasAttribute('role') && img.getAttribute('role') !== 'presentation') {
|
|
||||||
console.warn('⚠️ Image missing alt text:', img.src);
|
|
||||||
results.warnings++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: Check for form labels
|
|
||||||
const inputs = document.querySelectorAll('input:not([type="hidden"]), select, textarea');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
results.total++;
|
|
||||||
const label = document.querySelector(`label[for="${input.id}"]`);
|
|
||||||
const ariaLabel = input.getAttribute('aria-label');
|
|
||||||
const ariaLabelledBy = input.getAttribute('aria-labelledby');
|
|
||||||
|
|
||||||
if (!label && !ariaLabel && !ariaLabelledBy) {
|
|
||||||
console.error('❌ Form control missing label:', input.name || input.id);
|
|
||||||
results.failed++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 3: Check heading hierarchy
|
|
||||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
||||||
let lastLevel = 0;
|
|
||||||
|
|
||||||
headings.forEach(heading => {
|
|
||||||
results.total++;
|
|
||||||
const level = parseInt(heading.tagName.charAt(1));
|
|
||||||
|
|
||||||
if (level - lastLevel > 1 && lastLevel !== 0) {
|
|
||||||
console.warn('⚠️ Skipped heading level:', heading.textContent.trim().substring(0, 50));
|
|
||||||
results.warnings++;
|
|
||||||
} else {
|
|
||||||
results.passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLevel = level;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 4: Check ARIA landmarks
|
|
||||||
results.total++;
|
|
||||||
const landmarks = document.querySelectorAll('[role="banner"], [role="main"], [role="complementary"], [role="contentinfo"], header, main, aside, footer');
|
|
||||||
const uniqueRoles = new Set();
|
|
||||||
|
|
||||||
landmarks.forEach(element => {
|
|
||||||
const role = element.getAttribute('role') || element.tagName.toLowerCase();
|
|
||||||
uniqueRoles.add(role);
|
|
||||||
});
|
|
||||||
|
|
||||||
const requiredRoles = ['banner', 'main', 'contentinfo'];
|
|
||||||
let hasRequired = true;
|
|
||||||
|
|
||||||
requiredRoles.forEach(role => {
|
|
||||||
if (!uniqueRoles.has(role)) {
|
|
||||||
console.error(`❌ Missing ARIA landmark: ${role}`);
|
|
||||||
hasRequired = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasRequired) {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
results.failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 5: Check focus indicators
|
|
||||||
results.total++;
|
|
||||||
const focusableElements = document.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
||||||
|
|
||||||
if (focusableElements.length === 0) {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
// Check if focus styles are defined in CSS
|
|
||||||
const computedStyle = getComputedStyle(focusableElements[0]);
|
|
||||||
const outline = computedStyle.outline;
|
|
||||||
const boxShadow = computedStyle.boxShadow;
|
|
||||||
|
|
||||||
if (outline !== 'none' && outline !== '' && outline !== '0px none rgb(0, 0, 0)') {
|
|
||||||
results.passed++;
|
|
||||||
} else if (boxShadow && boxShadow !== 'none') {
|
|
||||||
results.passed++;
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Focus indicators may not be visible');
|
|
||||||
results.warnings++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log(`\n📊 Accessibility Test Results:`);
|
|
||||||
console.log(`✅ Passed: ${results.passed}`);
|
|
||||||
console.log(`❌ Failed: ${results.failed}`);
|
|
||||||
console.log(`⚠️ Warnings: ${results.warnings}`);
|
|
||||||
console.log(`📈 Success Rate: ${Math.round((results.passed / results.total) * 100)}%`);
|
|
||||||
|
|
||||||
if (results.failed === 0 && results.warnings === 0) {
|
|
||||||
console.log('🎉 All accessibility tests passed!');
|
|
||||||
} else if (results.failed === 0) {
|
|
||||||
console.log('👍 Accessibility compliant with minor warnings');
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ Accessibility issues found - review and fix');
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user