diff --git a/app/discord_bot.py b/app/discord_bot.py index 24e74b9..0e227b0 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -42,14 +42,21 @@ class TeamTryoutsBot(commands.Bot): """ def __init__(self, flask_app=None): + # Only what the bot actually reads. `members` — the privileged + # GUILD_MEMBERS intent — was requested and never used: nothing here + # lists or looks up guild members, the bot reaches people through + # the discord_user_id stored on their account (OPS-014). + # + # `message_content` stays: on_raw_reaction_add reads the text of a + # coach's reply to record a refusal note. intents = Intents.default() intents.message_content = True intents.dm_messages = True intents.dm_reactions = True intents.reactions = True intents.guilds = True - intents.members = True - + + super().__init__(command_prefix='!', intents=intents) self.flask_app = flask_app self.pending_requests = {} # Maps message_id to {type, id} for reaction handling diff --git a/app/routes/users.py b/app/routes/users.py index 2f051d9..5f36f46 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -35,9 +35,43 @@ import requests ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} ALLOWED_SIGNED_EXTENSIONS = {'pdf'} +#: Every PDF starts with this. Checking the name alone accepted a file +#: called anything.pdf holding anything at all. +PDF_SIGNATURE = b'%PDF-' + users_bp = Blueprint('users', __name__, url_prefix='/users') +def pdf_upload_error(file, allowed_extensions): + """Why this upload is not an acceptable PDF, or None if it is. + + upload_signed_contract checked nothing beyond a non-empty filename — + ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player + could put an arbitrary file on the server under a name the application + later hands back for download (SEC-021). + + Args: + file: The uploaded FileStorage, or None. + allowed_extensions: Extensions to accept, lowercase and without dot. + + Returns: + str | None: A message to flash, or None when the file is acceptable. + """ + if file is None or not file.filename: + return _('No file selected.') + + stem, dot, extension = file.filename.rpartition('.') + if not (stem and dot) or extension.lower() not in allowed_extensions: + return _('Only PDF files are allowed for contracts.') + + head = file.stream.read(len(PDF_SIGNATURE)) + file.stream.seek(0) + if head != PDF_SIGNATURE: + return _('That file is not a PDF, whatever its name says.') + + return None + + # --------------------------------------------------------------------------- # Form helpers shared by the schema-validated routes # --------------------------------------------------------------------------- @@ -660,16 +694,10 @@ def upload_contract(): flash(_('You do not have permission to upload a contract for this player.'), 'danger') return redirect(url_for('users.upload_contract')) - if 'contract_file' not in request.files: - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.upload_contract')) - - file = request.files['contract_file'] - if file.filename == '': - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.upload_contract')) - if not file.filename.lower().endswith('.pdf'): - flash(_('Only PDF files are allowed for contracts.'), 'danger') + file = request.files.get('contract_file') + error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) + if error: + flash(error, 'danger') return redirect(url_for('users.upload_contract')) upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') @@ -717,13 +745,10 @@ def upload_signed_contract(contract_id): flash(_('Only the player can upload their signed contract.'), 'danger') return redirect(url_for('users.list_contracts')) - if 'signed_file' not in request.files: - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.list_contracts')) - - file = request.files['signed_file'] - if file.filename == '': - flash(_('No file selected.'), 'danger') + file = request.files.get('signed_file') + error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) + if error: + flash(error, 'danger') return redirect(url_for('users.list_contracts')) signed_filename = f"signed_{contract.stored_filename}" diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 4b02bbf..7555c4e 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 81612f3..fb448dd 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-08 14:58-0400\n" +"POT-Creation-Date: 2026-08-08 15:07-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -100,22 +100,22 @@ msgstr "Points must be 2000 characters or less." msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/routes/auth.py:169 app/routes/auth.py:297 app/routes/users.py:49 -#: app/routes/users.py:653 +#: app/routes/auth.py:186 app/routes/auth.py:314 app/routes/users.py:83 +#: app/routes/users.py:687 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" -#: app/routes/auth.py:187 +#: app/routes/auth.py:204 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:225 +#: app/routes/auth.py:242 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:246 +#: app/routes/auth.py:263 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -123,27 +123,27 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:278 +#: app/routes/auth.py:295 msgid "Incorrect CAPTCHA answer. Please try again." msgstr "Incorrect CAPTCHA answer. Please try again." -#: app/routes/auth.py:320 app/routes/users.py:336 +#: app/routes/auth.py:337 app/routes/users.py:370 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:332 app/routes/users.py:340 +#: app/routes/auth.py:349 app/routes/users.py:374 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:378 +#: app/routes/auth.py:395 msgid "Your account has been created! You can now log in." msgstr "Your account has been created! You can now log in." -#: app/routes/auth.py:404 +#: app/routes/auth.py:421 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:444 +#: app/routes/auth.py:461 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -151,27 +151,27 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:452 +#: app/routes/auth.py:469 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:476 +#: app/routes/auth.py:493 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:480 +#: app/routes/auth.py:497 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:495 +#: app/routes/auth.py:512 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:542 +#: app/routes/auth.py:559 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:565 +#: app/routes/auth.py:587 msgid "You have been logged out." msgstr "You have been logged out." @@ -408,7 +408,7 @@ msgstr "You do not have permission to add notes to this team." msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:444 app/routes/users.py:1241 app/routes/users.py:1283 +#: app/routes/teams.py:444 app/routes/users.py:1266 app/routes/users.py:1308 msgid "Can only add notes for players." msgstr "Can only add notes for players." @@ -526,23 +526,35 @@ msgstr "You do not have permission to delete this tryout." msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users.py:119 +#: app/routes/users.py:61 +msgid "No file selected." +msgstr "No file selected." + +#: app/routes/users.py:65 +msgid "Only PDF files are allowed for contracts." +msgstr "Only PDF files are allowed for contracts." + +#: app/routes/users.py:70 +msgid "That file is not a PDF, whatever its name says." +msgstr "That file is not a PDF, whatever its name says." + +#: app/routes/users.py:153 msgid "Only the president can manage users." msgstr "Only the president can manage users." -#: app/routes/users.py:131 +#: app/routes/users.py:165 msgid "Only the president can edit users." msgstr "Only the president can edit users." -#: app/routes/users.py:168 +#: app/routes/users.py:202 msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users.py:178 +#: app/routes/users.py:212 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users.py:189 +#: app/routes/users.py:223 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -550,181 +562,172 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users.py:251 +#: app/routes/users.py:285 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users.py:266 +#: app/routes/users.py:300 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users.py:270 +#: app/routes/users.py:304 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users.py:307 +#: app/routes/users.py:341 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users.py:316 +#: app/routes/users.py:350 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users.py:355 +#: app/routes/users.py:389 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" -#: app/routes/users.py:412 +#: app/routes/users.py:446 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users.py:418 +#: app/routes/users.py:452 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users.py:443 +#: app/routes/users.py:477 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" -#: app/routes/users.py:641 +#: app/routes/users.py:675 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users.py:660 +#: app/routes/users.py:694 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users.py:664 app/routes/users.py:669 app/routes/users.py:721 -#: app/routes/users.py:726 -msgid "No file selected." -msgstr "No file selected." - -#: app/routes/users.py:672 -msgid "Only PDF files are allowed for contracts." -msgstr "Only PDF files are allowed for contracts." - -#: app/routes/users.py:705 +#: app/routes/users.py:733 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users.py:717 +#: app/routes/users.py:745 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users.py:737 +#: app/routes/users.py:762 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users.py:747 app/routes/users.py:758 +#: app/routes/users.py:772 app/routes/users.py:783 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users.py:761 +#: app/routes/users.py:786 msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users.py:828 +#: app/routes/users.py:853 msgid "Only players can request One on One sessions." msgstr "Only players can request One on One sessions." -#: app/routes/users.py:841 +#: app/routes/users.py:866 msgid "You do not have a coach assigned to your team." msgstr "You do not have a coach assigned to your team." -#: app/routes/users.py:868 +#: app/routes/users.py:893 msgid "Cannot request One on One - no coach assigned." msgstr "Cannot request One on One - no coach assigned." -#: app/routes/users.py:876 +#: app/routes/users.py:901 msgid "Invalid date or time format." msgstr "Invalid date or time format." -#: app/routes/users.py:890 +#: app/routes/users.py:915 msgid "The requested time is not within the coach's availability." msgstr "The requested time is not within the coach's availability." -#: app/routes/users.py:913 +#: app/routes/users.py:938 msgid "Your One on One request has been submitted!" msgstr "Your One on One request has been submitted!" -#: app/routes/users.py:948 +#: app/routes/users.py:973 msgid "Only coaches can accept One on One requests." msgstr "Only coaches can accept One on One requests." -#: app/routes/users.py:954 app/routes/users.py:995 +#: app/routes/users.py:979 app/routes/users.py:1020 msgid "This request is not for you." msgstr "This request is not for you." -#: app/routes/users.py:958 app/routes/users.py:999 +#: app/routes/users.py:983 app/routes/users.py:1024 msgid "This request has already been processed." msgstr "This request has already been processed." -#: app/routes/users.py:980 +#: app/routes/users.py:1005 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "One on One request from %(player)s has been approved!" -#: app/routes/users.py:989 +#: app/routes/users.py:1014 msgid "Only coaches can reject One on One requests." msgstr "Only coaches can reject One on One requests." -#: app/routes/users.py:1026 +#: app/routes/users.py:1051 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "One on One request from %(player)s has been rejected." -#: app/routes/users.py:1039 +#: app/routes/users.py:1064 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users.py:1070 +#: app/routes/users.py:1095 msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users.py:1132 +#: app/routes/users.py:1157 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users.py:1196 +#: app/routes/users.py:1221 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users.py:1202 +#: app/routes/users.py:1227 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users.py:1215 +#: app/routes/users.py:1240 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users.py:1229 +#: app/routes/users.py:1254 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users.py:1236 app/routes/users.py:1278 app/routes/users.py:1328 -#: app/routes/users.py:1379 +#: app/routes/users.py:1261 app/routes/users.py:1303 app/routes/users.py:1353 +#: app/routes/users.py:1404 msgid "Player and content are required." msgstr "Player and content are required." -#: app/routes/users.py:1245 app/routes/users.py:1287 app/routes/users.py:1332 -#: app/routes/users.py:1383 +#: app/routes/users.py:1270 app/routes/users.py:1312 app/routes/users.py:1357 +#: app/routes/users.py:1408 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users.py:1255 app/routes/users.py:1300 +#: app/routes/users.py:1280 app/routes/users.py:1325 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users.py:1268 app/routes/users.py:1313 app/routes/users.py:1363 +#: app/routes/users.py:1293 app/routes/users.py:1338 app/routes/users.py:1388 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users.py:1343 app/routes/users.py:1394 +#: app/routes/users.py:1368 app/routes/users.py:1419 msgid "Note added successfully." msgstr "Note added successfully." @@ -845,7 +848,7 @@ msgstr "Try Again" msgid "Language" msgstr "Language" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133 +#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" @@ -904,19 +907,19 @@ msgstr "Contracts" msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:117 +#: app/templates/layouts/base.html:122 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:143 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:149 app/templates/layouts/base.html:168 +#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:178 +#: app/templates/layouts/base.html:184 msgid "Team Tryout Management System" msgstr "Team Tryout Management System" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index a128182..b6f33c8 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 509c34d..daa8c38 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-08 14:58-0400\n" +"POT-Creation-Date: 2026-08-08 15:07-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -102,50 +102,50 @@ msgstr "Les points ne doivent pas dépasser 2000 caractères." msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/routes/auth.py:169 app/routes/auth.py:297 app/routes/users.py:49 -#: app/routes/users.py:653 +#: app/routes/auth.py:186 app/routes/auth.py:314 app/routes/users.py:83 +#: app/routes/users.py:687 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" -#: app/routes/auth.py:187 +#: app/routes/auth.py:204 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:225 +#: app/routes/auth.py:242 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:246 +#: app/routes/auth.py:263 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." msgstr "" -"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, ou " -"demandez de l’aide à un président." +"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " +"ou demandez de l’aide à un président." -#: app/routes/auth.py:278 +#: app/routes/auth.py:295 msgid "Incorrect CAPTCHA answer. Please try again." msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." -#: app/routes/auth.py:320 app/routes/users.py:336 +#: app/routes/auth.py:337 app/routes/users.py:370 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:332 app/routes/users.py:340 +#: app/routes/auth.py:349 app/routes/users.py:374 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:378 +#: app/routes/auth.py:395 msgid "Your account has been created! You can now log in." msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter." -#: app/routes/auth.py:404 +#: app/routes/auth.py:421 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:444 +#: app/routes/auth.py:461 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -153,27 +153,27 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:452 +#: app/routes/auth.py:469 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:476 +#: app/routes/auth.py:493 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:480 +#: app/routes/auth.py:497 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:495 +#: app/routes/auth.py:512 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:542 +#: app/routes/auth.py:559 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:565 +#: app/routes/auth.py:587 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -412,7 +412,7 @@ msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:444 app/routes/users.py:1241 app/routes/users.py:1283 +#: app/routes/teams.py:444 app/routes/users.py:1266 app/routes/users.py:1308 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." @@ -530,25 +530,37 @@ msgstr "Vous n’avez pas les droits pour supprimer cette sélection." msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users.py:119 +#: app/routes/users.py:61 +msgid "No file selected." +msgstr "Aucun fichier sélectionné." + +#: app/routes/users.py:65 +msgid "Only PDF files are allowed for contracts." +msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." + +#: app/routes/users.py:70 +msgid "That file is not a PDF, whatever its name says." +msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." + +#: app/routes/users.py:153 msgid "Only the president can manage users." msgstr "Seul le président peut gérer les utilisateurs." -#: app/routes/users.py:131 +#: app/routes/users.py:165 msgid "Only the president can edit users." msgstr "Seul le président peut modifier des utilisateurs." -#: app/routes/users.py:168 +#: app/routes/users.py:202 msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users.py:178 +#: app/routes/users.py:212 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users.py:189 +#: app/routes/users.py:223 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -556,183 +568,174 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users.py:251 +#: app/routes/users.py:285 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users.py:266 +#: app/routes/users.py:300 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users.py:270 +#: app/routes/users.py:304 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users.py:307 +#: app/routes/users.py:341 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users.py:316 +#: app/routes/users.py:350 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users.py:355 +#: app/routes/users.py:389 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." -#: app/routes/users.py:412 +#: app/routes/users.py:446 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users.py:418 +#: app/routes/users.py:452 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users.py:443 +#: app/routes/users.py:477 msgid "Profile updated successfully!" msgstr "Profil mis à jour." -#: app/routes/users.py:641 +#: app/routes/users.py:675 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users.py:660 +#: app/routes/users.py:694 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users.py:664 app/routes/users.py:669 app/routes/users.py:721 -#: app/routes/users.py:726 -msgid "No file selected." -msgstr "Aucun fichier sélectionné." - -#: app/routes/users.py:672 -msgid "Only PDF files are allowed for contracts." -msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." - -#: app/routes/users.py:705 +#: app/routes/users.py:733 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users.py:717 +#: app/routes/users.py:745 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users.py:737 +#: app/routes/users.py:762 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users.py:747 app/routes/users.py:758 +#: app/routes/users.py:772 app/routes/users.py:783 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users.py:761 +#: app/routes/users.py:786 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users.py:828 +#: app/routes/users.py:853 msgid "Only players can request One on One sessions." msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." -#: app/routes/users.py:841 +#: app/routes/users.py:866 msgid "You do not have a coach assigned to your team." msgstr "Aucun coach n’est assigné à votre équipe." -#: app/routes/users.py:868 +#: app/routes/users.py:893 msgid "Cannot request One on One - no coach assigned." msgstr "Impossible de demander une rencontre : aucun coach assigné." -#: app/routes/users.py:876 +#: app/routes/users.py:901 msgid "Invalid date or time format." msgstr "Format de date ou d’heure invalide." -#: app/routes/users.py:890 +#: app/routes/users.py:915 msgid "The requested time is not within the coach's availability." msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." -#: app/routes/users.py:913 +#: app/routes/users.py:938 msgid "Your One on One request has been submitted!" msgstr "Votre demande de rencontre a été envoyée." -#: app/routes/users.py:948 +#: app/routes/users.py:973 msgid "Only coaches can accept One on One requests." msgstr "Seuls les coachs peuvent accepter une demande de rencontre." -#: app/routes/users.py:954 app/routes/users.py:995 +#: app/routes/users.py:979 app/routes/users.py:1020 msgid "This request is not for you." msgstr "Cette demande ne vous est pas destinée." -#: app/routes/users.py:958 app/routes/users.py:999 +#: app/routes/users.py:983 app/routes/users.py:1024 msgid "This request has already been processed." msgstr "Cette demande a déjà été traitée." -#: app/routes/users.py:980 +#: app/routes/users.py:1005 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "La demande de rencontre de %(player)s a été approuvée." -#: app/routes/users.py:989 +#: app/routes/users.py:1014 msgid "Only coaches can reject One on One requests." msgstr "Seuls les coachs peuvent refuser une demande de rencontre." -#: app/routes/users.py:1026 +#: app/routes/users.py:1051 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "La demande de rencontre de %(player)s a été refusée." -#: app/routes/users.py:1039 +#: app/routes/users.py:1064 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users.py:1070 +#: app/routes/users.py:1095 msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users.py:1132 +#: app/routes/users.py:1157 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users.py:1196 +#: app/routes/users.py:1221 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users.py:1202 +#: app/routes/users.py:1227 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users.py:1215 +#: app/routes/users.py:1240 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users.py:1229 +#: app/routes/users.py:1254 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users.py:1236 app/routes/users.py:1278 app/routes/users.py:1328 -#: app/routes/users.py:1379 +#: app/routes/users.py:1261 app/routes/users.py:1303 app/routes/users.py:1353 +#: app/routes/users.py:1404 msgid "Player and content are required." msgstr "Le joueur et le contenu sont obligatoires." -#: app/routes/users.py:1245 app/routes/users.py:1287 app/routes/users.py:1332 -#: app/routes/users.py:1383 +#: app/routes/users.py:1270 app/routes/users.py:1312 app/routes/users.py:1357 +#: app/routes/users.py:1408 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users.py:1255 app/routes/users.py:1300 +#: app/routes/users.py:1280 app/routes/users.py:1325 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users.py:1268 app/routes/users.py:1313 app/routes/users.py:1363 +#: app/routes/users.py:1293 app/routes/users.py:1338 app/routes/users.py:1388 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users.py:1343 app/routes/users.py:1394 +#: app/routes/users.py:1368 app/routes/users.py:1419 msgid "Note added successfully." msgstr "Note ajoutée." @@ -851,7 +854,7 @@ msgstr "Réessayer" msgid "Language" msgstr "Langue" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133 +#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" @@ -910,19 +913,19 @@ msgstr "Contrats" msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:117 +#: app/templates/layouts/base.html:122 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:143 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:149 app/templates/layouts/base.html:168 +#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:178 +#: app/templates/layouts/base.html:184 msgid "Team Tryout Management System" msgstr "Système de gestion des sélections d’équipe" diff --git a/tests/test_contract_uploads.py b/tests/test_contract_uploads.py new file mode 100644 index 0000000..fde96f5 --- /dev/null +++ b/tests/test_contract_uploads.py @@ -0,0 +1,146 @@ +"""What may be written to the contracts directory — SEC-021. + +upload_signed_contract accepted any file with a non-empty name. +ALLOWED_SIGNED_EXTENSIONS was declared next to it and never read. The file +landed on disk under a name the application later hands back through +download_signed_contract, so whatever a player uploaded is what a manager +opens. + +The name alone was never enough either: `payload.pdf` says nothing about +the bytes. Both routes now check the signature as well. +""" + +import io +import os + +import pytest + +from app.extensions import db +from app.models import Contract + +PDF_BYTES = b'%PDF-1.7\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF\n' +NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header + + +@pytest.fixture +def contract_for(app, tmp_path): + """A contract row whose file lives in a throwaway directory.""" + + def _make(player_id, uploader_id): + stored = 'deadbeef.pdf' + path = tmp_path / stored + path.write_bytes(PDF_BYTES) + with app.app_context(): + contract = Contract( + player_id=player_id, uploaded_by_id=uploader_id, + original_filename='contract.pdf', stored_filename=stored, + file_path=str(path), + ) + db.session.add(contract) + db.session.commit() + return contract.id, tmp_path + + return _make + + +class TestSignedUpload: + def test_an_executable_named_pdf_is_refused( + self, app, client, as_role, make_user, contract_for + ): + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + contract = db.session.get(Contract, contract_id) + assert contract.status != 'signed' + assert contract.signed_file_path is None + assert not os.path.exists(directory / 'signed_deadbeef.pdf') + + def test_a_foreign_extension_is_refused( + self, app, client, as_role, make_user, contract_for + ): + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, _directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(b''), 'shell.php')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + assert db.session.get(Contract, contract_id).status != 'signed' + + def test_a_real_pdf_still_goes_through( + self, app, client, as_role, make_user, contract_for + ): + """Guard against over-correcting: signing a contract is the point.""" + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + contract = db.session.get(Contract, contract_id) + assert contract.status == 'signed' + assert contract.signed_at is not None + assert os.path.exists(directory / 'signed_deadbeef.pdf') + + def test_another_player_still_cannot_sign_it( + self, app, client, as_role, make_user, contract_for + ): + owner_id = make_user('player') + admin_id = make_user('admin') + contract_id, _directory = contract_for(owner_id, admin_id) + as_role('player') + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + assert db.session.get(Contract, contract_id).status != 'signed' + + +class TestTheHelper: + def test_it_reports_a_missing_file(self, app): + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + with app.test_request_context('/'): + assert pdf_upload_error(None, ALLOWED_CONTRACT_EXTENSIONS) + + def test_it_leaves_the_stream_readable(self, app): + """The signature check consumes bytes; the caller still has to save + the whole file afterwards.""" + from werkzeug.datastructures import FileStorage + + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='c.pdf') + with app.test_request_context('/'): + assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None + assert upload.stream.read() == PDF_BYTES + + def test_a_name_without_a_dot_is_refused(self, app): + from werkzeug.datastructures import FileStorage + + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='pdf') + with app.test_request_context('/'): + assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)