SEC-XSS-001. Chaine complete : un nom d'utilisateur libre arrivait dans le
DOM d'un coach ou d'un administrateur, en meme origine, avec sa session.
La CSP autorisant 'unsafe-inline', rien ne l'arretait.
Cote serveur - la cause
/matches/api/events construisait de la presentation dans un champ JSON :
match_desc = participants_str + f"<br>{match.description}"
Le navigateur deposait cette valeur telle quelle dans innerHTML. Les noms
de joueurs y transitaient sans echappement -- et il ne pouvait pas y en
avoir : c'est du JSON, pas du HTML.
Les deux valeurs etaient deja des cles distinctes du payload. La
concatenation faisait donc aussi afficher les participants deux fois dans
le modal : une fois dans "Teams", une fois en tete de "Description".
Corriger la faille corrige l'affichage.
Cote navigateur - le sink
showEventModal assemblait une chaine HTML puis l'affectait a innerHTML.
Remplace par une construction de noeuds : makeEl / detailItem /
multilineNode / teamNode passent tout texte par textContent. Les retours
a la ligne d'une description restent rendus, via des <br> crees en dur.
Les deux listes deroulantes concatenaient egalement titres de tryout et
noms d'equipe dans innerHTML. Remplacees par new Option(), dont le
premier argument est pose en texte.
Verification
5 tests sur le contrat de l'API, dont un avec un nom d'utilisateur
hostile ecrit directement en base -- ce que la validation refuse
desormais, mais que des lignes anterieures peuvent contenir.
Le JavaScript inline extrait passe `node --check`.
Reste ouvert : la CSP autorise toujours 'unsafe-inline' (SEC-WEB-001), donc
la defense en profondeur manque encore. Suivi en OPS-010.
Co-Authored-By: Claude Opus 5 <[email protected]>
438 lines
17 KiB
HTML
438 lines
17 KiB
HTML
{% extends "layouts/base.html" %}
|
|
{% block title %}Calendar - TryoutPro{% endblock %}
|
|
{% block page_title %}Calendar{% endblock %}
|
|
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3>
|
|
<div class="header-actions">
|
|
<div class="btn-group" role="group">
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')">
|
|
<i class="fas fa-calendar"></i> Month
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')">
|
|
<i class="fas fa-calendar-week"></i> Week
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')">
|
|
<i class="fas fa-calendar-day"></i> Day
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')">
|
|
<i class="fas fa-list"></i> List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="calendar"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Event Modal (for clicking empty days) -->
|
|
<div id="createEventModal" class="modal hidden">
|
|
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
|
|
<button class="modal-close" onclick="hideCreateEventModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
|
|
<input type="hidden" id="createEventDateInput"/>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5>
|
|
<p class="text-muted small">Add a scrim/match inside an existing tryout</p>
|
|
<div class="form-inline">
|
|
<select id="createTryoutSelect" class="form-select" style="flex:1;">
|
|
<option value="">-- Select a tryout --</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
|
|
<i class="fas fa-arrow-right"></i> Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<h5><i class="fas fa-users"></i> Schedule Team Match</h5>
|
|
<p class="text-muted small">Regular season match for an org team</p>
|
|
<div class="form-inline">
|
|
<select id="createTeamSelect" class="form-select" style="flex:1;">
|
|
<option value="">-- Select a team --</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
|
|
<i class="fas fa-arrow-right"></i> Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
|
|
<p class="text-muted small">Create a brand new tryout event</p>
|
|
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
|
|
<i class="fas fa-plus"></i> Create Tryout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Details Modal -->
|
|
<div id="eventModal" class="modal hidden">
|
|
<div class="modal-backdrop" onclick="hideEventModal()"></div>
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 id="modalTitle">Event Details</h3>
|
|
<button class="modal-close" onclick="hideEventModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="modalContent"></div>
|
|
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
|
|
<button class="btn btn-sm btn-outline" id="calPresenceBtn">Confirm</button>
|
|
</div>
|
|
<div id="modalActions" class="form-actions mt-3" style="display: none;">
|
|
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
|
|
<i class="fas fa-trash"></i> Delete Match
|
|
</button>
|
|
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
|
|
<i class="fas fa-edit"></i> Edit Match
|
|
</button>
|
|
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
|
|
<i class="fas fa-eye"></i> View Tryout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"></script>
|
|
<script>
|
|
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var calendarEl = document.getElementById('calendar');
|
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
|
initialView: 'dayGridMonth',
|
|
headerToolbar: {
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
|
},
|
|
events: '/matches/api/events',
|
|
eventClick: function(info) {
|
|
showEventModal(info.event);
|
|
},
|
|
dateClick: function(info) {
|
|
if (canScheduleMatches) {
|
|
var dateStr = info.dateStr;
|
|
showCreateEventModal(dateStr);
|
|
}
|
|
},
|
|
selectable: true,
|
|
select: function(info) {
|
|
if (canScheduleMatches) {
|
|
var dateStr = info.startStr;
|
|
showCreateEventModal(dateStr);
|
|
calendar.unselect();
|
|
}
|
|
},
|
|
slotMinTime: '12:00:00',
|
|
slotMaxTime: '24:00:00'
|
|
});
|
|
calendar.render();
|
|
|
|
window.fcCalendar = calendar;
|
|
|
|
// Pre-load tryout and team options for the create modal
|
|
if (canScheduleMatches) {
|
|
fetchTryoutOptions();
|
|
fetchTeamOptions();
|
|
}
|
|
});
|
|
|
|
function changeView(viewName) {
|
|
if (window.fcCalendar) {
|
|
window.fcCalendar.changeView(viewName);
|
|
}
|
|
}
|
|
|
|
// --- Create Event Modal ---
|
|
function showCreateEventModal(dateStr) {
|
|
document.getElementById('createEventDate').textContent = dateStr;
|
|
document.getElementById('createEventDateInput').value = dateStr;
|
|
|
|
// Reset dropdowns
|
|
document.getElementById('createTryoutSelect').value = '';
|
|
document.getElementById('createTeamSelect').value = '';
|
|
|
|
document.getElementById('createEventModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideCreateEventModal() {
|
|
document.getElementById('createEventModal').classList.add('hidden');
|
|
}
|
|
|
|
function goToTryoutMatch() {
|
|
var tryoutId = document.getElementById('createTryoutSelect').value;
|
|
if (!tryoutId) { alert('Please select a tryout.'); return; }
|
|
var date = document.getElementById('createEventDateInput').value;
|
|
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
|
|
}
|
|
|
|
function goToTeamMatch() {
|
|
var teamId = document.getElementById('createTeamSelect').value;
|
|
if (!teamId) { alert('Please select a team.'); return; }
|
|
var date = document.getElementById('createEventDateInput').value;
|
|
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
|
|
}
|
|
|
|
function goToCreateTryout() {
|
|
window.location.href = '/tryouts/create';
|
|
}
|
|
|
|
// Pre-fetch data for dropdowns
|
|
function fetchTryoutOptions() {
|
|
fetch('/matches/api/manageable-tryouts')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
// new Option() sets the label as text; concatenating it into
|
|
// innerHTML let a tryout title carry markup into the page.
|
|
var sel = document.getElementById('createTryoutSelect');
|
|
sel.replaceChildren(new Option('-- Select a tryout --', ''));
|
|
var today = new Date().toISOString().split('T')[0];
|
|
data.forEach(function(t) {
|
|
// Only show tryouts that haven't ended
|
|
var tryoutEndDate = t.end_date || t.date;
|
|
if (tryoutEndDate >= today) {
|
|
sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
|
|
}
|
|
});
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
function fetchTeamOptions() {
|
|
fetch('/team-matches/api/manageable-teams')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
var sel = document.getElementById('createTeamSelect');
|
|
sel.replaceChildren(new Option('-- Select a team --', ''));
|
|
data.forEach(function(t) {
|
|
sel.appendChild(new Option(t.name, t.id));
|
|
});
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Safe DOM builders
|
|
// ---------------------------------------------------------------------------
|
|
// Everything rendered in the event modal originates from a JSON endpoint,
|
|
// where no HTML escaping applies. Text therefore goes through textContent,
|
|
// never through innerHTML.
|
|
|
|
function makeEl(tag, className, text) {
|
|
var node = document.createElement(tag);
|
|
if (className) { node.className = className; }
|
|
if (text !== undefined && text !== null) { node.textContent = text; }
|
|
return node;
|
|
}
|
|
|
|
function detailItem(label, valueNode, fullWidth) {
|
|
var item = makeEl('div', 'detail-item' + (fullWidth ? ' full-width' : ''));
|
|
item.appendChild(makeEl('span', 'detail-label', label));
|
|
var value = makeEl('span', 'detail-value');
|
|
value.appendChild(valueNode);
|
|
item.appendChild(value);
|
|
return item;
|
|
}
|
|
|
|
// Renders newlines as <br> without letting any other markup through.
|
|
function multilineNode(text) {
|
|
var fragment = document.createDocumentFragment();
|
|
String(text).split('\n').forEach(function(line, index) {
|
|
if (index > 0) { fragment.appendChild(document.createElement('br')); }
|
|
fragment.appendChild(document.createTextNode(line));
|
|
});
|
|
return fragment;
|
|
}
|
|
|
|
// A team block: its name, plus an optional list of player names.
|
|
function teamNode(name, players) {
|
|
var team = makeEl('div', 'match-team');
|
|
team.appendChild(makeEl('span', 'team-name', name));
|
|
if (players) {
|
|
var list = makeEl('ul', 'team-players-list');
|
|
players.forEach(function(player) {
|
|
list.appendChild(makeEl('li', null, player));
|
|
});
|
|
team.appendChild(list);
|
|
}
|
|
return team;
|
|
}
|
|
|
|
function versusNode(left, right) {
|
|
var wrap = makeEl('div', 'match-teams');
|
|
wrap.appendChild(left);
|
|
wrap.appendChild(makeEl('div', 'match-vs', 'vs'));
|
|
wrap.appendChild(right);
|
|
return wrap;
|
|
}
|
|
|
|
function buildTeamsNode(props) {
|
|
var sides = props.participants.split(' vs ');
|
|
|
|
if (props.match_type === 'team_vs_team' && sides.length >= 2) {
|
|
return versusNode(teamNode(sides[0]), teamNode(sides[1]));
|
|
}
|
|
|
|
if (props.match_type === 'player_vs_player' && sides.length >= 2) {
|
|
return versusNode(
|
|
teamNode('Team 1', sides[0].split(', ')),
|
|
teamNode('Team 2', sides[1].split(', '))
|
|
);
|
|
}
|
|
|
|
return document.createTextNode(props.participants);
|
|
}
|
|
|
|
function showEventModal(event) {
|
|
var props = event.extendedProps;
|
|
var title = event.title;
|
|
var type = props.type;
|
|
var date = event.start ? event.start.toDateString() : '';
|
|
|
|
// Built as DOM nodes rather than concatenated HTML. Every value below —
|
|
// match title, location, description, and above all the participant list,
|
|
// which is made of user-chosen usernames — comes from a JSON API and has
|
|
// never been HTML-escaped. Assigning it to innerHTML executed it.
|
|
var grid = makeEl('div', 'detail-grid');
|
|
|
|
var typeLabel = props.match_type === 'team_vs_team' ? 'Team Match'
|
|
: (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim');
|
|
var typeBadgeClass = (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player')
|
|
? 'success' : 'warning';
|
|
|
|
grid.appendChild(detailItem('Type', makeEl('span', 'badge badge-' + typeBadgeClass, typeLabel)));
|
|
grid.appendChild(detailItem('Title', document.createTextNode(title)));
|
|
grid.appendChild(detailItem('Date', document.createTextNode(date)));
|
|
grid.appendChild(detailItem('Location', document.createTextNode(props.location || 'TBD')));
|
|
|
|
var status = props.status || 'scheduled';
|
|
grid.appendChild(detailItem('Status', makeEl('span', 'badge badge-' + status, status)));
|
|
|
|
if (type === 'match' && props.participants) {
|
|
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
|
}
|
|
|
|
if (props.description) {
|
|
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
|
}
|
|
|
|
var modalContent = document.getElementById('modalTitle');
|
|
modalContent.textContent = 'Match Details';
|
|
var target = document.getElementById('modalContent');
|
|
target.textContent = '';
|
|
target.appendChild(grid);
|
|
|
|
// Reset buttons
|
|
document.getElementById('deleteMatchBtn').style.display = 'none';
|
|
document.getElementById('editMatchBtn').style.display = 'none';
|
|
document.getElementById('viewTryoutBtn').style.display = 'none';
|
|
|
|
// Show action buttons for matches (coaches and above)
|
|
if (type === 'match' && canScheduleMatches) {
|
|
document.getElementById('modalActions').style.display = 'flex';
|
|
document.getElementById('editMatchBtn').style.display = 'inline-flex';
|
|
document.getElementById('editMatchBtn').onclick = function() {
|
|
window.location.href = '/matches/' + props.match_id + '/edit';
|
|
};
|
|
document.getElementById('deleteMatchBtn').style.display = 'inline-flex';
|
|
document.getElementById('deleteMatchBtn').onclick = function() {
|
|
if (confirm('Are you sure you want to delete this match?')) {
|
|
deleteCalendarMatch(props.match_id);
|
|
}
|
|
};
|
|
} else {
|
|
document.getElementById('modalActions').style.display = 'none';
|
|
}
|
|
|
|
// Show presence toggle for matches where user is a participant
|
|
var presenceDiv = document.getElementById('modalPresenceToggle');
|
|
if (type === 'match' && props.user_participant_id) {
|
|
presenceDiv.style.display = 'flex';
|
|
var confirmed = props.user_attendance_confirmed || false;
|
|
var toggleBtn = document.getElementById('calPresenceBtn');
|
|
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
|
|
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
|
|
toggleBtn.onclick = function() {
|
|
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
|
|
};
|
|
} else {
|
|
presenceDiv.style.display = 'none';
|
|
}
|
|
|
|
document.getElementById('eventModal').classList.remove('hidden');
|
|
}
|
|
|
|
function deleteCalendarMatch(matchId) {
|
|
fetch('/matches/' + matchId + '/delete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': '{{ csrf_token() }}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
.then(function(r) { return r.json().catch(function() { return {}; }); })
|
|
.then(function() {
|
|
hideEventModal();
|
|
if (window.fcCalendar) {
|
|
window.fcCalendar.refetchEvents();
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
console.error('Error deleting match:', err);
|
|
alert('Failed to delete match.');
|
|
});
|
|
}
|
|
|
|
function toggleCalendarPresence(matchId, participantId, btn) {
|
|
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': '{{ csrf_token() }}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.attendance_confirmed) {
|
|
btn.classList.add('btn-success');
|
|
btn.classList.remove('btn-outline');
|
|
btn.textContent = '✅ Confirmed';
|
|
} else {
|
|
btn.classList.remove('btn-success');
|
|
btn.classList.add('btn-outline');
|
|
btn.textContent = 'Confirm';
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
console.error('Error toggling presence:', err);
|
|
});
|
|
}
|
|
|
|
function hideEventModal() {
|
|
document.getElementById('eventModal').classList.add('hidden');
|
|
}
|
|
</script>
|
|
{% endblock %} |