15 Commits

Author SHA1 Message Date
herel 40c3131b90 fix: better subgroup 2026-06-29 17:35:48 +02:00
herel c3098cb415 fix: proper jwt verification 2026-06-29 17:35:36 +02:00
herel 8f9aec7631 fix: IDC State/Nonce Not Validated 2026-06-29 16:53:14 +02:00
herel bf7f7273e9 fix: add timeout for request 2026-06-29 16:52:35 +02:00
herel 071324d199 fix: Weak JWT Validation 2026-06-29 16:48:02 +02:00
herel 51ee0b6b32 fix: No static key fallback; reject non-OIDC tokens 2026-06-29 16:43:09 +02:00
herel 9c99787b1f fix: remove hard coded old secret 2026-06-29 16:42:51 +02:00
herel 8bb89eabdc fix: Permissive CORS Configuration 2026-06-29 16:40:36 +02:00
herel eca5eacd70 fix: Missing Authentication Checks 2026-06-29 16:37:39 +02:00
herel 5e17fd27d6 fix(security): Authentication Bypass via Broken JWT Validation in AuthHeaders.validate_oidc_token 2026-06-29 16:33:21 +02:00
herel 9dcf4f6fd3 chore: add some debug 2026-06-29 16:30:35 +02:00
herel ac8e252a2b chore: update categories 2026-06-29 16:28:15 +02:00
herel 1863f5586b fix: prevent OS Command Injection 2026-06-29 16:19:33 +02:00
herel c83bb2af9b 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 4c90716355 chore: update Dockerfile 2025-11-11 11:06:40 +01:00
8 changed files with 1022 additions and 722 deletions
+24 -14
View File
@@ -10,28 +10,38 @@ 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 # Runtime stage
FROM python:3.13-slim FROM python:3.13-slim AS runtime
# Copy installed packages from builder stage # Create working directory
COPY --from=builder /app/site-packages /app/site-packages
# Copy application code
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
COPY index.html favicon.ico ./
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
+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"
+15 -4
View File
@@ -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;
}); });
+36 -7
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,7 +643,19 @@ userdata = {
} }
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(): def refresh_data():
# Print curl equivalent for debugging
print(
curl_for_refresh_data(userdata["token"], userdata["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,
+106 -53
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):
@@ -146,11 +150,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 +171,30 @@ 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, max_age=300)
response.set_cookie("oidc_nonce", nonce, httponly=True, max_age=300)
response.set_cookie("oidc_verifier", code_verifier, httponly=True, 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 +202,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 +235,30 @@ 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(
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")
# Exchange authorization code for access token # Exchange authorization code for access token
token_data = { token_data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
@@ -254,6 +301,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(
@@ -357,6 +407,9 @@ 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
) )
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"