diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..46005ca --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: CI - Security, Lint & Tests + +on: + push: + pull_request: + workflow_dispatch: + +# This workflow validates branches only. It has no deployment step and no +# write permission, so an audit-branch push cannot alter main or production. +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Audit declared dependencies + run: pip-audit -r requirements.txt + + - name: Lint and check formatting + run: | + ruff check . + ruff format --check . + + - name: Run tests with coverage gate + run: pytest --cov=app --cov-report=term-missing --cov-report=xml + + - name: Run repository security checks + env: + SECRET_KEY: audit-ci-key-not-for-production-1234567890 + DATABASE_URL: 'sqlite:///:memory:' + FLASK_DEBUG: 'false' + run: python app/supporting_scripts/security_scan.py --skip-http diff --git a/.gitea/workflows/git-to-ptero.yaml b/.gitea/workflows/git-to-ptero.yaml index dac590e..3ff6e79 100644 --- a/.gitea/workflows/git-to-ptero.yaml +++ b/.gitea/workflows/git-to-ptero.yaml @@ -6,37 +6,147 @@ on: # branches: # - main # Optional: Run automatically on pushes to the main branch +# OPS-011 — what this workflow now guarantees, and what it still does not. +# +# Guaranteed: +# - nothing is uploaded unless the test suite and the linters pass; +# - only files on an explicit allowlist are uploaded, so a new file at the +# repository root does not reach production by default. That is how +# clear_db.py — a script that DROPs every table and recreates +# admin/password — got there in the first place; +# - after the upload, /health is polled until it answers healthy, and the +# job fails loudly if it does not. Before, a half-uploaded tree was a +# green deployment. +# +# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so +# for the length of the transfer production runs a mixture of two versions. +# Closing that needs a release-directory layout, which has three +# prerequisites, two of which cannot be done from here: +# +# 1. the Pterodactyl startup command must run the app from `current/` +# rather than from the server root, and the server must be restarted on +# switch — a panel change; +# 2. `documents/`, `logs/` and `.env` must live beside the releases, not +# inside one. DOCUMENTS_ROOT exists for this (app/storage.py); +# 3. contract paths must be relative to that root, so the switch does not +# strand them. Done: new rows are relative, old absolute ones still +# resolve. +# +# docs/deployment.md carries the design and the rollback procedure. + +# Least privilege (CI-003). This job never writes back to the repository; it +# holds the SSH key to the production node, which makes it the most valuable +# job in either forge to compromise. +permissions: + contents: read + +# Actions pinned to a commit, version in the comment. A tag is a moving +# pointer, and moving `v4` here means running arbitrary code in the job that +# holds that key. If the Gitea runner ever fails to resolve a commit ref, it +# fails on the checkout step — loudly, like the `@v7` that did not exist. + jobs: deploy-to-sftp: runs-on: ubuntu-latest steps: + # Was @v7, which does not exist (latest major is v5): the workflow + # failed on its very first step. - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Install lftp and ssh - run: sudo apt-get update && sudo apt-get install -y lftp openssh-client + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + # The gate. This runner is not the GitHub one, so a green CI over + # there proves nothing about what is about to be shipped from here: + # the deploy is triggered by hand, on whatever the branch holds. + - name: Refuse to deploy a broken tree + run: | + python -m pytest -q + python -m ruff check . + python -m ruff format --check . + + # An allowlist, not a list of exclusions. The previous form mirrored + # the whole working tree minus nine globs, so every file added to the + # repository shipped to production unless someone remembered to + # exclude it. This inverts the default: a new top-level file has to be + # named here to reach the server. + - name: Assemble the release payload + run: | + set -euo pipefail + mkdir -p payload + cp -r app payload/ + cp requirements.txt wsgi.py payload/ + # Compiled catalogues are versioned deliberately: the deployment is + # a file mirror with no build step (docs/translations.md). + find payload -name '__pycache__' -type d -prune -exec rm -rf {} + + find payload -name '*.pyc' -delete + echo "Shipping $(find payload -type f | wc -l) files:" + find payload -maxdepth 2 -type d | sort - name: Set up SSH Private Key env: # Binds the secret to a secure environment variable - SSH_PRIVATE_KEY: ${{ secrets.SSH }} + SSH_PRIVATE_KEY: ${{ secrets.SSH }} run: | mkdir -p ~/.ssh # Uses the environment variable, so the raw key is never printed in the execution log - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa - name: Push files via SFTP with progress run: | - # The mirror command below uses the -R (reverse) flag - # to push from local './' to remote './' - # Connection is made using 'open' inside the execution block to enforce SSH key usage + # --delete is deliberately NOT used. Uploaded contracts, logs and the + # server's own .env live under the deployment root and are absent + # from the repository; deleting anything not present locally would + # destroy them. Stale files therefore still accumulate — that is the + # other half of what the release-directory layout would fix. lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \ set sftp:auto-confirm yes; \ set net:max-retries 5; \ set net:timeout 30; \ set cmd:fail-exit yes; \ open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \ - mirror -R --verbose --parallel=4 ./ ./; \ - quit" \ No newline at end of file + mirror -R --verbose --parallel=4 ./payload/ ./; \ + quit" + + # Without this a deployment that left the site 500ing reported success, + # and the first person to hear about it was a user. /health checks the + # database connection and reports whether the Discord bot thread is + # alive (OPS-012). + # HEALTH_URL is carried as a secret rather than as a variable. It is + # not secret — it is the public site — but `secrets` is the context + # this runner is already known to support, and a smoke test that fails + # to run because of an unsupported expression is worse than none. + - name: Smoke test + if: ${{ secrets.HEALTH_URL != '' }} + env: + HEALTH_URL: ${{ secrets.HEALTH_URL }} + run: | + set -euo pipefail + # The app is restarted by the panel, not by this workflow, so the + # first few probes are expected to fail or answer from the old + # process. Two minutes, then give up. + for attempt in $(seq 1 24); do + body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body='' + if echo "$body" | grep -q '"status": *"healthy"'; then + echo "Healthy after ${attempt} attempt(s):" + echo "$body" + exit 0 + fi + echo "attempt ${attempt}: not healthy yet" + sleep 5 + done + echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md." + exit 1 + + - name: Warn when no health check is configured + if: ${{ secrets.HEALTH_URL == '' }} + run: | + echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https:///health in the repository secrets." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 895b582..a865b6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI - Security & Lint on: push: - branches: [main, master] + branches: [main, master, 'audit/**'] pull_request: branches: [main, master] workflow_dispatch: # Allow manual triggers @@ -11,84 +11,112 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege: nothing here writes back to the repository. +permissions: + contents: read + +# Third-party actions are pinned to a commit, with the version in a comment +# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a +# job that holds this repository's token. The comment is what makes the pin +# maintainable — a bare 40-character hash tells a reader nothing about +# whether it is current. Dependabot updates both together. + +env: + PYTHON_VERSION: '3.12' + jobs: security-audit: name: Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install dependencies - run: pip install pip-audit + - name: Install pip-audit + run: pip install pip-audit==2.9.0 - - name: Scan for vulnerable dependencies - run: pip-audit --require-hashes --no-deps || pip-audit + # Previously: `pip-audit --require-hashes --no-deps || pip-audit`. + # Neither form named the requirements file, so the fallback audited the + # runner's environment — which contained pip-audit and nothing else. + # The job passed green while checking none of the application's + # dependencies. -r makes it audit what the application actually pins. + - name: Scan declared dependencies for known vulnerabilities + run: pip-audit -r requirements.txt lint: name: Lint with Ruff runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} - name: Install ruff - run: pip install ruff + run: pip install ruff==0.14.4 + # Rule selection and per-file ignores live in pyproject.toml. Before it + # existed, this step ran ruff's bare defaults with no configuration. - name: Run ruff linter run: ruff check . --output-format=github - - name: Run ruff formatter check + # Enabled now that the repository has been formatted once, in its own + # commit (QUA-002). Reaching this step before that would have failed on + # 72 of 76 files for reasons unrelated to correctness. + - name: Check formatting run: ruff format --check . security-scan: name: Security Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - name: Install app dependencies - run: pip install -r requirements.txt + run: pip install -r requirements.txt -r requirements-dev.txt + # The path was `security_scan.py`, but the script lives under + # app/supporting_scripts/. The step had therefore failed on every run + # since the file was moved. - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} + DATABASE_URL: 'sqlite:///:memory:' FLASK_DEBUG: 'false' - run: python security_scan.py --skip-http + run: python app/supporting_scripts/security_scan.py --skip-http test: name: Tests runs-on: ubuntu-latest needs: [security-audit, lint] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - name: Install dependencies - run: pip install -r requirements.txt + run: pip install -r requirements.txt -r requirements-dev.txt + # Previously an `echo` guarded by continue-on-error: the job reported + # success without executing anything. The suite needs no environment + # variables and no database server: create_app() takes its configuration + # as an argument and the fixtures use a temporary SQLite file. - name: Run tests - run: | - echo "No tests configured yet. Add tests to the project." - # python -m pytest tests/ --cov=. --cov-report=xml - continue-on-error: true \ No newline at end of file + run: pytest --cov=app --cov-report=term-missing --cov-report=xml diff --git a/.gitignore b/.gitignore index 7419dd4..624e58b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,151 @@ -.env +# ============================================================================= +# Secrets et configuration locale +# ============================================================================= +# `.env*` couvre .env, .env.local, .env.production, .env.bak… +# L'exception laisse passer le seul fichier modèle, qui ne doit contenir +# que des valeurs factices. +.env* +!.env.example -.instance/ +*.key +*.pem +*.pfx +*.p12 +.certs/ +id_rsa +id_ed25519 +known_hosts +credentials.json +service-account*.json + +# ============================================================================= +# Assistants IA — Claude +# ============================================================================= +.claude/ +.claude.json +.claude/settings.local.json +CLAUDE.md +CLAUDE.local.md +.anthropic/ + +# ============================================================================= +# Assistants IA — ChatGPT / OpenAI +# ============================================================================= +.chatgpt/ +.openai/ +.codex/ +AGENTS.md +chatgpt-*.md +openai-*.md + +# ============================================================================= +# Assistants IA — autres outils +# ============================================================================= +.cursor/ +.cursorrules +.cursorignore +.windsurf/ +.windsurfrules +.aider* +.continue/ +.clinerules +.roo/ +.gemini/ +GEMINI.md +.github/copilot-instructions.md +.copilot/ + +# ============================================================================= +# Notes de travail et suivi générés par assistant IA +# ============================================================================= +# Rapports d'audit, plans, brouillons et notes de session : documents de +# travail, non destinés à être versionnés dans le dépôt applicatif. +.ai/ +audit/ +*.audit.md +NOTES-IA.md +TODO-IA.md + +# ============================================================================= +# Python +# ============================================================================= +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +.eggs/ +build/ +dist/ + +# Environnements virtuels +venv/ +.venv/ +env/ +ENV/ + +# Outils +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ +coverage_html_report/ +coverage.xml + +# ============================================================================= +# Données applicatives — ne jamais versionner +# ============================================================================= +# Base SQLite locale (une base de démo a déjà été committée par le passé, +# voir l'historique du commit `4fd27ba`). +instance/ *.db +*.sqlite +*.sqlite3 +# Contrats téléversés (données personnelles) documents/ -__pycache__/ -*.cpython-313.pyc -*.cpython-312.pyc -*.pyc +# Sauvegardes produites par app/supporting_scripts/backup.py +backups/ -.pytest_cache/ -.coverage - -htmlcov/ -.DS_Store +# Journaux applicatifs +logs/ *.log -.certs/ -*.pem +# État d'exécution du bot Discord +discord_pending.json -docs/ -*.html +# ============================================================================= +# Éditeurs et systèmes d'exploitation +# ============================================================================= +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +desktop.ini -instance/ -*.db \ No newline at end of file +# ============================================================================= +# NOTE — règles retirées volontairement +# ============================================================================= +# `docs/` et `*.html` figuraient ici et ignoraient TOUT fichier .html du +# dépôt, y compris les templates Jinja2 de app/templates/. Un nouveau +# template était donc invisible pour git : l'application fonctionnait en +# local et cassait en production avec une TemplateNotFound, sans que +# `git status` ne signale quoi que ce soit. +# +# Les rapports de couverture HTML, qui étaient vraisemblablement la cible +# de ces règles, sont couverts ci-dessus par `htmlcov/` et +# `coverage_html_report/`. + +# ============================================================================= +# Internationalisation +# ============================================================================= +# Le gabarit de catalogue est entierement regenerable : +# pybabel extract -F babel.cfg -k _l -o messages.pot . +# Les catalogues .po (sources de traduction) et .mo (compiles, lus a +# l'execution) sont eux versionnes : le deploiement est un simple miroir de +# fichiers, sans etape de compilation. +messages.pot diff --git a/README.md b/README.md index 91c27d9..01036bd 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,153 @@ # Plateforme centralisée de tryouts -## Security Configuration +Application interne du club e-sport de l'UdeS : inscriptions aux sélections, +évaluations, gestion des équipes, disponibilités, contrats, et notifications +Discord. -### Required Environment Variables +Le site est servi **en français**, l'anglais reste accessible par le sélecteur +de la barre latérale (voir `docs/translations.md`). -Before deploying, create a `.env` file which integrates everything in the .env.exemple. -Ensure you follow the comments of the exemple if you are to use this tool in production. +--- +## Démarrer -### Security Features Implemented +```bash +python -m venv .venv +.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt +cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL +.venv/Scripts/python run.py # développement, http://127.0.0.1:5000 +``` -- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks -- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled -- **CSRF Protection**: Enabled by default on all forms -- **HTTPS Enforcement**: Automatic redirect to HTTPS in production -- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS -- **Open Redirect Prevention**: URL validation on login redirect -- **Authorization Checks**: Proper ownership validation on all sensitive operations -- **nginx**: reverse-proxy and load balancer -- **Waitress WSGI**: Production ready WSGI +`SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse +de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote +psycopg 3 est nommé automatiquement si l'URL n'en nomme pas. -### When true in .env: -- **Forces HTTPS only** -- **Forcer secure cookies** +Production : `python wsgi.py` (Waitress derrière nginx). Voir +`docs/deployment.md`. -## App details +Ce sont les **deux seuls** points d'entrée. -### Code +## Vérifier -- Full python backend using flask -- statics are pure HTML and CSS -- Some js to add logic to styling and showing certain pages/cards +```bash +.venv/Scripts/python -m pytest # suite complète +.venv/Scripts/python -m ruff check . # lint +.venv/Scripts/python -m ruff format --check . +``` -### Functionalities +Les trois tournent en CI et y sont bloquants. -- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role. -- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only -- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria -- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time -- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach. -- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes. -- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note. -- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response. -- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning. +--- +## Ce que fait l'application -## Discord Integration +- **Comptes et rôles** — cinq rôles : président (`admin`), gérant + (`manager`), coach, joueur (`player`), recruteur (`scout`). Le président + attribue les rôles. +- **Sélections** — organisation des tryouts, trois formats de match + (équipe contre équipe, joueur contre joueur, scrim), évaluation des + joueurs sur dix critères. +- **Équipes** — effectifs de la saison, matchs et entraînements. Le + formulaire d'entraînement affiche les disponibilités des joueurs. +- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux + réservables des coachs. +- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et + des notes nominatives (visibles par le joueur concerné). +- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond + depuis le site ou par une réaction sur le message privé Discord. +- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur. -The application supports sending Discord direct messages to coaches when players request One on One sessions, -when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match. +## Comment c'est construit -When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request. -Same thing with **matches** and **practices**, the players can react or answer on the platform. +Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript +maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord +(`discord.py`) dans un fil du même processus que le serveur web. -### Setup Instructions +`docs/architecture.md` contient les diagrammes (classes, paquets, flux +d'une requête). -#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists) +--- -1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) -2. Create a new application -3. Go to the "Bot" tab and create a bot user -4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN` -5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages) +## Sécurité -#### 2. Add Bot to your server +En place et vérifié par des tests : -For the bot to send DMs: -1. Each user must have the bot added to their Discord server OR be friends with the bot -2. Users need to add their Discord User ID to their profile: - - Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode) - - Right-click on their profile → Copy ID - - Enter this numeric ID in the "Discord User ID" field in their profile settings +- **Limitation de débit** sur la connexion (10 requêtes/minute par IP). +- **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec + expiration effective. +- **CSRF** sur tous les formulaires, y compris la déconnexion (en POST). +- **HTTPS** forcé en production, **HSTS**. +- **CSP sans `unsafe-inline`** sur `script-src` : aucun gestionnaire + d'événement en ligne, chaque bloc ` {% block scripts %}{% endblock %} - \ No newline at end of file + diff --git a/app/templates/layouts/macros.html b/app/templates/layouts/macros.html index c37cfea..827435e 100644 --- a/app/templates/layouts/macros.html +++ b/app/templates/layouts/macros.html @@ -7,7 +7,7 @@ {# Page Header Macro - renders title and breadcrumb #} {% macro page_header(title, breadcrumb) %} - {% block title %}{{ title }} - TryoutPro{% endblock %} + {% block title %}{{ title }} - UdeS team manager{% endblock %} {% block page_title %}{{ title }}{% endblock %} {% block breadcrumb %}{{ breadcrumb }}{% endblock %} {% endmacro %} @@ -108,11 +108,12 @@ {# Modal Macro - renders a modal dialog #} {% macro modal(id, title, content, footer_buttons=None) %}