17 Commits

Author SHA1 Message Date
herel afcd230c3a 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 4593f9e8fb fix: use tailwindcss cli 2026-06-29 18:02:10 +02:00
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
13 changed files with 1126 additions and 724 deletions
+5 -1
View File
@@ -3,7 +3,11 @@ myice.ini
schedule.json
.envrc
cookies.txt
# Byte-compiled / optimized / DLL files
# Tailwind CSS generated artifacts
style.css
tailwindcss
# Python bytecode
__pycache__/
*.py[cod]
*$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
```
### 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
```bash
+36 -14
View File
@@ -4,34 +4,56 @@ FROM python:3.13-slim AS builder
# Create working directory
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 requirements.txt ./
# Install dependencies to a target directory
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
FROM python:3.13-slim
# Build Tailwind CSS
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
COPY --from=builder /app/site-packages /app/site-packages
# Runtime stage
FROM python:3.13-slim AS runtime
# 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
# Create working directory
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
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
# Bytecompile Python files for faster first load
RUN python -m compileall -q ./myice
# Expose port
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"
+16 -5
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
@@ -475,6 +475,17 @@
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) {
// Reset subgroup options
subgroupSelect.innerHTML = '<option value="">Tous</option>';
@@ -493,10 +504,9 @@
events
.filter(event => event.agegroup === selectedAgegroup)
.forEach(event => {
// Extract subgroup from event.name or event.title
// This assumes the subgroup is part of the name field
// Extract subgroup from event.name by removing opponent
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;
// Filter by subgroup
if (selectedSubgroup !== "" && event.name !== selectedSubgroup) return false;
let eventSubgroup = extractSubgroup(event.name);
if (selectedSubgroup !== "" && eventSubgroup !== selectedSubgroup) return false;
return true;
});
+36 -7
View File
@@ -140,14 +140,14 @@ class AgeGroup(str, Enum):
u13a = "U13 (A)"
u13p = "U13 (Prép)"
u14ev = "U14 (Elite Vernets)"
u14sae = "U14 (Groupe SAE)"
u14esmv = "U14 (Elite Sous-Moulin/Vergers)"
u15t = "U15 (Top)"
u15a = "U15 (A)"
u16e = "U16 (Elite)"
u16e = "U16 (Elit)"
u16t = "U16 (Top GS Ass)"
u18e = "U18 (Elite)"
u18el = "U18 (Elit)"
u21e = "U21 (ELIT)"
u18e = "U18 (Elit)"
u21e = "U21 (Elit)"
u14t = "U14 (Top)"
u14t1 = "U14 (Top1)"
u14t2 = "U14 (Top2)"
@@ -334,7 +334,7 @@ def get_schedule(num_days: int) -> str:
global userid
assert session and userid
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)
r = session.post(
"https://app.myice.hockey/inc/processclubplanning.php",
@@ -418,11 +418,13 @@ def schedule(
def os_open(file: str) -> None:
import subprocess
if os.uname().sysname == "Linux":
os.system(f"xdg-open {file}")
subprocess.run(["xdg-open", file])
else:
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]:
@@ -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):
global global_config_section
import base64
@@ -604,10 +618,13 @@ def mobile_login(config_section: str | None = None):
return {"id": 0, "token": token, "id_club": club_id}
print("Requesting token", file=sys.stderr)
# Print curl equivalent for debugging
print(curl_for_mobile_login(username, password), file=sys.stderr)
with requests.post(
"https://app.myice.hockey/api/mobilerest/login",
headers=mobile_headers,
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,
) as r:
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():
# Print curl equivalent for debugging
print(
curl_for_refresh_data(userdata["token"], userdata["id_club"]), file=sys.stderr
)
with requests.post(
"https://app.myice.hockey/api/mobilerest/refreshdata",
headers=mobile_headers,
+123 -51
View File
@@ -1,19 +1,22 @@
import base64
import hashlib
import logging
import requests
import os
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, status, Request
import requests
from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from typing import Annotated
from . import myice
# OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get(
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ"
)
CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
if not CLIENT_SECRET:
raise RuntimeError("CLIENT_SECRET environment variable must be set")
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"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
@@ -26,16 +29,19 @@ ALLOWED_USERS = (
else []
)
origins = ["*"]
origins = (
os.environ.get("ALLOWED_ORIGINS", "").split(",")
if os.environ.get("ALLOWED_ORIGINS")
else []
)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
block_endpoints = ["/health"]
@@ -70,9 +76,7 @@ class AuthHeaders(BaseModel):
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# Fallback to the old static key for compatibility
if token == "abc":
return True
# No static key fallback; reject non-OIDC tokens
return False
def validate_oidc_token(self) -> bool:
@@ -81,39 +85,39 @@ class AuthHeaders(BaseModel):
if not token:
return False
# Basic validation - in production, you would validate the JWT signature
# and check issuer, audience, expiration, etc.
import base64
import json
# Try JWT validation first (for id_token-style tokens)
if token.count(".") == 2:
import jwt
from jwt import PyJWKClient
# Decode JWT header and payload (without verification for simplicity in this example)
parts = token.split(".")
if len(parts) != 3:
# If not a standard JWT, treat as opaque token
# For now, we'll accept any non-empty token as valid for testing
return len(token) > 0
jwks_client = PyJWKClient(JWKS_URI)
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload_data = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer="https://login.infomaniak.com/",
)
user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False
return True
# Decode payload (part 1)
payload = parts[1]
if not payload:
# Fallback: validate opaque tokens by calling userinfo endpoint
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {token}"}
)
if userinfo_response.status_code != 200:
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")
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 as e:
print(f"Token validation error: {e}")
# Even if we can't validate the token, if it exists, we'll accept it for now
# This is for development/testing purposes only
return len(self.token()) > 0
except Exception:
return False
class HealthCheck(BaseModel):
@@ -136,6 +140,11 @@ def get_health() -> HealthCheck:
return HealthCheck(status="OK")
@app.get("/style.css")
async def style_css():
return FileResponse("style.css")
@app.get("/favicon.ico")
async def favico():
return FileResponse("favicon.ico")
@@ -146,11 +155,18 @@ async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import urllib.parse
state = "random_state_string" # In production, use a proper random state
nonce = "random_nonce_string" # In production, use a proper random nonce
import secrets
# Store state and nonce in session (simplified for this example)
# In production, use proper session management
state = secrets.token_urlsafe(32)
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 = (
f"{AUTHORIZATION_ENDPOINT}"
@@ -160,14 +176,41 @@ async def login(request: Request):
f"&scope=openid email profile"
f"&state={state}"
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")
async def callback(code: str, state: str):
async def callback(request: Request, code: str, state: str):
"""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
token_data = {
"grant_type": "authorization_code",
@@ -175,18 +218,24 @@ async def callback(code: str, state: str):
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
token_response = requests.post(TOKEN_ENDPOINT, data=token_data).json()
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
frontend_url = os.environ.get("FRONTEND_URL", "/")
error_detail = token_response.get(
"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"]
@@ -202,16 +251,32 @@ async def callback(code: str, state: str):
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error
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
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")
async def exchange_token(code: str):
async def exchange_token(
request: Request,
code: str,
headers: Annotated[AuthHeaders, Header()],
):
"""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
token_data = {
"grant_type": "authorization_code",
@@ -219,6 +284,7 @@ async def exchange_token(code: str):
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
@@ -254,6 +320,9 @@ async def exchange_token(code: str):
@app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
access_token = headers.token()
userinfo_response = requests.get(
@@ -357,6 +426,9 @@ async def game(
game_id: int,
account: str = "default",
):
if not headers.authorized():
raise HTTPException(401, detail="get out")
username, password, userid, existing_token, club_id = myice.get_login(
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]
name = "myice"
version = "v0.6.0"
version = "v0.6.1"
description = "myice parsing"
authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"},
@@ -15,6 +15,7 @@ dependencies = [
"pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11)",
"pyjwt[crypto] (>=2.10.1)",
]
[tool.poetry.dependencies]
+20 -19
View File
@@ -1,46 +1,47 @@
--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"
anyio==4.11.0 ; python_version >= "3.13"
certifi==2025.8.3 ; python_version >= "3.13"
charset-normalizer==3.4.3 ; python_version >= "3.13"
certifi==2025.10.5 ; python_version >= "3.13"
charset-normalizer==3.4.4 ; 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"
dnspython==2.8.0 ; python_version >= "3.13"
email-validator==2.3.0 ; python_version >= "3.13"
fastapi-cli==0.0.13 ; python_version >= "3.13"
fastapi-cloud-cli==0.2.1 ; python_version >= "3.13"
fastapi==0.118.0 ; python_version >= "3.13"
fastapi-cli==0.0.16 ; python_version >= "3.13"
fastapi-cloud-cli==0.3.1 ; python_version >= "3.13"
fastapi==0.121.1 ; python_version >= "3.13"
h11==0.16.0 ; 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"
idna==3.10 ; python_version >= "3.13"
idna==3.11 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.0.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13"
pydantic-core==2.33.2 ; python_version >= "3.13"
pydantic==2.11.9 ; python_version >= "3.13"
pydantic-core==2.41.5 ; python_version >= "3.13"
pydantic==2.12.4 ; python_version >= "3.13"
pygments==2.19.2 ; python_version >= "3.13"
pypdf==6.1.1 ; python_version >= "3.13"
python-dotenv==1.1.1 ; python_version >= "3.13"
pypdf==6.2.0 ; python_version >= "3.13"
python-dotenv==1.2.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.32.5 ; python_version >= "3.13"
rich-toolkit==0.15.1 ; python_version >= "3.13"
rich==14.1.0 ; python_version >= "3.13"
rignore==0.6.4 ; python_version >= "3.13"
rich==14.2.0 ; python_version >= "3.13"
rignore==0.7.6 ; 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"
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"
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"
uvicorn==0.37.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"
watchfiles==1.1.0 ; python_version >= "3.13"
uvicorn==0.38.0 ; 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.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: [],
}