SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.
Ce qui bloque reellement
Un nonce autorise des elements <script> ; il ne peut rien pour un
attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
durcie.
Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
les deux ne serait donc pas une transition douce -- ce serait couper
d'un coup tous les scripts en ligne et tous les onclick, et uniquement
sur les navigateurs recents. La bascule doit etre atomique, d'ou un
drapeau unique : CSP_ALLOW_INLINE_SCRIPT.
Infrastructure posee
build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
<script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
bascule finale sera un changement de configuration, pas de gabarits.
Couche partagee migree en premier
base.html et macros.html sont rendus sur absolument toutes les pages. Six
gestionnaires retires, remplaces par des attributs data-action et un
ecouteur delegue unique dans main.js. La delegation plutot qu'un
ecouteur par widget : le contenu injecte dynamiquement herite du
comportement sans re-attachement.
Un cliquet plutot qu'une promesse
tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
mettre le budget a jour aussi, ce qui force a enregistrer la progression
dans le diff. A zero, il ne reste qu'a basculer le drapeau.
Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
verifiait pas l'espace avant l'attribut.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
496 lines
18 KiB
JavaScript
496 lines
18 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();
|
|
},
|
|
};
|
|
|
|
document.addEventListener('click', function (event) {
|
|
const trigger = event.target.closest('[data-action]');
|
|
if (!trigger) {
|
|
return;
|
|
}
|
|
const handler = DATA_ACTIONS[trigger.getAttribute('data-action')];
|
|
if (handler) {
|
|
handler(trigger, event);
|
|
}
|
|
});
|