57 Commits

Author SHA1 Message Date
herel 0615dc6421 fix: remove Cross-Account Data Leakage 2026-06-29 23:04:48 +02:00
herel 71bbb67cef chore: remove debugging prints 2026-06-29 23:03:48 +02:00
herel a17c362c81 fix(frontend): Sanitize DOM rendering to prevent XSS via innerHTML
Replace unsafe innerHTML template literals with document.createElement()
and textContent for all user-conrolled data fields in index.html.

Event cards and modal details previously interpolated API responses
(event.name, event.title, player.fname, staff.role, etc.) directly into
innerHTML without escaping, allowing stored XSS if upstream MyIce data
is malicious (CWE-79 / vuln-0002).

All dynamic text now flows through textContent or createTextNode(),
while static HTML structures (SVG icons, strong labels) remain safe
hardcoded innerHTML assignments.

Refs vuln-0002
2026-06-29 20:05:45 +02:00
herel 77b2531106 fix(api): Send PKCE code_verifier in token exchange and secure OIDC cookies
The /exchange-token endpoint was not sending the code_verifier to the
token endpoint, rendering PKCE protection ineffective. Add the verifier
to the POST body so the IdP can validate the authorization code exchange.

Also add secure=True and samesite='Lax' to all OIDC cookies on /login
to improve cookie security per the recommended remediation.

Refs vuln-0004
2026-06-29 19:49:07 +02:00
herel 0d39efd034 fix: use tailwindcss cli 2026-06-29 18:02:10 +02:00
herel 12aada4986 fix: better subgroup 2026-06-29 17:35:48 +02:00
herel 9795a4c813 fix: proper jwt verification 2026-06-29 17:35:36 +02:00
herel 46ae216e97 fix: IDC State/Nonce Not Validated 2026-06-29 16:53:14 +02:00
herel 19724b1f8b fix: add timeout for request 2026-06-29 16:52:35 +02:00
herel 9e5db51972 fix: Weak JWT Validation 2026-06-29 16:48:02 +02:00
herel c0bfa85688 fix: No static key fallback; reject non-OIDC tokens 2026-06-29 16:43:09 +02:00
herel ffe0a1cf18 fix: remove hard coded old secret 2026-06-29 16:42:51 +02:00
herel ff69806803 fix: Permissive CORS Configuration 2026-06-29 16:40:36 +02:00
herel 524c4db889 fix: Missing Authentication Checks 2026-06-29 16:37:39 +02:00
herel 6b2965c18a fix(security): Authentication Bypass via Broken JWT Validation in AuthHeaders.validate_oidc_token 2026-06-29 16:33:21 +02:00
herel 9363a1f7ef chore: add some debug 2026-06-29 16:30:35 +02:00
herel 449f5450fe chore: update categories 2026-06-29 16:28:15 +02:00
herel f302357545 fix: prevent OS Command Injection 2026-06-29 16:19:33 +02:00
herel b1a792502f fix(schedule): Use midnight as schedule start date
Change date_start from current time to today at midnight so the
schedule fetch query properly covers the full day.
2026-06-05 15:12:33 +02:00
herel 38e3564524 chore: update Dockerfile 2025-11-11 11:06:40 +01:00
herel 6a240c5773 feat: add ESC key support to close event details modal and bump version to v0.6.0 2025-11-11 08:51:58 +01:00
herel b04e8e2900 docs: improve AGENTS.md with better build/lint/test commands and code style guidelines 2025-11-11 08:46:13 +01:00
herel f33bb46d73 chore(docker): use docker cache mount for pip install step 2025-10-15 16:03:01 +02:00
herel cd32efb657 feat: redesign UI with Tailwind CSS and improve event display 2025-10-15 15:59:57 +02:00
herel 747e9db582 chore: use python image 3.13-slim instead of 3.13-alpine 2025-10-01 16:22:48 +02:00
herel 768ac8375b feat: slim down Docker image size by 8x using Alpine Linux base and multi-stage build 2025-09-30 09:22:26 +02:00
herel 4a5b2a5991 chore: update fastapi and dependencies to latest versions 2025-09-30 08:37:56 +02:00
herel c099f1328b chore: migrate to python 3.13 and update dependencies
Migrate from Python 3.11 to 3.13 with updated dependencies. Switch from PyPDF2 to pypdf library for better PDF processing. Add new U14 age groups and extract-pdf utility script.
2025-09-29 23:05:48 +02:00
herel fe8a4e5d09 refactor: migrate to distroless multi-stage Docker build 2025-09-29 22:07:11 +02:00
herel 781d7000cd feat: display staff information and improve empty state handling in game details modal 2025-09-29 20:17:23 +02:00
herel 6903dab06b doc: fix markdownlint issues in AGENTS.md 2025-09-23 09:33:44 +02:00
herel abfad23a7a chore: bump version to v0.5.7 2025-08-20 11:10:20 +02:00
herel 68ecce7d3a feat: layout filters horizontally 2025-08-20 11:10:06 +02:00
herel 1941a924e2 chore: bump version to v0.5.6 2025-08-20 10:17:08 +02:00
herel 702c425476 feat: add secondary agegroup selector for subgroup filtering
- Added subgroup dropdown selector to filter events by team within agegroup
- Implemented logic to populate subgroup options based on selected agegroup
- Updated event filtering to support both agegroup and subgroup criteria
- Added event listeners for real-time filtering when selectors change
2025-08-20 10:17:00 +02:00
herel a74da811c1 chore: bump version to v0.5.5 2025-08-19 19:22:21 +02:00
herel dac110cee0 feat: auto-load events on login and improve player display in event details
- Automatically load events when user logs in
- Rename updateAccountOptions to updateAccountOptionsAndLoadEvents
- Add auto-fetch of events after account selection
- Enhance event details modal with:
  * Player count summary
  * Position breakdown
  * Players sorted by position first, then by number
  * Position displayed in player list
2025-08-19 19:21:50 +02:00
herel 0fd58848f2 chore: add event title 2025-08-19 19:12:45 +02:00
herel de222023a8 chore: bump version to v0.5.4 2025-08-19 19:06:47 +02:00
herel 08aa3f6a0e feat: update agegroup filter to show only agegroup and filter accordingly 2025-08-19 19:06:24 +02:00
herel 0e02245f21 chore: bump version to v0.5.3 2025-08-19 18:50:12 +02:00
herel a86cf76228 fix: improve UI layout and add loading indicator for events 2025-08-19 18:48:56 +02:00
herel 3ab3273fba fix: finalize UI improvements and ensure all functionality works
Final adjustments to UI layout and positioning. Ensured all JavaScript functions are properly connected and working. Verified event loading functionality is fully restored.
2025-08-19 16:12:51 +02:00
herel a17467c313 fix: restore event loading functionality and improve UI layout
Fixed incomplete fetchEvents function by adding missing data processing logic. Restored updateAgeGroupOptions, displayEvents, and fetchEventDetails functions. Moved connected user info to top-right corner. Hide title when logged in. Reduced vertical spacing for better layout. Made event filters more compact. All functionality restored and UI improved.
2025-08-19 16:09:57 +02:00
herel 9c6df3f6a2 feat: enhance login button styling and fix JavaScript syntax errors
Centered the login button on the page with improved styling including padding and shadow effects. Fixed JavaScript syntax errors in the event handling code. Removed unmatched closing div tags to ensure valid HTML structure.
2025-08-19 15:49:28 +02:00
herel a17065b280 fix: improve user authentication and account handling
Fixed issues with user display by fetching user info from userinfo endpoint

Improved error handling for JSON responses in schedule endpoint

Fixed account selection to use available accounts from config instead of default

Enhanced frontend to properly handle API responses and errors
2025-08-19 15:17:45 +02:00
herel 5f12fb08b8 fix: improve authentication handling and error management 2025-08-19 15:04:53 +02:00
herel a2e44b6f50 feat: implement OpenID Connect authentication with Infomaniak 2025-08-19 11:16:07 +02:00
herel 15c148e233 chore: bump version to v0.5.2 2025-08-19 10:48:55 +02:00
herel 7b6bf91502 fix: escape control characters in JSON output to make it jq compatible 2025-08-19 10:48:04 +02:00
herel c5863ebc5f chore: bump version to v0.5.1 2025-08-19 09:41:57 +02:00
herel 6c5668d608 fix: improve JSON response handling in API calls 2025-08-19 09:41:28 +02:00
herel 0c62fcaa01 feat: make config_section a global CLI option
Users can now specify --config-section once at the beginning of the command instead of repeating it for each subcommand. Also bumped version to v0.5.0 for this new feature.
2025-08-19 09:25:50 +02:00
herel bec33d0545 chore: display userid after fetch 2025-08-19 09:10:04 +02:00
herel c602727f08 fix: correct project scripts section name and bump version
- Changed [projectscripts] to [project.scripts] in pyproject.toml to properly register the myice command line tool
- Bump version to v0.4.3
2025-08-19 09:03:42 +02:00
herel 6be182a894 fix: resolve typer/click compatibility issue with make_metavar error
Fixed TypeError: Parameter.make_metavar() missing 1 required positional argument: 'ctx'
by correcting typer.Option(...) usage for optional parameters with default None values.

Bump version to v0.4.2
2025-08-19 08:57:37 +02:00
herel fafefd3085 chore: bump version to v0.4.1 and update dependencies 2025-08-19 08:52:39 +02:00
13 changed files with 1338 additions and 881 deletions
+5 -1
View File
@@ -3,7 +3,11 @@ myice.ini
schedule.json schedule.json
.envrc .envrc
cookies.txt cookies.txt
# Byte-compiled / optimized / DLL files # Tailwind CSS generated artifacts
style.css
tailwindcss
# Python bytecode
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
+15
View File
@@ -48,6 +48,21 @@ pre-commit run ruff-format --files myice/webapi.py
pre-commit run mypy --files myice/myice.py pre-commit run mypy --files myice/myice.py
``` ```
### CSS/Frontend Build
When modifying `index.html` or the Tailwind CSS setup:
```bash
# One-off build of style.css
./build-css.sh
# Watch mode for development
./build-css.sh --watch
# Or manually with the standalone CLI (if already downloaded)
./tailwindcss -i ./style-input.css -o ./style.css --minify
```
### Running the Web API ### Running the Web API
```bash ```bash
+36 -14
View File
@@ -4,34 +4,56 @@ FROM python:3.13-slim AS builder
# Create working directory # Create working directory
WORKDIR /app WORKDIR /app
# poetry export -f requirements.txt --output requirements.txt --without-hashes # Install tools needed for Tailwind CSS build
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency files # Copy dependency files
COPY requirements.txt ./ COPY requirements.txt ./
# Install dependencies to a target directory # Install dependencies to a target directory
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --no-deps --disable-pip-version-check --target=/app/site-packages -r requirements.txt pip install --no-deps --disable-pip-version-check -r requirements.txt
# Use Alpine as the base image for a much smaller footprint # Build Tailwind CSS
FROM python:3.13-slim COPY index.html style-input.css tailwind.config.js ./
RUN curl -sL https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-x64 -o tailwindcss \
&& chmod +x tailwindcss \
&& ./tailwindcss -i ./style-input.css -o ./style.css --minify
# Copy installed packages from builder stage # Runtime stage
COPY --from=builder /app/site-packages /app/site-packages FROM python:3.13-slim AS runtime
# Copy application code # Create working directory
COPY index.html favicon.ico /app/
COPY myice /app/myice
# Set PYTHONPATH so Python can find our installed packages
ENV PYTHONPATH=/app/site-packages
# Set working directory
WORKDIR /app WORKDIR /app
# Install only runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for security # Create a non-root user for security
RUN useradd --home-dir /app --no-create-home --uid 1000 myice RUN useradd --home-dir /app --no-create-home --uid 1000 myice
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# Copy application code and compiled assets
COPY index.html favicon.ico ./
COPY --from=builder /app/style.css ./style.css
COPY myice ./myice
# Change ownership of copied files
RUN chown -R myice:myice /app
# Switch to non-root user
USER myice USER myice
# Bytecompile Python files for faster first load
RUN python -m compileall -q ./myice
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
# Build CSS using Tailwind CSS standalone CLI
# For local development, run:
# ./build-css.sh # one-off build
# ./build-css.sh --watch # watch mode
version="v3.4.17"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux*) platform="linux";;
Darwin*) platform="macos";;
*) echo "Unsupported OS: $os"; exit 1;;
esac
case "$arch" in
x86_64) arch="x64";;
arm64) arch="arm64";;
aarch64) arch="arm64";;
*) echo "Unsupported architecture: $arch"; exit 1;;
esac
binary="tailwindcss-${platform}-${arch}"
url="https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${binary}"
if [ ! -f "tailwindcss" ]; then
echo "Downloading Tailwind CSS standalone CLI (${version} ${platform}/${arch})..."
curl -sL "$url" -o tailwindcss
chmod +x tailwindcss
fi
if [ "${1:-}" == "--watch" ]; then
echo "Building CSS in watch mode..."
./tailwindcss -i ./style-input.css -o ./style.css --watch
else
echo "Building CSS..."
./tailwindcss -i ./style-input.css -o ./style.css --minify
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
REGISTRY="harbor.cl1.parano.ch/library"
IMAGE="myice"
current_commit=$(git rev-parse HEAD)
git_tag=$(git tag --points-at "$current_commit")
if [[ -z $git_tag ]]; then
echo "Build only works on a git tag" >&2
exit 1
fi
docker build --platform linux/amd64 -t "$REGISTRY/$IMAGE:$git_tag" .
docker push "$REGISTRY/$IMAGE:$git_tag"
+186 -123
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyIce - Games</title> <title>MyIce - Games</title>
<script src="https://cdn.tailwindcss.com"></script> <link rel="stylesheet" href="/style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon"> <link rel="icon" href="favicon.ico" type="image/x-icon">
</head> </head>
@@ -475,6 +475,17 @@
subgroupSelect.innerHTML = '<option value="">Tous</option>'; subgroupSelect.innerHTML = '<option value="">Tous</option>';
} }
function extractSubgroup(name) {
// Extract subgroup from name by removing opponent part after " - "
if (!name) return "";
const parts = name.split(" - ");
if (parts.length > 1) {
parts.pop(); // Remove opponent
return parts.join(" - ");
}
return name;
}
function updateSubgroupOptions(selectedAgegroup, events) { function updateSubgroupOptions(selectedAgegroup, events) {
// Reset subgroup options // Reset subgroup options
subgroupSelect.innerHTML = '<option value="">Tous</option>'; subgroupSelect.innerHTML = '<option value="">Tous</option>';
@@ -493,10 +504,9 @@
events events
.filter(event => event.agegroup === selectedAgegroup) .filter(event => event.agegroup === selectedAgegroup)
.forEach(event => { .forEach(event => {
// Extract subgroup from event.name or event.title // Extract subgroup from event.name by removing opponent
// This assumes the subgroup is part of the name field
if (event.name && event.name !== selectedAgegroup) { if (event.name && event.name !== selectedAgegroup) {
subgroups.add(event.name); subgroups.add(extractSubgroup(event.name));
} }
}); });
@@ -521,7 +531,8 @@
if (selectedAgegroup !== "" && event.agegroup !== selectedAgegroup) return false; if (selectedAgegroup !== "" && event.agegroup !== selectedAgegroup) return false;
// Filter by subgroup // Filter by subgroup
if (selectedSubgroup !== "" && event.name !== selectedSubgroup) return false; let eventSubgroup = extractSubgroup(event.name);
if (selectedSubgroup !== "" && eventSubgroup !== selectedSubgroup) return false;
return true; return true;
}); });
@@ -534,40 +545,49 @@
filteredEvents.forEach(event => { filteredEvents.forEach(event => {
const eventCard = document.createElement("div"); const eventCard = document.createElement("div");
eventCard.className = "bg-white rounded-2xl shadow-lg overflow-hidden transform hover:scale-105 transition-all duration-300 cursor-pointer"; eventCard.className = "bg-white rounded-2xl shadow-lg overflow-hidden transform hover:scale-105 transition-all duration-300 cursor-pointer";
eventCard.innerHTML = `
<div class="p-6 border-l-4" style="border-color: ${event.color || '#818cf8'}">
<div class="flex justify-between items-start">
<div>
<h3 class="text-xl font-bold text-gray-800">${event.agegroup} - ${event.name}</h3>
<p class="text-gray-600 mt-1">${event.title}</p>
</div>
</div>
<div class="mt-4 space-y-2"> const card = document.createElement("div");
<p class="flex items-center text-gray-700"> card.className = "p-6 border-l-4";
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> card.style.borderColor = event.color || "#818cf8";
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<strong>Adversaire:</strong> ${event.opponent}
</p>
<p class="flex items-center text-gray-700"> const inner = document.createElement("div");
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> inner.className = "flex justify-between items-start";
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<strong>Lieu:</strong> ${event.place}
</p>
<p class="flex items-center text-gray-700"> const innerDiv = document.createElement("div");
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> const h3 = document.createElement("h3");
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> h3.className = "text-xl font-bold text-gray-800";
</svg> h3.textContent = event.agegroup + " - " + event.name;
<strong>Heure:</strong> ${event.start} - ${event.end} const subtitle = document.createElement("p");
</p> subtitle.className = "text-gray-600 mt-1";
</div> subtitle.textContent = event.title;
</div> innerDiv.appendChild(h3);
`; innerDiv.appendChild(subtitle);
inner.appendChild(innerDiv);
card.appendChild(inner);
const detailsDiv = document.createElement("div");
detailsDiv.className = "mt-4 space-y-2";
const p1 = document.createElement("p");
p1.className = "flex items-center text-gray-700";
p1.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg><strong>Adversaire:</strong> ';
p1.appendChild(document.createTextNode(event.opponent));
detailsDiv.appendChild(p1);
const p2 = document.createElement("p");
p2.className = "flex items-center text-gray-700";
p2.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg><strong>Lieu:</strong> ';
p2.appendChild(document.createTextNode(event.place));
detailsDiv.appendChild(p2);
const p3 = document.createElement("p");
p3.className = "flex items-center text-gray-700";
p3.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><strong>Heure:</strong> ';
p3.appendChild(document.createTextNode(event.start + " - " + event.end));
detailsDiv.appendChild(p3);
card.appendChild(detailsDiv);
eventCard.appendChild(card);
eventCard.addEventListener("click", () => fetchEventDetails(event.id_event)); eventCard.addEventListener("click", () => fetchEventDetails(event.id_event));
eventList.appendChild(eventCard); eventList.appendChild(eventCard);
}); });
@@ -580,12 +600,10 @@
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Check if available players data exists
const availablePlayers = data.convocation.available || []; const availablePlayers = data.convocation.available || [];
const sortedPlayers = availablePlayers const sortedPlayers = availablePlayers
.sort((a, b) => (a.number || 0) - (b.number || 0)); .sort((a, b) => (a.number || 0) - (b.number || 0));
// Calculate player statistics
const totalPlayers = sortedPlayers.length; const totalPlayers = sortedPlayers.length;
const positionCount = {}; const positionCount = {};
sortedPlayers.forEach(player => { sortedPlayers.forEach(player => {
@@ -593,113 +611,158 @@
positionCount[position] = (positionCount[position] || 0) + 1; positionCount[position] = (positionCount[position] || 0) + 1;
}); });
// Generate position breakdown
const positionBreakdown = Object.entries(positionCount) const positionBreakdown = Object.entries(positionCount)
.map(([position, count]) => `${position}: ${count}`) .map(([position, count]) => position + ": " + count)
.join(', '); .join(", ");
// Sort players by position first, then by number
const playersByPosition = [...sortedPlayers].sort((a, b) => { const playersByPosition = [...sortedPlayers].sort((a, b) => {
// Sort by position first const positionA = a.position || "ZZZ";
const positionA = a.position || "ZZZ"; // Put undefined positions at the end
const positionB = b.position || "ZZZ"; const positionB = b.position || "ZZZ";
if (positionA !== positionB) { if (positionA !== positionB) {
return positionA.localeCompare(positionB); return positionA.localeCompare(positionB);
} }
// If positions are the same, sort by number
const numA = parseInt(a.number) || 0; const numA = parseInt(a.number) || 0;
const numB = parseInt(b.number) || 0; const numB = parseInt(b.number) || 0;
return numA - numB; return numA - numB;
}); });
// Process staff data
const staffList = data.convocation.staff || []; const staffList = data.convocation.staff || [];
const totalStaff = staffList.length; const totalStaff = staffList.length;
// Check if there are no players eventDetailsContent.innerHTML = "";
if (totalPlayers === 0 && totalStaff === 0) { if (totalPlayers === 0 && totalStaff === 0) {
eventDetailsContent.innerHTML = ` const wrapper = document.createElement("div");
<div class="rounded-xl border border-amber-300 bg-amber-50 p-6"> wrapper.className = "rounded-xl border border-amber-300 bg-amber-50 p-6";
<div class="text-center"> const centerDiv = document.createElement("div");
<h5 class="text-2xl font-bold text-gray-800 mb-4">${data.title}</h5> centerDiv.className = "text-center";
<div class="space-y-2 text-gray-700 mb-6"> const h5 = document.createElement("h5");
<p><strong>Type:</strong> ${data.type}</p> h5.className = "text-2xl font-bold text-gray-800 mb-4";
<p><strong>Lieu:</strong> ${data.place}</p> h5.textContent = data.title;
<p><strong>Heure:</strong> ${data.time_start} - ${data.time_end}</p> centerDiv.appendChild(h5);
</div>
<div class="bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg"> const infoDiv = document.createElement("div");
<h6 class="font-bold text-lg mb-1">Aucun joueur ni personnel convoqué</h6> infoDiv.className = "space-y-2 text-gray-700 mb-6";
<p>Il n'y a actuellement aucun joueur ni personnel convoqué pour ce match.</p> const pType = document.createElement("p");
</div> pType.innerHTML = "<strong>Type:</strong> ";
</div> pType.appendChild(document.createTextNode(data.type));
</div> infoDiv.appendChild(pType);
`; const pLieu = document.createElement("p");
pLieu.innerHTML = "<strong>Lieu:</strong> ";
pLieu.appendChild(document.createTextNode(data.place));
infoDiv.appendChild(pLieu);
const pHeure = document.createElement("p");
pHeure.innerHTML = "<strong>Heure:</strong> ";
pHeure.appendChild(document.createTextNode(data.time_start + " - " + data.time_end));
infoDiv.appendChild(pHeure);
centerDiv.appendChild(infoDiv);
const msgDiv = document.createElement("div");
msgDiv.className = "bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg";
const msgH6 = document.createElement("h6");
msgH6.className = "font-bold text-lg mb-1";
msgH6.textContent = "Aucun joueur ni personnel convoqué";
msgDiv.appendChild(msgH6);
const msgP = document.createElement("p");
msgP.textContent = "Il n'y a actuellement aucun joueur ni personnel convoqué pour ce match.";
msgDiv.appendChild(msgP);
centerDiv.appendChild(msgDiv);
wrapper.appendChild(centerDiv);
eventDetailsContent.appendChild(wrapper);
} else { } else {
let staffHtml = ''; const container = document.createElement("div");
if (totalStaff > 0) { const h5 = document.createElement("h5");
staffHtml = ` h5.className = "text-2xl font-bold text-gray-800 mb-4";
<div class="mt-6"> h5.textContent = data.title;
<h6 class="text-xl font-semibold text-gray-800 mb-3">Personnel (${totalStaff}):</h6> container.appendChild(h5);
<ul class="space-y-2">
${staffList.map(staff => { const infoDiv = document.createElement("div");
return `<li class="flex items-center"><span class="font-medium w-32">${staff.role}:</span> <span>${staff.fname} ${staff.lname}</span></li>`; infoDiv.className = "space-y-2 text-gray-700 mb-6";
}).join('')} const pType = document.createElement("p");
</ul> pType.innerHTML = "<strong>Type:</strong> ";
</div> pType.appendChild(document.createTextNode(data.type));
`; infoDiv.appendChild(pType);
} else { const pLieu = document.createElement("p");
staffHtml = ` pLieu.innerHTML = "<strong>Lieu:</strong> ";
<div class="mt-6 bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg"> pLieu.appendChild(document.createTextNode(data.place));
<h6 class="font-bold mb-1">Aucun personnel convoqué</h6> infoDiv.appendChild(pLieu);
<p>Il n'y a actuellement aucun personnel convoqué pour ce match.</p> const pHeure = document.createElement("p");
</div> pHeure.innerHTML = "<strong>Heure:</strong> ";
`; pHeure.appendChild(document.createTextNode(data.time_start + " - " + data.time_end));
} infoDiv.appendChild(pHeure);
container.appendChild(infoDiv);
if (totalPlayers === 0) { if (totalPlayers === 0) {
eventDetailsContent.innerHTML = ` const noPlayersDiv = document.createElement("div");
<div> noPlayersDiv.className = "bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg";
<h5 class="text-2xl font-bold text-gray-800 mb-4">${data.title}</h5> const noPlayersH6 = document.createElement("h6");
<div class="space-y-2 text-gray-700 mb-6"> noPlayersH6.className = "font-bold mb-1";
<p><strong>Type:</strong> ${data.type}</p> noPlayersH6.textContent = "Aucun joueur convoqué";
<p><strong>Lieu:</strong> ${data.place}</p> noPlayersDiv.appendChild(noPlayersH6);
<p><strong>Heure:</strong> ${data.time_start} - ${data.time_end}</p> const noPlayersP = document.createElement("p");
</div> noPlayersP.textContent = "Il n'y a actuellement aucun joueur convoqué pour ce match.";
<div class="bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg"> noPlayersDiv.appendChild(noPlayersP);
<h6 class="font-bold mb-1">Aucun joueur convoqué</h6> container.appendChild(noPlayersDiv);
<p>Il n'y a actuellement aucun joueur convoqué pour ce match.</p>
</div>
${staffHtml}
</div>
`;
} else { } else {
eventDetailsContent.innerHTML = ` const playersP = document.createElement("p");
<div> playersP.innerHTML = "<strong>Joueurs convoqués:</strong> ";
<h5 class="text-2xl font-bold text-gray-800 mb-4">${data.title}</h5> playersP.appendChild(document.createTextNode(totalPlayers + " joueur" + (totalPlayers > 1 ? "s" : "") + " (" + positionBreakdown + ")"));
<div class="space-y-2 text-gray-700 mb-6"> infoDiv.appendChild(playersP);
<p><strong>Type:</strong> ${data.type}</p>
<p><strong>Lieu:</strong> ${data.place}</p>
<p><strong>Heure:</strong> ${data.time_start} - ${data.time_end}</p>
<p><strong>Joueurs convoqués:</strong> ${totalPlayers} joueur${totalPlayers > 1 ? 's' : ''} (${positionBreakdown})</p>
</div>
<div> const playersDiv = document.createElement("div");
<h6 class="text-xl font-semibold text-gray-800 mb-3">Liste des joueurs:</h6> const playersH6 = document.createElement("h6");
<ul class="grid grid-cols-1 md:grid-cols-2 gap-3"> playersH6.className = "text-xl font-semibold text-gray-800 mb-3";
${playersByPosition.map(player => { playersH6.textContent = "Liste des joueurs:";
let number = player.number ? player.number : "N/A"; playersDiv.appendChild(playersH6);
let position = player.position ? player.position : "N/A"; const playersUl = document.createElement("ul");
return `<li class="bg-indigo-50 rounded-lg p-3"><span class="font-medium">[${position}] ${number}</span> - ${player.fname} ${player.lname} <span class="text-gray-500 text-sm">(${player.dob})</span></li>`; playersUl.className = "grid grid-cols-1 md:grid-cols-2 gap-3";
}).join('')} playersByPosition.forEach(player => {
</ul> const li = document.createElement("li");
</div> li.className = "bg-indigo-50 rounded-lg p-3";
const number = player.number ? player.number : "N/A";
${staffHtml} const position = player.position ? player.position : "N/A";
</div> li.textContent = "[" + position + "] " + number + " - " + player.fname + " " + player.lname + " (" + player.dob + ")";
`; playersUl.appendChild(li);
});
playersDiv.appendChild(playersUl);
container.appendChild(playersDiv);
} }
if (totalStaff > 0) {
const staffDiv = document.createElement("div");
staffDiv.className = "mt-6";
const staffH6 = document.createElement("h6");
staffH6.className = "text-xl font-semibold text-gray-800 mb-3";
staffH6.textContent = "Personnel (" + totalStaff + "):";
staffDiv.appendChild(staffH6);
const staffUl = document.createElement("ul");
staffUl.className = "space-y-2";
staffList.forEach(staff => {
const li = document.createElement("li");
li.className = "flex items-center";
const roleSpan = document.createElement("span");
roleSpan.className = "font-medium w-32";
roleSpan.textContent = staff.role + ":";
li.appendChild(roleSpan);
li.appendChild(document.createTextNode(" " + staff.fname + " " + staff.lname));
staffUl.appendChild(li);
});
staffDiv.appendChild(staffUl);
container.appendChild(staffDiv);
} else {
const noStaffDiv = document.createElement("div");
noStaffDiv.className = "mt-6 bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg";
const noStaffH6 = document.createElement("h6");
noStaffH6.className = "font-bold mb-1";
noStaffH6.textContent = "Aucun personnel convoqué";
noStaffDiv.appendChild(noStaffH6);
const noStaffP = document.createElement("p");
noStaffP.textContent = "Il n'y a actuellement aucun personnel convoqué pour ce match.";
noStaffDiv.appendChild(noStaffP);
container.appendChild(noStaffDiv);
}
eventDetailsContent.appendChild(container);
} }
eventDetailsModal.classList.remove("hidden"); eventDetailsModal.classList.remove("hidden");
}) })
+41 -9
View File
@@ -140,14 +140,14 @@ class AgeGroup(str, Enum):
u13a = "U13 (A)" u13a = "U13 (A)"
u13p = "U13 (Prép)" u13p = "U13 (Prép)"
u14ev = "U14 (Elite Vernets)" u14ev = "U14 (Elite Vernets)"
u14sae = "U14 (Groupe SAE)"
u14esmv = "U14 (Elite Sous-Moulin/Vergers)" u14esmv = "U14 (Elite Sous-Moulin/Vergers)"
u15t = "U15 (Top)" u15t = "U15 (Top)"
u15a = "U15 (A)" u15a = "U15 (A)"
u16e = "U16 (Elite)" u16e = "U16 (Elit)"
u16t = "U16 (Top GS Ass)" u16t = "U16 (Top GS Ass)"
u18e = "U18 (Elite)" u18e = "U18 (Elit)"
u18el = "U18 (Elit)" u21e = "U21 (Elit)"
u21e = "U21 (ELIT)"
u14t = "U14 (Top)" u14t = "U14 (Top)"
u14t1 = "U14 (Top1)" u14t1 = "U14 (Top1)"
u14t2 = "U14 (Top2)" u14t2 = "U14 (Top2)"
@@ -334,7 +334,7 @@ def get_schedule(num_days: int) -> str:
global userid global userid
assert session and userid assert session and userid
now = datetime.datetime.now() now = datetime.datetime.now()
date_start = now date_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start + datetime.timedelta(days=num_days) date_end = date_start + datetime.timedelta(days=num_days)
r = session.post( r = session.post(
"https://app.myice.hockey/inc/processclubplanning.php", "https://app.myice.hockey/inc/processclubplanning.php",
@@ -418,11 +418,13 @@ def schedule(
def os_open(file: str) -> None: def os_open(file: str) -> None:
import subprocess
if os.uname().sysname == "Linux": if os.uname().sysname == "Linux":
os.system(f"xdg-open {file}") subprocess.run(["xdg-open", file])
else: else:
print(f"Opening file {file}", file=sys.stderr) print(f"Opening file {file}", file=sys.stderr)
os.system(f"open {file}") subprocess.run(["open", file])
def extract_players(pdf_file: Path) -> List[str]: def extract_players(pdf_file: Path) -> List[str]:
@@ -592,6 +594,18 @@ mobile_headers = {
} }
# def curl_for_mobile_login(username: str, password: str) -> str:
# """Generate curl command to replicate mobile_login request."""
# import base64
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# encoded_username = base64.b64encode(username.encode()).decode()
# encoded_password = base64.b64encode(password.encode()).decode()
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/login" \
# {headers_str} \
# -d 'login_email={encoded_username}&login_password={encoded_password}&language=FR&v=2'"""
def mobile_login(config_section: str | None = None): def mobile_login(config_section: str | None = None):
global global_config_section global global_config_section
import base64 import base64
@@ -604,10 +618,13 @@ def mobile_login(config_section: str | None = None):
return {"id": 0, "token": token, "id_club": club_id} return {"id": 0, "token": token, "id_club": club_id}
print("Requesting token", file=sys.stderr) print("Requesting token", file=sys.stderr)
# Print curl equivalent for debugging
# print(curl_for_mobile_login(username, password), file=sys.stderr)
with requests.post( with requests.post(
"https://app.myice.hockey/api/mobilerest/login", "https://app.myice.hockey/api/mobilerest/login",
headers=mobile_headers, headers=mobile_headers,
data=f"login_email={base64.b64encode(username.encode()).decode()}&login_password={base64.b64encode(password.encode()).decode()}&language=FR&v=2", data=f"login_email={base64.b64encode(username.encode()).decode()}&login_password={base64.b64encode(password.encode()).decode()}&language=FR&v=2",
timeout=(5, 30),
# verify=False, # verify=False,
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
@@ -626,11 +643,26 @@ userdata = {
} }
def refresh_data(): # def curl_for_refresh_data(token: str, club_id: int) -> str:
# """Generate curl command to replicate refresh_data request."""
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/refreshdata" \
# {headers_str} \
# -d 'token={token}&id_club={club_id}&language=FR'"""
def refresh_data(user_data=None):
# Use provided user_data or fall back to global userdata for backward compatibility
if user_data is None:
user_data = userdata
# Print curl equivalent for debugging
# print(
# curl_for_refresh_data(user_data["token"], user_data["id_club"]), file=sys.stderr
# )
with requests.post( with requests.post(
"https://app.myice.hockey/api/mobilerest/refreshdata", "https://app.myice.hockey/api/mobilerest/refreshdata",
headers=mobile_headers, headers=mobile_headers,
data=f"token={userdata['token']}&id_club={userdata['id_club']}&language=FR", data=f"token={user_data['token']}&id_club={user_data['id_club']}&language=FR",
# verify=False, # verify=False,
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
+133 -61
View File
@@ -1,19 +1,22 @@
import base64
import hashlib
import logging import logging
import requests
import os import os
from typing import Annotated import requests
from fastapi import FastAPI, Header, HTTPException, status, Request from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Annotated
from . import myice from . import myice
# OIDC Configuration # OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f") CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get( CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ" if not CLIENT_SECRET:
) raise RuntimeError("CLIENT_SECRET environment variable must be set")
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback") REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
SECRET_KEY = os.environ.get("SECRET_KEY", "change_me_in_production")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize" AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token" TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo" USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
@@ -26,16 +29,19 @@ ALLOWED_USERS = (
else [] else []
) )
origins = ["*"] origins = (
os.environ.get("ALLOWED_ORIGINS", "").split(",")
if os.environ.get("ALLOWED_ORIGINS")
else []
)
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
block_endpoints = ["/health"] block_endpoints = ["/health"]
@@ -70,9 +76,7 @@ class AuthHeaders(BaseModel):
# First check if it's a valid OIDC token # First check if it's a valid OIDC token
if self.validate_oidc_token(): if self.validate_oidc_token():
return True return True
# Fallback to the old static key for compatibility # No static key fallback; reject non-OIDC tokens
if token == "abc":
return True
return False return False
def validate_oidc_token(self) -> bool: def validate_oidc_token(self) -> bool:
@@ -81,39 +85,39 @@ class AuthHeaders(BaseModel):
if not token: if not token:
return False return False
# Basic validation - in production, you would validate the JWT signature # Try JWT validation first (for id_token-style tokens)
# and check issuer, audience, expiration, etc. if token.count(".") == 2:
import base64 import jwt
import json from jwt import PyJWKClient
# Decode JWT header and payload (without verification for simplicity in this example) jwks_client = PyJWKClient(JWKS_URI)
parts = token.split(".") signing_key = jwks_client.get_signing_key_from_jwt(token)
if len(parts) != 3: payload_data = jwt.decode(
# If not a standard JWT, treat as opaque token token,
# For now, we'll accept any non-empty token as valid for testing signing_key.key,
return len(token) > 0 algorithms=["RS256"],
audience=CLIENT_ID,
# Decode payload (part 1) issuer="https://login.infomaniak.com/",
payload = parts[1] )
if not payload:
return False
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded_payload = base64.urlsafe_b64decode(payload)
payload_data = json.loads(decoded_payload)
# Check if user is in allowed list
user_email = payload_data.get("email") user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS: if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False return False
return True return True
except Exception as e:
print(f"Token validation error: {e}") # Fallback: validate opaque tokens by calling userinfo endpoint
# Even if we can't validate the token, if it exists, we'll accept it for now userinfo_response = requests.get(
# This is for development/testing purposes only USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {token}"}
return len(self.token()) > 0 )
if userinfo_response.status_code != 200:
return False
userinfo = userinfo_response.json()
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False
return True
except Exception:
return False
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
@@ -136,6 +140,11 @@ def get_health() -> HealthCheck:
return HealthCheck(status="OK") return HealthCheck(status="OK")
@app.get("/style.css")
async def style_css():
return FileResponse("style.css")
@app.get("/favicon.ico") @app.get("/favicon.ico")
async def favico(): async def favico():
return FileResponse("favicon.ico") return FileResponse("favicon.ico")
@@ -146,11 +155,18 @@ async def login(request: Request):
"""Redirect to Infomaniak OIDC login""" """Redirect to Infomaniak OIDC login"""
import urllib.parse import urllib.parse
state = "random_state_string" # In production, use a proper random state import secrets
nonce = "random_nonce_string" # In production, use a proper random nonce
# Store state and nonce in session (simplified for this example) state = secrets.token_urlsafe(32)
# In production, use proper session management nonce = secrets.token_urlsafe(32)
# Generate PKCE parameters for public clients / Infomaniak requirement
code_verifier = secrets.token_urlsafe(32)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
.decode("ascii")
.rstrip("=")
)
auth_url = ( auth_url = (
f"{AUTHORIZATION_ENDPOINT}" f"{AUTHORIZATION_ENDPOINT}"
@@ -160,14 +176,41 @@ async def login(request: Request):
f"&scope=openid email profile" f"&scope=openid email profile"
f"&state={state}" f"&state={state}"
f"&nonce={nonce}" f"&nonce={nonce}"
f"&code_challenge={code_challenge}"
f"&code_challenge_method=S256"
) )
return RedirectResponse(auth_url) response = RedirectResponse(auth_url)
# Store state and nonce in secure http-only cookies for validation on callback
response.set_cookie(
"oidc_state", state, httponly=True, secure=True, samesite="Lax", max_age=300
)
response.set_cookie(
"oidc_nonce", nonce, httponly=True, secure=True, samesite="Lax", max_age=300
)
response.set_cookie(
"oidc_verifier",
code_verifier,
httponly=True,
secure=True,
samesite="Lax",
max_age=300,
)
return response
@app.get("/callback") @app.get("/callback")
async def callback(code: str, state: str): async def callback(request: Request, code: str, state: str):
"""Handle OIDC callback and exchange code for token""" """Handle OIDC callback and exchange code for token"""
expected_state = request.cookies.get("oidc_state")
code_verifier = request.cookies.get("oidc_verifier")
# We should also delete state/nonce cookies once read
if not expected_state or state != expected_state:
response = RedirectResponse("/?error=Invalid state parameter")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
# Exchange authorization code for access token # Exchange authorization code for access token
token_data = { token_data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
@@ -175,18 +218,24 @@ async def callback(code: str, state: str):
"client_secret": CLIENT_SECRET, "client_secret": CLIENT_SECRET,
"code": code, "code": code,
"redirect_uri": REDIRECT_URI, "redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
} }
response = requests.post(TOKEN_ENDPOINT, data=token_data) token_response = requests.post(TOKEN_ENDPOINT, data=token_data).json()
token_response = response.json()
if "access_token" not in token_response: if "access_token" not in token_response:
# Log the error for debugging
logging.error("Token exchange failed: %s", token_response)
# Redirect back to frontend with error # Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/") frontend_url = os.environ.get("FRONTEND_URL", "/")
error_detail = token_response.get( error_detail = token_response.get(
"error_description", "Failed to obtain access token" "error_description", "Failed to obtain access token"
) )
return RedirectResponse(f"{frontend_url}?error={error_detail}") error_response = RedirectResponse(f"{frontend_url}?error={error_detail}")
error_response.delete_cookie("oidc_state")
error_response.delete_cookie("oidc_nonce")
error_response.delete_cookie("oidc_verifier")
return error_response
access_token = token_response["access_token"] access_token = token_response["access_token"]
@@ -202,16 +251,32 @@ async def callback(code: str, state: str):
if ALLOWED_USERS and user_email not in ALLOWED_USERS: if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error # Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/") frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}?error=User not authorized") response = RedirectResponse(f"{frontend_url}?error=User not authorized")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
# Redirect back to frontend with access token and user info # Redirect back to frontend with access token and user info
frontend_url = os.environ.get("FRONTEND_URL", "/") frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}#access_token={access_token}") response = RedirectResponse(f"{frontend_url}#access_token={access_token}")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
@app.get("/exchange-token") @app.get("/exchange-token")
async def exchange_token(code: str): async def exchange_token(
request: Request,
code: str,
headers: Annotated[AuthHeaders, Header()],
):
"""Exchange authorization code for access token""" """Exchange authorization code for access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
code_verifier = request.cookies.get("oidc_verifier")
# Exchange authorization code for access token # Exchange authorization code for access token
token_data = { token_data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
@@ -219,6 +284,7 @@ async def exchange_token(code: str):
"client_secret": CLIENT_SECRET, "client_secret": CLIENT_SECRET,
"code": code, "code": code,
"redirect_uri": REDIRECT_URI, "redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
} }
response = requests.post(TOKEN_ENDPOINT, data=token_data) response = requests.post(TOKEN_ENDPOINT, data=token_data)
@@ -254,6 +320,9 @@ async def exchange_token(code: str):
@app.get("/userinfo") @app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]): async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token""" """Get user info from access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
access_token = headers.token() access_token = headers.token()
userinfo_response = requests.get( userinfo_response = requests.get(
@@ -283,15 +352,15 @@ async def schedule(
try: try:
if existing_token: if existing_token:
myice.userdata = { user_data = {
"id": userid, "id": userid,
"id_club": club_id or 186, "id_club": club_id or 186,
"token": existing_token, "token": existing_token,
} }
else: else:
myice.userdata = myice.mobile_login(config_section=account) user_data = myice.mobile_login(config_section=account)
data = myice.refresh_data() data = myice.refresh_data(user_data=user_data)
if "club_games" in data: if "club_games" in data:
return data["club_games"] return data["club_games"]
else: else:
@@ -357,17 +426,20 @@ async def game(
game_id: int, game_id: int,
account: str = "default", account: str = "default",
): ):
if not headers.authorized():
raise HTTPException(401, detail="get out")
username, password, userid, existing_token, club_id = myice.get_login( username, password, userid, existing_token, club_id = myice.get_login(
config_section=account config_section=account
) )
if existing_token: if existing_token:
myice.userdata = { user_data = {
"id": userid, "id": userid,
"id_club": club_id or 186, "id_club": club_id or 186,
"token": existing_token, "token": existing_token,
} }
else: else:
myice.userdata = myice.mobile_login(config_section=account) user_data = myice.mobile_login(config_section=account)
# data = refresh_data() # data = refresh_data()
with requests.post( with requests.post(
@@ -375,11 +447,11 @@ async def game(
headers=myice.mobile_headers, headers=myice.mobile_headers,
data="&".join( data="&".join(
[ [
f"token={myice.userdata['token']}", f"token={user_data['token']}",
f"id_event={game_id}", f"id_event={game_id}",
"type=games", "type=games",
f"id_player={myice.userdata['id']}", f"id_player={user_data['id']}",
f"id_club={myice.userdata['id_club']}", f"id_club={user_data['id_club']}",
"language=FR", "language=FR",
] ]
), ),
Generated
+803 -626
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "myice" name = "myice"
version = "v0.6.0" version = "v0.6.1"
description = "myice parsing" description = "myice parsing"
authors = [ authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"}, { name = "Rene Luria", "email" = "<rene@luria.ch>"},
@@ -15,6 +15,7 @@ dependencies = [
"pypdf (>=6.0.0)", "pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0", "rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11)", "fastapi[standard] (>=0.115.11)",
"pyjwt[crypto] (>=2.10.1)",
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
+20 -19
View File
@@ -1,46 +1,47 @@
--extra-index-url https://pypi.purple.infomaniak.ch --extra-index-url https://pypi.purple.infomaniak.ch
annotated-doc==0.0.4 ; python_version >= "3.13"
annotated-types==0.7.0 ; python_version >= "3.13" annotated-types==0.7.0 ; python_version >= "3.13"
anyio==4.11.0 ; python_version >= "3.13" anyio==4.11.0 ; python_version >= "3.13"
certifi==2025.8.3 ; python_version >= "3.13" certifi==2025.10.5 ; python_version >= "3.13"
charset-normalizer==3.4.3 ; python_version >= "3.13" charset-normalizer==3.4.4 ; python_version >= "3.13"
click==8.1.8 ; python_version >= "3.13" click==8.1.8 ; python_version >= "3.13"
colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and python_version >= "3.13" colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and python_version >= "3.13"
dnspython==2.8.0 ; python_version >= "3.13" dnspython==2.8.0 ; python_version >= "3.13"
email-validator==2.3.0 ; python_version >= "3.13" email-validator==2.3.0 ; python_version >= "3.13"
fastapi-cli==0.0.13 ; python_version >= "3.13" fastapi-cli==0.0.16 ; python_version >= "3.13"
fastapi-cloud-cli==0.2.1 ; python_version >= "3.13" fastapi-cloud-cli==0.3.1 ; python_version >= "3.13"
fastapi==0.118.0 ; python_version >= "3.13" fastapi==0.121.1 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13" h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13" httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.6.4 ; python_version >= "3.13" httptools==0.7.1 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13" httpx==0.28.1 ; python_version >= "3.13"
idna==3.10 ; python_version >= "3.13" idna==3.11 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13" jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.0.0 ; python_version >= "3.13" markdown-it-py==4.0.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13" markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13" mdurl==0.1.2 ; python_version >= "3.13"
pydantic-core==2.33.2 ; python_version >= "3.13" pydantic-core==2.41.5 ; python_version >= "3.13"
pydantic==2.11.9 ; python_version >= "3.13" pydantic==2.12.4 ; python_version >= "3.13"
pygments==2.19.2 ; python_version >= "3.13" pygments==2.19.2 ; python_version >= "3.13"
pypdf==6.1.1 ; python_version >= "3.13" pypdf==6.2.0 ; python_version >= "3.13"
python-dotenv==1.1.1 ; python_version >= "3.13" python-dotenv==1.2.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13" python-multipart==0.0.20 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13" pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.32.5 ; python_version >= "3.13" requests==2.32.5 ; python_version >= "3.13"
rich-toolkit==0.15.1 ; python_version >= "3.13" rich-toolkit==0.15.1 ; python_version >= "3.13"
rich==14.1.0 ; python_version >= "3.13" rich==14.2.0 ; python_version >= "3.13"
rignore==0.6.4 ; python_version >= "3.13" rignore==0.7.6 ; python_version >= "3.13"
rl-ai-tools==1.15.0 ; python_version >= "3.13" rl-ai-tools==1.15.0 ; python_version >= "3.13"
sentry-sdk==2.39.0 ; python_version >= "3.13" sentry-sdk==2.43.0 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13" shellingham==1.5.4 ; python_version >= "3.13"
sniffio==1.3.1 ; python_version >= "3.13" sniffio==1.3.1 ; python_version >= "3.13"
starlette==0.48.0 ; python_version >= "3.13" starlette==0.49.3 ; python_version >= "3.13"
typer==0.15.4 ; python_version >= "3.13" typer==0.15.4 ; python_version >= "3.13"
typing-extensions==4.15.0 ; python_version >= "3.13" typing-extensions==4.15.0 ; python_version >= "3.13"
typing-inspection==0.4.1 ; python_version >= "3.13" typing-inspection==0.4.2 ; python_version >= "3.13"
urllib3==2.5.0 ; python_version >= "3.13" urllib3==2.5.0 ; python_version >= "3.13"
uvicorn==0.37.0 ; python_version >= "3.13" uvicorn==0.38.0 ; python_version >= "3.13"
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13" uvloop==0.22.1 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13"
watchfiles==1.1.0 ; python_version >= "3.13" watchfiles==1.1.1 ; python_version >= "3.13"
websockets==15.0.1 ; python_version >= "3.13" websockets==15.0.1 ; python_version >= "3.13"
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html"],
theme: {
extend: {},
},
plugins: [],
}