Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune liste de « ce qui reste » ne reprenait. OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les valeurs pour la production », puis posait FLASK_DEBUG=true, SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug execute du code soumis par le navigateur : cette ligne transformait un copier-coller en shell distant. Chaque valeur est desormais sure a la copie, et le fichier refuse de demarrer tant que les deux secrets obligatoires ne sont pas remplis plutot que de demarrer grand ouvert. Renomme en .env.example : l orthographe francaise ne correspondait pas a l exception !.env.example du .gitignore, donc le fichier n etait suivi que par accident de l ordre des regles. Les deux points de la decision ouverte du §8 tombent d un seul git mv. OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les defauts sont **inchanges**, deliberement : choisir sans connaitre la topologie coupe la prod si nginx est ailleurs, ou casse la limitation de debit pour tout le monde si on cesse de croire X-Forwarded-For alors que c etait la seule source d adresses. Ce sont maintenant des variables, les valeurs sures sont dans .env.example pour un nouveau deploiement, et docs/deployment.md donne les quatre topologies avec la valeur de chacune. wsgi.py avertit au demarrage tant que les deux defauts sont en place. Le commentaire de HOST annoncait « bind to localhost by default » a cote d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code faisait l inverse. Il dit maintenant ce qu il fait. QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte, depuis des hotes que la CSP autorise nommement. Qui controle ces CDN controlait ce qui s execute sur chaque page. Empreintes posees, avec ce que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier, ca ne prouve pas qu il etait honnete au moment du calcul. **Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque ouverture du calendrier depuis la montee de version. Une feuille de style en echec est silencieuse dans le navigateur, c est ce qui l a fait durer. CI-003 — actions epinglees sur un commit, version en commentaire, dans les deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du code arbitraire dans le job qui detient la cle SSH de production. Ce job recoit aussi enfin un bloc permissions. 517 tests.
462 lines
18 KiB
HTML
462 lines
18 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" data-action="change-view" data-view="dayGridMonth">
|
|
<i class="fas fa-calendar"></i> {{ _('Month') }}
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
|
|
<i class="fas fa-calendar-week"></i> {{ _('Week') }}
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
|
|
<i class="fas fa-calendar-day"></i> {{ _('Day') }}
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="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" data-action="hide-create-event"></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" data-action="hide-create-event">×</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" data-action="go-tryout-match">
|
|
<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" data-action="go-team-match">
|
|
<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" data-action="go-create-tryout">
|
|
<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" data-action="hide-event-modal"></div>
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 id="modalTitle">{{ _('Event Details') }}</h3>
|
|
<button class="modal-close" data-action="hide-event-modal">×</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 %}
|
|
{# The stylesheet that used to sit here — index.global.min.css — does not
|
|
exist. FullCalendar 6 bundles its styles into the JS, and that file is not
|
|
in the published package: the link had been answering 404 on every calendar
|
|
load since the upgrade. A failed stylesheet is silent in the browser, which
|
|
is why it survived.
|
|
|
|
Integrity pins the bundle (QUA-004): this file is executable script from a
|
|
third party, on the page that shows every match in the club. See the note
|
|
in layouts/base.html for what SRI does and does not promise. #}
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"
|
|
integrity="sha384-WfE/vOHqht3KDj6FvpwQUf3UxEPUHoGJ3w1yZ8rhpLWnVigt8HjXL2zXqtcfS7mf"
|
|
crossorigin="anonymous"
|
|
referrerpolicy="no-referrer"></script>
|
|
<script nonce="{{ csp_nonce }}">
|
|
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');
|
|
}
|
|
|
|
// Behaviours declared in the markup, dispatched by the delegated listener
|
|
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
|
|
registerActions({
|
|
'change-view': function (element) {
|
|
changeView(element.getAttribute('data-view'));
|
|
},
|
|
'hide-create-event': hideCreateEventModal,
|
|
'go-tryout-match': goToTryoutMatch,
|
|
'go-team-match': goToTeamMatch,
|
|
'go-create-tryout': goToCreateTryout,
|
|
'hide-event-modal': hideEventModal,
|
|
});
|
|
</script>
|
|
{% endblock %} |