5 Commits
Author SHA1 Message Date
herel 6a959a01e6 build: Update dependencies and pre-commit hooks
Bump mypy to 2.3.1 and refresh all pre-commit hooks (ruff 0.16,
markdownlint 0.18). Fix issues surfaced by the new tooling:

- Use lowercase samesite cookie values (mypy 2.x)
- Remove no-op global declarations and address new ruff findings;
  ignore rules that would require behavior changes
- Add missing comma in AI system prompt (strings were concatenated)
- Wrap long README lines for MD013
2026-09-16 15:23:57 +02:00
herel ff369143d2 build: Upgrade dependencies to latest versions
Bump minimum versions for requests, typer, pypdf, rl-ai-tools,
fastapi and pyjwt, and update dev dependencies (mypy,
types-requests). Remove unused ipykernel from dev group.

Regenerate poetry.lock, re-export requirements.txt and sync
uv.lock accordingly. requirements.txt now also includes pyjwt
and its crypto dependencies which were missing from the
previous export.
2026-09-16 13:10:24 +02:00
herel e77c034082 chore: update .gitignore 2026-06-29 23:11:51 +02:00
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
15 changed files with 2626 additions and 4252 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
docs/superpowers/specs/*.md
+8 -8
View File
@@ -15,7 +15,7 @@ repos:
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.9
rev: v0.16.7
hooks:
# Run the linter.
- id: ruff
@@ -24,26 +24,26 @@ repos:
- id: ruff-format
args: [--diff, --target-version, py312]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.1
rev: v2.3.1
hooks:
- id: mypy
exclude: ^(docs/|example-plugin/)
args: [--ignore-missing-imports]
additional_dependencies: [types-requests, pypdf]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.37.1
rev: v1.38.0
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/markdownlint/markdownlint
rev: v0.13.0
rev: v0.18.1
hooks:
- id: markdownlint
exclude: "^.github|_sidebar\\.md$|superpowers/(specs|plans)/.*\\.md$"
exclude: "^.github|(^docs/_sidebar\\.md$)"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
rev: v0.11.0.1-1
hooks:
- id: shellcheck
args: ["--severity=error"]
@@ -51,12 +51,12 @@ repos:
files: "\\.sh$"
- repo: https://github.com/golangci/misspell
rev: v0.7.0
rev: v0.8.0
hooks:
- id: misspell
args: ["-i", "charactor"]
- repo: https://github.com/python-poetry/poetry
rev: "2.1.4"
rev: "2.4.1"
hooks:
- id: poetry-check
- id: poetry-lock
+12 -6
View File
@@ -64,17 +64,20 @@ To fetch the `token` and `club_id` using mobile-login:
myice mobile-login
```
This will output the necessary information that you can add to your `myice.ini` file.
This will output the necessary information that you can add to your
`myice.ini` file.
## web interface
The tool includes a web interface that can be accessed by running the web API server:
The tool includes a web interface that can be accessed by running the web
API server:
```shell
uv run fastapi run myice/webapi.py
```
Then open your browser at `http://localhost:8000`. The web interface allows you to:
Then open your browser at `http://localhost:8000`. The web interface allows
you to:
- Select between different configured accounts
- View upcoming games and practices
@@ -88,16 +91,19 @@ The web interface supports two authentication methods:
Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only
users in the allowed list will be granted access.
1. **Static API Key**: For development purposes, you can still use `abc` as the token.
1. **Static API Key**: For development purposes, you can still use `abc` as
the token.
### Environment Variables
To configure OIDC authentication, set the following environment variables:
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_ID`: Your OIDC client ID
(default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_SECRET`: Your OIDC client secret
- `REDIRECT_URI`: The redirect URI (default: `http://localhost:8000/callback`)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., `"user1@example.com,user2@example.com"`)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses
(e.g., `"user1@example.com,user2@example.com"`)
The web API provides the following endpoints:
File diff suppressed because it is too large. Load diff
@@ -1,350 +0,0 @@
<!-- markdownlint-disable-file -->
# MyIce Flutter App - Design Specification
**Date:** 2026-07-06
**Context:** Application mobile Flutter (iOS/Android) reproduisant le fonctionnement de la webapp myice existante.
**Backend:** L'application se connecte a l'API FastAPI existante (myice/webapi.py) via HTTP.
## 1. Architecture globale
Approche : **Repository + Provider (B)**
```
lib/
├── main.dart # Entry point, MaterialApp, MultiProvider
├── config/
│ └── api_config.dart # URL base de l'API, timeouts
├── models/
│ ├── account.dart # {name, label}
│ ├── event.dart # Game/Practice du schedule
│ ├── event_detail.dart # Details + convocation
│ ├── user_info.dart # {email}
│ └── convocation.dart # Player, Staff, Convocation
├── services/
│ ├── api_service.dart # Dio + intercepteur Bearer
│ └── auth_service.dart # Login/logout OIDC, deep link
├── providers/
│ ├── auth_provider.dart # Etat auth + user info
│ ├── schedule_provider.dart # Liste events + filtres
│ └── event_provider.dart # Details d'un event (optionnel)
├── repositories/
│ └── myice_repository.dart # Appels API : accounts, schedule, game
├── screens/
│ ├── login_screen.dart
│ ├── schedule_screen.dart
│ └── event_detail_screen.dart
└── widgets/
├── event_card.dart
├── filter_dropdown.dart
└── loading_indicator.dart
```
**Pattern :** Repository encapsule les appels API -> Providers gerent l'etat reactif -> Screens ecoutent les Providers.
## 2. Dependances Flutter
```yaml
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
provider: ^6.1.0
shared_preferences: ^2.2.0
flutter_web_auth: ^0.6.0
intl: ^0.19.0 # Pour le parsing/formatage de dates si besoin
```
## 3. Modeles de donnees (JSON <-> Dart)
### Account
```dart
class Account {
final String name;
final String label;
Account({required this.name, required this.label});
factory Account.fromJson(Map<String, dynamic> json) => Account(
name: json['name'] as String,
label: json['label'] as String,
);
}
```
### Event
Provenant de `/schedule`.
```dart
class Event {
final String idEvent;
final String agegroup;
final String name;
final String title;
final String opponent;
final String place;
final String start;
final String end;
final String? color;
final String eventType; // mappe depuis le champ JSON 'event'
Event({
required this.idEvent,
required this.agegroup,
required this.name,
required this.title,
required this.opponent,
required this.place,
required this.start,
required this.end,
this.color,
required this.eventType,
});
factory Event.fromJson(Map<String, dynamic> json) => Event(
idEvent: json['id_event']?.toString() ?? '',
agegroup: json['agegroup'] as String,
name: json['name'] as String? ?? '',
title: json['title'] as String,
opponent: json['opponent'] as String? ?? '',
place: json['place'] as String? ?? '',
start: json['start'] as String,
end: json['end'] as String,
color: json['color'] as String?,
eventType: json['event'] as String? ?? '',
);
}
```
### Player
```dart
class Player {
final String? position;
final String? number;
final String fname;
final String lname;
final String dob;
Player({this.position, this.number, required this.fname, required this.lname, required this.dob});
factory Player.fromJson(Map<String, dynamic> json) => Player(
position: json['position'] as String?,
number: json['number']?.toString(),
fname: json['fname'] as String,
lname: json['lname'] as String,
dob: json['dob'] as String,
);
}
```
### Staff
```dart
class Staff {
final String role;
final String fname;
final String lname;
Staff({required this.role, required this.fname, required this.lname});
factory Staff.fromJson(Map<String, dynamic> json) => Staff(
role: json['role'] as String,
fname: json['fname'] as String,
lname: json['lname'] as String,
);
}
```
### Convocation
```dart
class Convocation {
final List<Player> available;
final List<Staff> staff;
Convocation({required this.available, required this.staff});
factory Convocation.fromJson(Map<String, dynamic> json) => Convocation(
available: (json['available'] as List? ?? [])
.map((e) => Player.fromJson(e as Map<String, dynamic>))
.toList(),
staff: (json['staff'] as List? ?? [])
.map((e) => Staff.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
```
### EventDetail
Provenant de `/game/{id}`.
```dart
class EventDetail {
final String title;
final String type;
final String place;
final String timeStart;
final String timeEnd;
final Convocation convocation;
EventDetail({
required this.title,
required this.type,
required this.place,
required this.timeStart,
required this.timeEnd,
required this.convocation,
});
factory EventDetail.fromJson(Map<String, dynamic> json) => EventDetail(
title: json['title'] as String,
type: json['type'] as String,
place: json['place'] as String? ?? '',
timeStart: json['time_start'] as String,
timeEnd: json['time_end'] as String,
convocation: Convocation.fromJson(json['convocation'] as Map<String, dynamic>),
);
}
```
### UserInfo
```dart
class UserInfo {
final String email;
UserInfo({required this.email});
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
email: json['email'] as String? ?? '',
);
}
```
## 4. Services
### ApiService
Utilise `dio`. Un intercepteur ajoute automatiquement le header `Authorization: Bearer <token>`.
Concept:
- `setBearerToken(token)` ajoute un `InterceptorsWrapper` a Dio.
- `clearBearerToken()` retire les intercepteurs existants.
- Methodes: `getAccounts()`, `getSchedule(account)`, `getGameDetail(gameId, account)`, `getUserInfo()`.
- Retournent des objets modeles via `fromJson`.
### AuthService
Utilise `flutter_web_auth` pour l'OIDC + `shared_preferences` pour stocker le token.
Concept:
- `storedToken`, `storedAccount` depuis `SharedPreferences`.
- `login()`: appelle `FlutterWebAuth.authenticate(url: baseUrl/login, callbackUrlScheme: 'myice')`. Parse le fragment `#access_token=xxx` de l'URL retournee. Stocke le token.
- `setAccount(account)`: persiste le compte selectionne.
- `logout()`: supprime token, account, userInfo des prefs.
**Configuration deep link requise:**
- Android: intent-filter dans `AndroidManifest.xml` pour scheme `myice://`.
- iOS: `CFBundleURLTypes` dans `Info.plist` avec scheme `myice`.
## 5. Providers (Provider package)
### AuthProvider
Gere l'etat global d'authentification.
**Etat:** `isAuthenticated`, `userInfo`.
**Methodes:**
- `init()`: Verifie token stocke, appelle `/userinfo`. Si 401, considere deconnecte.
- `login()`: Lance AuthService.login(), met a jour le token, appelle `/userinfo`, notifie listeners.
- `logout()`: Efface le token, notifie listeners.
### ScheduleProvider
Gere les events, les comptes, et les filtres.
**Etat:** `accounts`, `events`, `selectedAccount`, `selectedAgegroup`, `selectedSubgroup`, `isLoading`, `error`.
**Methodes:**
- `loadAccounts()`: Appelle `/accounts`, met a jour `accounts`. Selectionne `storedAccount` si present, sinon le premier.
- `loadSchedule()`: Met `isLoading = true`, appelle `/schedule?account=selectedAccount`, filtre events ou `eventType == "Jeu"`, extrait les `agegroup` uniques pour les filtres.
- `setAccount(String)`: Met a jour `selectedAccount`, sauvegarde dans SharedPreferences, appelle `loadSchedule()`.
- `setAgegroup(String)`: Met a jour, reset `selectedSubgroup`, notifie.
- `setSubgroup(String)`: Met a jour, notifie.
- `get filteredEvents`: Getter filtre `events` par `agegroup` et `subgroup`.
**Logique de filtre sous-groupe:**
Identique a la webapp. La fonction `extractSubgroup(name)` retire la partie apres `" - "` dans le `name` de l'event pour obtenir le sous-groupe (si applicable).
### EventDetailProvider (Optionnel)
Gere le chargement des details d'un event. Peut etre integre dans `ScheduleProvider` si prefere pour garder un seul provider.
**Etat:** `eventDetail`, `isLoading`, `error`.
**Methodes:** `loadEventDetail(gameId)` appelle `/game/{gameId}`.
## 6. Ecrans
### LoginScreen
- **AppBar:** Aucune (centre).
- **Body:** Column centree.
- Titre: "MyIce - Games" (headlineLarge, couleur indigo).
- Bouton: "Se connecter avec Infomaniak" (style degrade indigo -> violet).
- Texte d'aide: "Connectez-vous pour acceder aux evenements...".
- **Comportement:**
- Si `authProvider.isAuthenticated`, navigue automatiquement vers `ScheduleScreen`.
- Affiche `CircularProgressIndicator` pendant le login.
- SnackBar en cas d'erreur.
### ScheduleScreen
- **AppBar:** Titre "MyIce", action logout + email utilisateur.
- **Body:**
- **Filtres:** Card avec 3 `DropdownButtonFormField` (Compte, Age, Sous-groupe). Le bouton "Charger" est optionnel (changement de compte recharge auto).
- **Liste:** `Expanded` + `ListView.builder`. Affiche `EventCard` pour chaque event filtre.
- Etats: Loading (`CircularProgressIndicator`), vide (texte info), erreur (SnackBar).
### EventDetailScreen
- **AppBar:** Titre = `event.title`, bouton retour.
- **Body:** `SingleChildScrollView` + `Padding(16)`.
- Card recap: Type, Lieu, Heure.
- Section Joueurs:
- Si vide: Card amber "Aucun joueur convoque".
- Sinon: Card avec total + repartition positions. Liste triee par position puis numero: `[Position] #Num - Prenom Nom (DOB)`.
- Section Personnel:
- Si vide: Card bleu "Aucun personnel convoque".
- Sinon: Liste `role: Prenom Nom`.
- Si aucun joueur ET aucun personnel: Card amber principal "Aucun joueur ni personnel convoque" uniquement.
### Widgets reutilisables
- **EventCard:** Card avec bordure laterale coloree. Titre (`agegroup - name`), sous-titre, details (adversaire, lieu, heure). onTap -> `EventDetailScreen`.
- **FilterDropdown:** `DropdownButtonFormField` generique stylise.
## 7. Navigation & Routing
Utilisation de `Navigator` classique (pas de package tierce pour 3 ecrans).
- `main.dart`: `MaterialApp` avec `home` determine par `Consumer<AuthProvider>` (`ScheduleScreen` ou `LoginScreen`).
- Navigation details: `Navigator.push(MaterialPageRoute(builder: (_) => EventDetailScreen(...)))`.
## 8. Gestion des erreurs
- Erreur reseau / API: SnackBar rouge avec message.
- 401 (Token expire): Deconnexion automatique + redirection `LoginScreen`.
- JSON malforme: La webapp gere cote backend. Cote Flutter, `jsonDecode` echoue -> message "Donnees invalides".
- Aucun event/joueur: Interface vide avec message informatif (pas d'erreur).
## 9. Configuration requise
### Android
Ajouter un intent-filter dans `android/app/src/main/AndroidManifest.xml` pour le deep link `myice://callback`.
### iOS
Ajouter `CFBundleURLTypes` dans `ios/Runner/Info.plist` avec scheme `myice`.
### Configuration API
`lib/config/api_config.dart`:
```dart
class ApiConfig {
static const String baseUrl = 'https://your-api-domain.com'; // A configurer
}
```
## 10. Plan d'implementation (High-level)
1. Scaffolding du projet Flutter (`flutter create`).
2. Ajout des dependances (`pubspec.yaml`).
3. Configuration des deep links (`AndroidManifest` + `Info.plist`).
4. Implementation des modeles de donnees (`fromJson`).
5. Implementation des services (`ApiService`, `AuthService`).
6. Implementation des providers (`AuthProvider`, `ScheduleProvider`).
7. Implementation des ecrans (`LoginScreen`, `ScheduleScreen`, `EventDetailScreen`).
8. Implementation des widgets (`EventCard`, etc.).
9. Wiring dans `main.dart` avec `MultiProvider`.
10. Tests manuels de l'auth OIDC et de la navigation.
---
**Prochaine etape apres validation du spec:** Creer le plan d'implementation detaille avec le skill `writing-plans`.
+68 -25
View File
@@ -13,7 +13,7 @@ import tempfile
from enum import Enum
from pathlib import Path
from typing import Annotated
from typing import List, Tuple
import pypdf
import requests
import typer
@@ -112,8 +112,8 @@ def sanitize_json_response(text):
app = typer.Typer(no_args_is_help=True)
session: requests.Session
userid: int
session = requests.Session()
userid: int = 0
global_config_section: str = "default"
@@ -245,7 +245,6 @@ def get_login(
def select_club(club_id: int = 172):
"""Select a club by ID after login."""
global session
r = session.get(
f"https://app.myice.hockey/?cl={club_id}", headers={"User-Agent": user_agent}
)
@@ -253,14 +252,12 @@ def select_club(club_id: int = 172):
def do_login(config_section: str | None = None):
global session
global userid
global global_config_section
# Use provided config_section, or fall back to global one
section = config_section if config_section is not None else global_config_section
username, password, userid_tmp, token, club_id = get_login(config_section=section)
username, password, userid_tmp, _token, club_id = get_login(config_section=section)
if userid_tmp is not None:
userid = userid_tmp
r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent})
@@ -287,7 +284,6 @@ def do_login(config_section: str | None = None):
def get_userid():
global session
r = session.get(
"https://app.myice.hockey/players/clubschedule/",
headers={
@@ -306,7 +302,7 @@ def get_userid():
def wrapper_session(func):
def wrapper(*args, **kwargs):
global session, userid, global_config_section
global session, userid
# Use the global config_section
config_section = global_config_section
@@ -330,8 +326,6 @@ def wrapper_session(func):
@wrapper_session
def get_schedule(num_days: int) -> str:
global session
global userid
assert session and userid
now = datetime.datetime.now()
date_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -361,7 +355,6 @@ def get_schedule(num_days: int) -> str:
@wrapper_session
def game_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatgamepdfgenerator.php?id={gameid}",
@@ -378,7 +371,6 @@ def game_pdf(gameid: int, outfile: Path):
@wrapper_session
def practice_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatpracticepdf.php?practice={gameid}",
@@ -406,7 +398,6 @@ def schedule(
"""
Fetch schedule as json
"""
global global_config_section
schedule = get_schedule(num_days)
if outfile:
with outfile.open("w") as f:
@@ -427,7 +418,7 @@ def os_open(file: str) -> None:
subprocess.run(["open", file])
def extract_players(pdf_file: Path) -> List[str]:
def extract_players(pdf_file: Path) -> list[str]:
reader = pypdf.PdfReader(pdf_file)
page = reader.pages[0]
@@ -454,7 +445,6 @@ def get_game_pdf(
"""
Genate the pdf for the game invitation
"""
global global_config_section
if open_file:
output_filename = f"game_{game_id}.pdf"
else:
@@ -475,7 +465,6 @@ def get_practice_pdf(
"""
Genate the pdf for the practice invitation
"""
global global_config_section
output_filename = f"practice_{game_id}.pdf"
practice_pdf(game_id, Path(output_filename))
os_open(output_filename)
@@ -495,7 +484,6 @@ def parse_schedule(
"""
Parse schedule.json to look for specific games or practices
"""
global global_config_section
try:
with schedule_file.open("r") as f:
data = json.load(f)
@@ -545,7 +533,6 @@ def check_with_ai(
"""
Search through the schedule with natural language using Infomaniak LLM API
"""
global global_config_section
if not utils.init():
sys.exit(1)
with schedule_file.open("r") as f:
@@ -560,7 +547,7 @@ def check_with_ai(
system = "\n".join(
[
"Tu es une IA connaissant bien les données suivantes, qui décrivent les match et entraînements d'équipes de hockey sur glace.",
f"aujourd'hui, nous sommes le {when}"
f"aujourd'hui, nous sommes le {when}",
"attention: ce qu'il y a entre parenthèse après la catégorie est une catégorie à part entière, example, u13 a = U13 (A) et ça correspond aux agegroup",
"assure-toi de ne pas confondre les catégories d'age dans tes réponses. ne donne pas une réponse pour la mauvaise équipe",
"ne confond pas top, elite, a, prép",
@@ -569,7 +556,7 @@ def check_with_ai(
"```",
],
)
history: List[Tuple[str, str]] = []
history: list[tuple[str, str]] = []
while True:
try:
question = input("> ")
@@ -607,7 +594,6 @@ mobile_headers = {
def mobile_login(config_section: str | None = None):
global global_config_section
import base64
# Use provided config_section, or fall back to global one
@@ -686,14 +672,14 @@ def refresh_data(user_data=None):
@app.command("mobile-login")
def do_mobile_login():
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
print(json.dumps(userdata, indent=2))
@app.command("mobile")
def mobile():
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
games = [x for x in refresh_data().get("club_games")]
# Use built-in print to avoid rich formatting issues
@@ -702,12 +688,22 @@ def mobile():
builtins.print(json.dumps(games, indent=2, ensure_ascii=False))
@app.command("mobile-all")
def mobile_all():
global userdata
userdata = mobile_login(config_section=global_config_section)
# Use built-in print to avoid rich formatting issues
import builtins
builtins.print(json.dumps(refresh_data(), indent=2, ensure_ascii=False))
@app.command("mobile-game")
def mobile_game(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
with requests.post(
@@ -725,6 +721,7 @@ def mobile_game(
),
# verify=False,
) as r:
try:
data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"]
@@ -744,6 +741,52 @@ def mobile_game(
print(
f"[{player['position']}] {player['fname']} {player['lname']} ({player['dob']})"
)
except KeyError:
print(sanitize_json_response(r.text))
@app.command("mobile-practice")
def mobile_practice(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata
userdata = mobile_login(config_section=global_config_section)
with requests.post(
"https://app.myice.hockey/api/mobilerest/getevent",
headers=mobile_headers,
data="&".join(
[
f"token={userdata['token']}",
f"id_event={game_id}",
"type=practices",
f"id_player={userdata['id']}",
f"id_club={userdata['id_club']}",
"language=FR",
]
),
# verify=False,
) as r:
data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"]
if raw:
print(data)
print(
f"Game {game_id}: {data['title']} ({data['type']}, {data.get('is_away')}) at {data['time_start']}"
)
print(f"{len(players)} players")
for player in sorted(
players,
key=lambda x: (
x["position"] if x["position"] else "none",
x["dob"],
),
):
print(
f"[{player['position']}] {player['fname']} {player['lname']} ({player['dob']})"
)
if __name__ == "__main__":
+30 -46
View File
@@ -2,12 +2,14 @@ import base64
import hashlib
import logging
import os
from typing import Annotated
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, Optional
from . import myice
# OIDC Configuration
@@ -34,6 +36,7 @@ origins = (
if os.environ.get("ALLOWED_ORIGINS")
else []
)
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
@@ -49,10 +52,9 @@ block_endpoints = ["/health"]
class LogFilter(logging.Filter):
def filter(self, record):
if record.args and len(record.args) >= 3:
if record.args[2] in block_endpoints:
return False
return True
return not (
record.args and len(record.args) >= 3 and record.args[2] in block_endpoints
)
uvicorn_logger = logging.getLogger("uvicorn.access")
@@ -73,11 +75,8 @@ class AuthHeaders(BaseModel):
if not token:
return False
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# No static key fallback; reject non-OIDC tokens
return False
# No static key fallback; only valid OIDC tokens are accepted
return self.validate_oidc_token()
def validate_oidc_token(self) -> bool:
try:
@@ -100,9 +99,9 @@ class AuthHeaders(BaseModel):
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
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
# Fallback: validate opaque tokens by calling userinfo endpoint
userinfo_response = requests.get(
@@ -113,9 +112,9 @@ class AuthHeaders(BaseModel):
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
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
except Exception:
return False
@@ -151,7 +150,7 @@ async def favico():
@app.get("/login")
async def login(request: Request, redirect_uri: Optional[str] = None):
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import secrets
import urllib.parse
@@ -182,27 +181,17 @@ async def login(request: Request, redirect_uri: Optional[str] = None):
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
"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
"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,
)
# Store target redirect URI (e.g. myice://callback) for mobile app flow
if redirect_uri:
response.set_cookie(
"oidc_target_redirect",
redirect_uri,
httponly=True,
secure=True,
samesite="Lax",
samesite="lax",
max_age=300,
)
return response
@@ -213,17 +202,12 @@ 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")
# Determine final redirect target (mobile app or web frontend)
target_redirect = request.cookies.get("oidc_target_redirect") or os.environ.get(
"FRONTEND_URL", "/"
)
# We should also delete state/nonce cookies once read
if not expected_state or state != expected_state:
response = RedirectResponse(f"/{target_redirect}?error=Invalid state parameter")
response = RedirectResponse("/?error=Invalid state parameter")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
# Exchange authorization code for access token
token_data = {
@@ -239,16 +223,16 @@ async def callback(request: Request, code: str, state: str):
if "access_token" not in token_response:
# Log the error for debugging
logging.error("Token exchange failed: %s", token_response)
logger.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"
)
error_response = RedirectResponse(f"{target_redirect}?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")
error_response.delete_cookie("oidc_target_redirect")
return error_response
access_token = token_response["access_token"]
@@ -264,19 +248,19 @@ async def callback(request: Request, code: str, state: str):
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error
response = RedirectResponse(f"{target_redirect}?error=User not authorized")
frontend_url = os.environ.get("FRONTEND_URL", "/")
response = RedirectResponse(f"{frontend_url}?error=User not authorized")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
# Redirect back to frontend with access token and user info
response = RedirectResponse(f"{target_redirect}#access_token={access_token}")
frontend_url = os.environ.get("FRONTEND_URL", "/")
response = RedirectResponse(f"{frontend_url}#access_token={access_token}")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
@@ -355,13 +339,13 @@ async def schedule(
raise HTTPException(401, detail="get out")
try:
username, password, userid, existing_token, club_id = myice.get_login(
_username, _password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
except Exception as e:
raise HTTPException(
400,
detail=f"Configuration error: {str(e)}. Available accounts: isaac, leonard",
detail=f"Configuration error: {e!s}. Available accounts: isaac, leonard",
)
try:
@@ -443,7 +427,7 @@ async def game(
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
)
if existing_token:
-54
View File
@@ -1,54 +0,0 @@
# Flutter/Dart specific
.dart_tool/
.packages
build/
.pub/
# IDEs
.idea/
*.iml
*.ipr
*.iws
*.ctxt
# macOS
.DS_Store
# Android
*.apk
*.ap_
*.aab
!/android/app/src/main/AndroidManifest.xml
# iOS
ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework
ios/Flutter/Flutter.podspec
# Xcode
xcuserdata/
*.xcworkspace/
DerivedData/
# Web
lib/generated_plugin_registrant.dart
# Windows
windows/flutter/
windows/runner/
# Linux
linux/flutter/
linux/runner/
# Firebase
firebase_app_id_file.json
# Coverage
coverage/
# Temporary files
*.tmp
*.swp
*~
@@ -1,33 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="myice_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myice" android:host="callback" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
-58
View File
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MyIce</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>myice_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ch.parano.myice</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myice</string>
</array>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
-25
View File
@@ -1,25 +0,0 @@
---
name: myice_mobile
description: Flutter mobile app for MyIce
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.1.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
provider: ^6.1.0
shared_preferences: ^2.2.0
flutter_web_auth: ^0.6.0
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
Generated
+1638 -1594
View File
File diff suppressed because it is too large. Load diff
+17 -9
View File
@@ -10,21 +10,20 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests (>=2.32.3)",
"typer (>=0.15.1)",
"pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11)",
"pyjwt[crypto] (>=2.10.1)",
"requests (>=2.34.2)",
"typer (>=0.27.2)",
"pypdf (>=6.19.0)",
"rl-ai-tools >=1.17.3",
"fastapi[standard] (>=0.141.1)",
"pyjwt[crypto] (>=2.14.0)",
]
[tool.poetry.dependencies]
rl-ai-tools = { source = "infomaniak" }
[tool.poetry.group.dev.dependencies]
ipykernel = "^6.29.5"
types-requests = "^2.32.0.20241016"
mypy = "^1.15.0"
types-requests = "^2.33.0.20260906"
mypy = "^2.3.1"
[[tool.poetry.source]]
@@ -32,6 +31,15 @@ name = "infomaniak"
url = "https://pypi.purple.infomaniak.ch"
priority = "supplemental"
[tool.ruff.lint]
ignore = [
"ASYNC210",
"BLE001",
"DTZ005",
"PLW1510",
"SIM115",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+43 -35
View File
@@ -1,47 +1,55 @@
--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.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"
agent-detector==2.0.0 ; python_version >= "3.13"
annotated-doc==0.0.5 ; python_version >= "3.13"
annotated-types==0.8.0 ; python_version >= "3.13"
anyio==4.15.1 ; python_version >= "3.13"
certifi==2026.7.22 ; python_version >= "3.13"
cffi==2.1.1 ; python_version >= "3.13" and platform_python_implementation != "PyPy"
charset-normalizer==3.5.1 ; python_version >= "3.13"
click==8.5.0 ; python_version >= "3.13"
colorama==0.4.6 ; python_version >= "3.13" and platform_system == "Windows"
cryptography==50.0.1 ; python_version >= "3.13"
detect-installer==0.2.1 ; 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.16 ; python_version >= "3.13"
fastapi-cloud-cli==0.3.1 ; python_version >= "3.13"
fastapi==0.121.1 ; python_version >= "3.13"
fastapi-cli==0.0.32 ; python_version >= "3.13"
fastapi-cloud-cli==0.26.0 ; python_version >= "3.13"
fastapi==0.141.1 ; python_version >= "3.13"
fastar==0.12.0 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.7.1 ; python_version >= "3.13"
httptools==0.8.0 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13"
idna==3.11 ; python_version >= "3.13"
idna==3.19 ; 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.2.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; 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.2.0 ; python_version >= "3.13"
python-dotenv==1.2.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13"
pycparser==3.0 ; implementation_name != "PyPy" and platform_python_implementation != "PyPy" and python_version >= "3.13"
pydantic-core==2.46.5 ; python_version >= "3.13"
pydantic-extra-types==2.11.1 ; python_version >= "3.13"
pydantic-settings==2.15.0 ; python_version >= "3.13"
pydantic==2.13.5 ; python_version >= "3.13"
pygments==2.21.0 ; python_version >= "3.13"
pyjwt==2.14.0 ; python_version >= "3.13"
pypdf==6.19.0 ; python_version >= "3.13"
python-dotenv==1.2.3 ; python_version >= "3.13"
python-multipart==0.0.32 ; 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.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.43.0 ; python_version >= "3.13"
requests==2.34.2 ; python_version >= "3.13"
rich-toolkit==0.20.5 ; python_version >= "3.13"
rich==15.0.0 ; python_version >= "3.13"
rignore==0.8.1 ; python_version >= "3.13"
rl-ai-tools==1.17.3 ; python_version >= "3.13"
sentry-sdk==2.69.2 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13"
sniffio==1.3.1 ; 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.2 ; python_version >= "3.13"
urllib3==2.5.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"
starlette==1.6.0 ; python_version >= "3.13"
typer==0.27.2 ; python_version >= "3.13"
typing-extensions==4.16.0 ; python_version >= "3.13"
typing-inspection==0.4.4 ; python_version >= "3.13"
urllib3==2.8.0 ; python_version >= "3.13"
uvicorn==0.53.0 ; python_version >= "3.13"
uvloop==0.22.1 ; platform_python_implementation != "PyPy" and sys_platform != "win32" and sys_platform != "cygwin" and python_version >= "3.13"
watchfiles==1.2.0 ; python_version >= "3.13"
websockets==17.1 ; python_version >= "3.13"
Generated
+810 -282
View File
File diff suppressed because it is too large. Load diff