SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.
Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.
Deux motifs generiques de plus dans main.js
data-mirror affichage direct de la valeur d'un curseur.
evaluate_player repetait le meme
oninput="this.nextElementSibling.textContent = ..."
sur ses neuf curseurs de note.
data-submit-on-change remplace onchange="this.form.submit()"
Markup genere dans des chaines JavaScript
match_form construisait sept gestionnaires par concatenation, en y
injectant l'identifiant du joueur. Le markup portait deja data-player-id :
returnToPool et assignToTeam lisent desormais leurs arguments depuis
l'element clique. Cela supprime a la fois l'attribut en ligne et la
concatenation qui l'alimentait. Meme motif que dans coach_availability.
Bascule
CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
La variable d'environnement reste, comme issue de secours si un
deploiement rencontrait un gestionnaire oublie -- mais la laisser active
revient a renoncer a la protection.
Le cliquet devient une garde
Le budget par gabarit est vide et les tests deviennent absolus : aucun
gestionnaire en ligne, et tout bloc <script> inline doit porter son
nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
journaux ne le signale -- d'ou le test.
Verifications
22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
par node --check.
193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.
Co-Authored-By: Claude Opus 5 <[email protected]>
598 lines
21 KiB
JavaScript
598 lines
21 KiB
JavaScript
/**
|
|
* Team Tryouts - Main JavaScript Module
|
|
*
|
|
* This module provides core UI functionality including:
|
|
* - Dark mode toggle and persistence
|
|
* - Sidebar mobile toggle
|
|
* - Draggable dashboard blocks
|
|
* - Auto-dismissing alerts
|
|
*/
|
|
|
|
/**
|
|
* Toggle dark mode theme.
|
|
*
|
|
* Switches between light and dark themes, updates the toggle button icon,
|
|
* and persists the preference in localStorage.
|
|
*/
|
|
function toggleDarkMode() {
|
|
const body = document.documentElement;
|
|
const isDark = body.getAttribute('data-theme') === 'dark';
|
|
const toggle = document.getElementById('darkModeToggle');
|
|
|
|
if (isDark) {
|
|
body.removeAttribute('data-theme');
|
|
localStorage.setItem('theme', 'light');
|
|
toggle.innerHTML = '<i class="fas fa-moon"></i>';
|
|
} else {
|
|
body.setAttribute('data-theme', 'dark');
|
|
localStorage.setItem('theme', 'dark');
|
|
toggle.innerHTML = '<i class="fas fa-sun"></i>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load saved theme preference from localStorage.
|
|
*
|
|
* Called on page load to restore the user's preferred theme.
|
|
*/
|
|
function loadTheme() {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
const toggle = document.getElementById('darkModeToggle');
|
|
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
if (toggle) {
|
|
toggle.innerHTML = '<i class="fas fa-sun"></i>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle sidebar visibility on mobile devices.
|
|
*
|
|
* Adds/removes 'open' class on sidebar to show/hide it.
|
|
*/
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
sidebar.classList.toggle('open');
|
|
}
|
|
|
|
/**
|
|
* Handle click outside sidebar to close it on mobile.
|
|
*
|
|
* Listens for document clicks and closes sidebar when clicking
|
|
* outside of it, but only on screens smaller than 768px.
|
|
*/
|
|
document.addEventListener('click', function(event) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const toggle = document.getElementById('sidebarToggle');
|
|
if (window.innerWidth <= 768) {
|
|
if (!sidebar.contains(event.target) && !toggle.contains(event.target)) {
|
|
sidebar.classList.remove('open');
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Initialize confirmation dialogs for elements with data-confirm attribute.
|
|
*
|
|
* Adds click handler to show confirmation dialog before submitting forms.
|
|
*/
|
|
document.querySelectorAll('[data-confirm]').forEach(function(el) {
|
|
el.addEventListener('click', function(e) {
|
|
if (!confirm(this.getAttribute('data-confirm'))) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Initialize draggable blocks functionality on dashboard pages.
|
|
*
|
|
* Adds drag handles to card headers and enables drag-and-drop
|
|
* reordering of cards. Saves layout to localStorage.
|
|
*/
|
|
function initDraggableBlocks() {
|
|
const grids = document.querySelectorAll('.dashboard-grid');
|
|
|
|
grids.forEach(function(grid) {
|
|
const cards = grid.querySelectorAll('.card');
|
|
|
|
// Add drag handles to card headers
|
|
cards.forEach(function(card, index) {
|
|
const header = card.querySelector('.card-header');
|
|
if (header) {
|
|
// Add unique ID to card if not present
|
|
if (!card.id) {
|
|
card.id = 'block-' + index;
|
|
}
|
|
|
|
// Create drag handle
|
|
const dragHandle = document.createElement('div');
|
|
dragHandle.className = 'drag-handle';
|
|
dragHandle.innerHTML = '<i class="fas fa-grip-vertical"></i>';
|
|
dragHandle.title = 'Drag to reorder';
|
|
|
|
// Add draggable class to header
|
|
header.classList.add('draggable');
|
|
header.prepend(dragHandle);
|
|
|
|
// Make header draggable
|
|
makeHeaderDraggable(header, card, grid);
|
|
}
|
|
});
|
|
|
|
// Load saved layout
|
|
loadLayout(grid);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make a card header draggable.
|
|
*
|
|
* @param {HTMLElement} header - The card header element
|
|
* @param {HTMLElement} card - The card element being dragged
|
|
* @param {HTMLElement} grid - The parent grid container
|
|
*/
|
|
function makeHeaderDraggable(header, card, grid) {
|
|
let draggedElement = null;
|
|
let placeholder = null;
|
|
let ghost = null;
|
|
let animationFrame = null;
|
|
|
|
header.addEventListener('mousedown', function(e) {
|
|
// Only start drag from drag handle
|
|
if (!e.target.closest('.drag-handle')) return;
|
|
|
|
e.preventDefault();
|
|
draggedElement = card;
|
|
|
|
// Get card dimensions
|
|
const rect = card.getBoundingClientRect();
|
|
const offsetX = e.clientX - rect.left;
|
|
const offsetY = e.clientY - rect.top;
|
|
|
|
// Store original data-position to restore later
|
|
const originalDataPosition = card.getAttribute('data-position');
|
|
|
|
// Remove data-position during drag to allow free placement
|
|
if (originalDataPosition) {
|
|
card.removeAttribute('data-position');
|
|
}
|
|
|
|
// Create ghost element (floating preview)
|
|
ghost = document.createElement('div');
|
|
ghost.className = 'sortable-ghost';
|
|
ghost.style.position = 'fixed';
|
|
ghost.style.pointerEvents = 'none';
|
|
ghost.style.zIndex = '99999';
|
|
ghost.style.width = rect.width + 'px';
|
|
ghost.style.height = rect.height + 'px';
|
|
ghost.style.backgroundColor = 'var(--card-bg, white)';
|
|
ghost.style.border = '2px solid var(--primary)';
|
|
ghost.style.borderRadius = 'var(--radius)';
|
|
ghost.style.opacity = '0.9';
|
|
ghost.style.boxShadow = 'var(--shadow-lg)';
|
|
ghost.style.cursor = 'grabbing';
|
|
document.body.appendChild(ghost);
|
|
|
|
// Create placeholder element (shows drop position)
|
|
placeholder = document.createElement('div');
|
|
placeholder.className = 'sortable-placeholder';
|
|
placeholder.style.minHeight = rect.height + 'px';
|
|
card.parentNode.insertBefore(placeholder, card);
|
|
|
|
// Keep original card visible but add dragging style
|
|
card.classList.add('dragging');
|
|
card.style.opacity = '0.5';
|
|
card.style.transform = 'scale(0.98)';
|
|
|
|
// Initial position
|
|
ghost.style.top = (e.clientY - offsetY) + 'px';
|
|
ghost.style.left = (e.clientX - offsetX) + 'px';
|
|
|
|
/**
|
|
* Update ghost position during drag.
|
|
* @param {number} clientY - Mouse Y coordinate
|
|
* @param {number} clientX - Mouse X coordinate
|
|
*/
|
|
function updateGhostPosition(clientY, clientX) {
|
|
// Position ghost directly at cursor position
|
|
ghost.style.top = (clientY - offsetY) + 'px';
|
|
ghost.style.left = (clientX - offsetX) + 'px';
|
|
}
|
|
|
|
// Get grid layout info for 2D position tracking
|
|
const gridRect = grid.getBoundingClientRect();
|
|
const gridStyle = window.getComputedStyle(grid);
|
|
const gridGap = parseInt(gridStyle.gap) || 20;
|
|
|
|
/**
|
|
* Calculate which column the X position falls into.
|
|
* @param {number} x - X coordinate relative to grid
|
|
* @returns {number} Column index
|
|
*/
|
|
function getColumnFromX(x) {
|
|
// Calculate which column the x position falls into
|
|
const relativeX = x - gridRect.left;
|
|
const colWidth = (gridRect.width + gridGap) / Math.max(1, Math.floor(gridRect.width / 300)); // Estimate columns based on min 300px width
|
|
return Math.floor(relativeX / (colWidth + gridGap));
|
|
}
|
|
|
|
/**
|
|
* Handle mouse movement during drag.
|
|
* @param {MouseEvent} e - Mouse event
|
|
*/
|
|
function onMouseMove(e) {
|
|
e.preventDefault();
|
|
|
|
// Cancel any pending animation frame
|
|
if (animationFrame) {
|
|
cancelAnimationFrame(animationFrame);
|
|
}
|
|
|
|
// Use requestAnimationFrame for smooth updates
|
|
animationFrame = requestAnimationFrame(function() {
|
|
updateGhostPosition(e.clientY, e.clientX);
|
|
|
|
// Use a point slightly offset from cursor to avoid ghost interference
|
|
// This ensures we detect the card under the cursor, not the ghost
|
|
const checkX = e.clientX;
|
|
const checkY = e.clientY + 10; // 10px below cursor for better detection
|
|
|
|
// Find the element under the offset point
|
|
const elementUnderCursor = document.elementFromPoint(checkX, checkY);
|
|
|
|
// Check if we're directly over the placeholder (skip processing to avoid flicker)
|
|
if (elementUnderCursor && elementUnderCursor.closest('.sortable-placeholder')) {
|
|
return; // Already over placeholder, don't change position
|
|
}
|
|
|
|
// Find the card that contains or is the element under cursor
|
|
let targetCard = null;
|
|
if (elementUnderCursor) {
|
|
targetCard = elementUnderCursor.closest('.card');
|
|
// Make sure it's not the dragged card
|
|
if (targetCard && targetCard.classList.contains('dragging')) {
|
|
targetCard = null;
|
|
}
|
|
}
|
|
|
|
// If we found a valid target card, determine insert position
|
|
if (targetCard) {
|
|
const targetRect = targetCard.getBoundingClientRect();
|
|
const targetMiddleY = targetRect.top + targetRect.height / 2;
|
|
|
|
// If cursor is above the card's middle, insert before it
|
|
// If cursor is below, insert after it
|
|
// Use checkY for consistent comparison with detection point
|
|
if (checkY < targetMiddleY) {
|
|
// Check if placeholder is already in the correct position
|
|
if (targetCard.nextElementSibling !== placeholder) {
|
|
grid.insertBefore(placeholder, targetCard);
|
|
}
|
|
} else {
|
|
// Insert after the target card
|
|
const nextCard = targetCard.nextElementSibling;
|
|
// If next sibling is the placeholder or the dragged element, it's already in the right position
|
|
if (nextCard === placeholder || nextCard === draggedElement) {
|
|
// Already in correct position, don't move
|
|
} else if (nextCard && nextCard.classList.contains('card')) {
|
|
grid.insertBefore(placeholder, nextCard);
|
|
} else {
|
|
grid.appendChild(placeholder);
|
|
}
|
|
}
|
|
} else {
|
|
// No target card found - check if we should move to end
|
|
// This handles the case where we're dragging over empty space or the grid background
|
|
// If cursor is within the grid bounds, move to end
|
|
if (e.clientX >= gridRect.left && e.clientX <= gridRect.right &&
|
|
e.clientY >= gridRect.top && e.clientY <= gridRect.bottom) {
|
|
// Check if we're over the dragged element (which is hidden but still in DOM)
|
|
const isOverDraggedElement = elementUnderCursor &&
|
|
(elementUnderCursor.closest('.card.dragging') ||
|
|
elementUnderCursor.closest('.sortable-placeholder'));
|
|
|
|
if (!isOverDraggedElement) {
|
|
// Move placeholder to end if not already there
|
|
const lastCard = grid.querySelector('.card:last-of-type');
|
|
if (lastCard && lastCard.nextElementSibling !== placeholder) {
|
|
grid.appendChild(placeholder);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle mouse release to complete drag.
|
|
* @param {MouseEvent} e - Mouse event
|
|
*/
|
|
function onMouseUp(e) {
|
|
if (animationFrame) {
|
|
cancelAnimationFrame(animationFrame);
|
|
}
|
|
|
|
if (draggedElement) {
|
|
draggedElement.classList.remove('dragging');
|
|
// Restore original card styles
|
|
draggedElement.style.opacity = '';
|
|
draggedElement.style.transform = '';
|
|
|
|
// Ensure the card is placed in the correct position
|
|
// The placeholder shows where the card should go
|
|
if (placeholder && placeholder.parentNode) {
|
|
// Move the dragged element to the placeholder position
|
|
placeholder.parentNode.replaceChild(draggedElement, placeholder);
|
|
} else {
|
|
// If no placeholder exists, append to the end
|
|
grid.appendChild(draggedElement);
|
|
}
|
|
|
|
// Save layout
|
|
saveLayout(grid);
|
|
}
|
|
|
|
if (ghost && ghost.parentNode) {
|
|
ghost.parentNode.removeChild(ghost);
|
|
}
|
|
|
|
if (placeholder && placeholder.parentNode) {
|
|
placeholder.parentNode.removeChild(placeholder);
|
|
}
|
|
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save the current card layout order to localStorage.
|
|
*
|
|
* @param {HTMLElement} grid - The grid container to save layout for
|
|
*/
|
|
function saveLayout(grid) {
|
|
const pageKey = getPageKey();
|
|
const cards = grid.querySelectorAll('.card');
|
|
const order = Array.from(cards).map(card => card.id);
|
|
localStorage.setItem('blockLayout_' + pageKey, JSON.stringify(order));
|
|
}
|
|
|
|
/**
|
|
* Load saved card layout from localStorage.
|
|
*
|
|
* @param {HTMLElement} grid - The grid container to load layout for
|
|
*/
|
|
function loadLayout(grid) {
|
|
const pageKey = getPageKey();
|
|
const saved = localStorage.getItem('blockLayout_' + pageKey);
|
|
|
|
if (saved) {
|
|
try {
|
|
const order = JSON.parse(saved);
|
|
const cards = Array.from(grid.querySelectorAll('.card'));
|
|
|
|
// Sort cards according to saved order
|
|
order.forEach(function(id) {
|
|
const card = cards.find(c => c.id === id);
|
|
if (card) {
|
|
grid.appendChild(card);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load layout:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current page key for layout storage.
|
|
*
|
|
* @returns {string} Page identifier based on URL path
|
|
*/
|
|
function getPageKey() {
|
|
const path = window.location.pathname;
|
|
if (path.includes('/tryouts/')) {
|
|
return 'tryout';
|
|
}
|
|
if (path.includes('/profile')) {
|
|
return 'profile';
|
|
}
|
|
return 'dashboard';
|
|
}
|
|
|
|
/**
|
|
* Initialize on DOM ready.
|
|
*
|
|
* Loads theme preference and initializes draggable blocks.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Load theme preference
|
|
loadTheme();
|
|
|
|
// Initialize draggable blocks
|
|
initDraggableBlocks();
|
|
|
|
/**
|
|
* Auto-dismiss flash alerts after 5 seconds.
|
|
*/
|
|
const alerts = document.querySelectorAll('.alert-dismissible');
|
|
alerts.forEach(function(alert) {
|
|
setTimeout(function() {
|
|
if (alert.parentElement) {
|
|
alert.style.opacity = '0';
|
|
alert.style.transition = 'opacity 0.3s ease';
|
|
setTimeout(function() {
|
|
if (alert.parentElement) {
|
|
alert.remove();
|
|
}
|
|
}, 300);
|
|
}
|
|
}, 5000);
|
|
});
|
|
});
|
|
/* =========================================================================
|
|
Declarative behaviours — replacing inline event handlers
|
|
=========================================================================
|
|
|
|
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
|
|
attributes, and a nonce does not help: nonces apply to <script> elements,
|
|
never to event handler attributes. Dropping 'unsafe-inline' therefore
|
|
requires removing every one of them first.
|
|
|
|
Rather than one listener per widget, behaviours are declared in the
|
|
markup with a data-action attribute and dispatched from a single
|
|
delegated listener. New markup gets the behaviour for free, and nothing
|
|
has to be re-bound after content is replaced dynamically.
|
|
|
|
<button data-action="toggle-sidebar">
|
|
<button data-action="dismiss-alert">
|
|
<div data-action="hide-modal" data-modal-id="confirmDelete">
|
|
|
|
Migration status is tracked by tests/test_csp.py.
|
|
========================================================================= */
|
|
|
|
const DATA_ACTIONS = {
|
|
'toggle-sidebar': function () {
|
|
toggleSidebar();
|
|
},
|
|
'toggle-dark-mode': function () {
|
|
toggleDarkMode();
|
|
},
|
|
'dismiss-alert': function (element) {
|
|
const alert = element.closest('.alert');
|
|
if (alert) {
|
|
alert.remove();
|
|
}
|
|
},
|
|
'hide-modal': function (element) {
|
|
const id = element.getAttribute('data-modal-id');
|
|
if (id && typeof hideModal === 'function') {
|
|
hideModal(id);
|
|
}
|
|
},
|
|
'history-back': function (element, event) {
|
|
event.preventDefault();
|
|
history.back();
|
|
},
|
|
// Removes the nearest ancestor matching data-remove, or the parent.
|
|
'remove-element': function (element) {
|
|
const selector = element.getAttribute('data-remove');
|
|
const target = selector ? element.closest(selector) : element.parentElement;
|
|
if (target) {
|
|
target.remove();
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Register behaviours defined by a single page.
|
|
*
|
|
* Page-local functions live in that page's script block, so they cannot be
|
|
* listed in DATA_ACTIONS above. Each page declares its own:
|
|
*
|
|
* registerActions({ 'clear-availability': clearAllAvailability });
|
|
*
|
|
* @param {Object} map - action name to handler(element, event).
|
|
*/
|
|
function registerActions(map) {
|
|
Object.assign(DATA_ACTIONS, map);
|
|
}
|
|
|
|
function dispatchAction(attribute, event) {
|
|
const trigger = event.target.closest('[' + attribute + ']');
|
|
if (!trigger) {
|
|
return;
|
|
}
|
|
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
|
|
if (handler) {
|
|
handler(trigger, event);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', function (event) {
|
|
dispatchAction('data-action', event);
|
|
});
|
|
|
|
// Separate attribute rather than one shared with click: a <select> would
|
|
// otherwise fire its handler on the click that opens it.
|
|
document.addEventListener('change', function (event) {
|
|
dispatchAction('data-change', event);
|
|
});
|
|
|
|
/**
|
|
* Confirmation before a destructive submit.
|
|
*
|
|
* <form data-confirm="Delete this match?">
|
|
*
|
|
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
|
* markup where it can be translated.
|
|
*/
|
|
document.addEventListener('submit', function (event) {
|
|
const form = event.target.closest('[data-confirm]');
|
|
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Live value display next to a range input.
|
|
*
|
|
* <input type="range" data-mirror>
|
|
* <span>5</span>
|
|
*
|
|
* Replaces oninput="this.nextElementSibling.textContent = this.value",
|
|
* which the evaluation form repeated on all nine score sliders.
|
|
* data-mirror may name a selector; empty means the next sibling.
|
|
*/
|
|
document.addEventListener('input', function (event) {
|
|
const input = event.target.closest('[data-mirror]');
|
|
if (!input) {
|
|
return;
|
|
}
|
|
const selector = input.getAttribute('data-mirror');
|
|
const target = selector
|
|
? document.querySelector(selector)
|
|
: input.nextElementSibling;
|
|
if (target) {
|
|
target.textContent = input.value;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Submit the surrounding form when a control changes.
|
|
*
|
|
* <select data-submit-on-change>
|
|
*
|
|
* Replaces onchange="this.form.submit()".
|
|
*/
|
|
document.addEventListener('change', function (event) {
|
|
const control = event.target.closest('[data-submit-on-change]');
|
|
if (control && control.form) {
|
|
control.form.submit();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Navigate on selection.
|
|
*
|
|
* <select data-navigate="/team-matches/{value}/create">
|
|
*
|
|
* {value} is replaced by the chosen option, URL-encoded. An empty
|
|
* selection navigates nowhere.
|
|
*/
|
|
document.addEventListener('change', function (event) {
|
|
const select = event.target.closest('[data-navigate]');
|
|
if (!select || !select.value) {
|
|
return;
|
|
}
|
|
window.location.href = select.getAttribute('data-navigate')
|
|
.replace('{value}', encodeURIComponent(select.value));
|
|
});
|