Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 3bb8e87ef2 fix: A42 terminé — client httpx partagé par boucle (v7.28.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `app/services/http_client.py` : `async with shared_client(timeout=15)
  as client:` remplace les 49 créations `async with httpx.AsyncClient(`
  de 14 fichiers (gitea ×21, providers oidc/oauth ×11, calendar ×4,
  automations ×3…) — le pool de connexions est réutilisé au lieu d'être
  recréé à chaque appel. __aexit__ no-op (le client partagé ne se ferme
  pas à la sortie).
- Cache par (boucle d'event, kwargs) en WeakKeyDictionary : un
  AsyncClient n'est JAMAIS partagé entre deux loops (piège des tests
  « Event loop is closed ») — une boucle par test = client propre
  collecté avec la boucle. Clé = kwargs triés, repr() pour les valeurs
  non hashables (`headers=` dict → TypeError rattrapé par la suite).
- Laissés délibérément : github_adapter (transport MockTransport
  injecté), webhook_outbound (client « own_client » fermé par la
  fonction).
- Tests : `test_http_client_shared_and_loop_scoped` (réutilisation mêmes
  kwargs / cloisonné kwargs / cloisonné loop) ; le stub des webhooks
  patche aussi la fabrique `http_client.httpx` + purge du cache (avant :
  webhook_outbound.httpx patché mais la fabrique partagée créait un vrai
  client → réseau réel dans les tests).

suite **1091/1091** · ruff OK · docs à jour
2026-10-01 23:09:45 -04:00
bruno 069c438aae fix: A20 phase 2 — chart/leaflet vendorisés + connect-src fermé (v7.27.0)
FlowDeck CI / lint (push) Successful in 1m54s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m41s
- Vendorisation : chart.js 4.5.1 + leaflet 1.9 (leaflet.js, leaflet.css,
  5 images marker/layer) vers static/js/vendor/ (déjà ignoré par eslint) ;
  les 3 URL CDN des vues chart/map (collections.py) pointent en local →
  la CSP n'a plus AUCUN hôte tiers dans script-src ni style-src.
- connect-src fermé : `'self' ws://{host} wss://{host}` — Host de la
  requête (uvicorn rejette déjà les Host invalides) + filtrage des
  caractères hors base URL. Le `https:` universel (canal d'exfil) et les
  ws:/wss: tout-hôtes disparaissent. Grep négatif : 0 fetch cross-origin
  côté front.
- Google Fonts : entrées CSP mortes (0 référence dans le code) retirées
  de style-src/font-src.
- img-src https: CONSERVÉ volontairement (unfurls YouTube/Vimeo… + tuiles
  OSM inénumérables) — ponytail: commenté dans security.py.

Tests : test_csp_no_cdn_and_vendor (CSP sans CDN/Google, connect-src
exact 'self' ws://testserver wss://testserver, 4 assets vendor 200,
source collections.py sans CDN) + test_view_chart_renders mis à jour
(chemin vendor). Suite complète 1090/1090 (1089 + 1).

Reste A20 : unsafe-eval (Alpine x-data + htmx hx-on/hx-vars = eval)
→ build @alpinejs/csp + couverture E2E des vues d'abord (même logique
que la décision A39).

suite **1090/1090** · ruff OK · docs à jour
2026-10-01 22:49:48 -04:00
34 changed files with 982 additions and 86 deletions
+60
View File
@@ -1,5 +1,65 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé)
### Changed
- **`app/services/http_client.py`** : `async with shared_client(timeout=15)
as client:` remplace les **49 créations `async with httpx.AsyncClient(`**
réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4,
automations ×3, …) — le pool de connexions est réutilisé au lieu d'être
recréé à chaque appel
- Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un
`AsyncClient` n'est jamais partagé entre deux loops (le piège des tests :
« Event loop is closed ») — une boucle par test = un client propre,
collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non
hashables (`headers=` dict)
- Context manager no-op à la sortie (pas de fermeture du client partagé) ;
`ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non
fermés à la main (GC des sockets), upgrade = lifespan
- **Laissés délibérément** : `github_adapter` (transport MockTransport
injecté), `webhook_outbound` (client « own_client » fermé par la fonction)
### Tests
- `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs),
cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`)
- Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du
cache (les tests patchaient `webhook_outbound.httpx`, contourné par la
fabrique partagée)
- Suite complète : **1091/1091**
## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé)
### Changed
- **Vendorisation** : chart.js 4.5.1 + leaflet 1.9 (js, css, 5 images
marker/layer) téléchargés vers `static/js/vendor/` ; les 3 URL CDN des
vues chart/map (`collections.py`) pointent en local → **la CSP n'a plus
aucun hôte tiers** dans `script-src` ni `style-src`
- **`connect-src` fermé** : `'self' ws://{host} wss://{host}` (Host de la
requête, caractères hors base URL filtrés) — le `https:` universel
(canal d'exfil JS) et les `ws:`/`wss:` tout-hôtes disparaissent.
Grep négatif : 0 fetch cross-origin côté front
- **Google Fonts** : entrées CSP mortes (0 référence dans le code) retirées
de `style-src`/`font-src`
- `img-src https:` **conservé volontairement** (unfurls YouTube/Vimeo… et
tuiles OSM inénumérables) — `ponytail:` commenté dans `security.py`
### Tests
- `test_csp_no_cdn_and_vendor` : CSP sans CDN/Google, connect-src exact
(`'self' ws://testserver wss://testserver`), 4 assets vendor servis (200),
source `collections.py` sans référence CDN
- `test_view_chart_renders` : assert sur le chemin vendor
- Suite complète : **1090/1090**
### Notes
- Reste A20 : `unsafe-eval` (Alpine x-data + htmx hx-on/hx-vars = eval)
→ build `@alpinejs/csp` + couverture E2E des vues d'abord (même logique
que la décision A39)
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop) ## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
### Changed ### Changed
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.26.0 7.28.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.26.0 (audit — A21 phase 2c : 190 routes hors loop, 86 % total) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.28.0 (audit — A42 TERMINÉ : client httpx partagé par boucle) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global ## Avancement Global
+3 -4
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
import logging import logging
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,7 +38,7 @@ class GiteaOAuth:
async def exchange_code(self, code: str) -> dict | None: async def exchange_code(self, code: str) -> dict | None:
"""Exchange authorization code for access token.""" """Exchange authorization code for access token."""
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.post( resp = await client.post(
self.TOKEN_URL, self.TOKEN_URL,
data={ data={
@@ -61,7 +60,7 @@ class GiteaOAuth:
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
"""Get user info from Gitea API.""" """Get user info from Gitea API."""
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
self.USER_URL, self.USER_URL,
headers={"Authorization": f"token {access_token}"}, headers={"Authorization": f"token {access_token}"},
+7 -7
View File
@@ -7,7 +7,7 @@ import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ class GiteaProvider(OAuthProvider):
"grant_type": "authorization_code", "grant_type": "authorization_code",
"redirect_uri": redirect_uri or self.redirect_uri, "redirect_uri": redirect_uri or self.redirect_uri,
} }
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post(url, json=data, headers={"Accept": "application/json"}) r = await client.post(url, json=data, headers={"Accept": "application/json"})
if r.status_code != 200: if r.status_code != 200:
logger.error("Gitea token exchange failed: %s", r.text) logger.error("Gitea token exchange failed: %s", r.text)
@@ -85,7 +85,7 @@ class GiteaProvider(OAuthProvider):
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
url = f"{self.base}/api/v1/user" url = f"{self.base}/api/v1/user"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url, headers={"Authorization": f"token {access_token}"}) r = await client.get(url, headers={"Authorization": f"token {access_token}"})
if r.status_code != 200: if r.status_code != 200:
return None return None
@@ -100,7 +100,7 @@ class GiteaProvider(OAuthProvider):
async def list_repositories(self, access_token: str) -> list[dict]: async def list_repositories(self, access_token: str) -> list[dict]:
repos = [] repos = []
async with httpx.AsyncClient(timeout=30) as client: async with shared_client(timeout=30) as client:
for page in range(1, 6): for page in range(1, 6):
r = await client.get( r = await client.get(
f"{self.base}/api/v1/user/repos", f"{self.base}/api/v1/user/repos",
@@ -158,7 +158,7 @@ class GitHubProvider(OAuthProvider):
) )
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None: async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post( r = await client.post(
self.token_url, self.token_url,
data={ data={
@@ -179,7 +179,7 @@ class GitHubProvider(OAuthProvider):
async def get_user(self, access_token: str) -> dict | None: async def get_user(self, access_token: str) -> dict | None:
url = f"{self.api_url}/user" url = f"{self.api_url}/user"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get( r = await client.get(
url, url,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"}, headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
@@ -197,7 +197,7 @@ class GitHubProvider(OAuthProvider):
async def list_repositories(self, access_token: str) -> list[dict]: async def list_repositories(self, access_token: str) -> list[dict]:
repos = [] repos = []
async with httpx.AsyncClient(timeout=30) as client: async with shared_client(timeout=30) as client:
for page in range(1, 6): for page in range(1, 6):
r = await client.get( r = await client.get(
f"{self.api_url}/user/repos", f"{self.api_url}/user/repos",
+4 -4
View File
@@ -15,7 +15,7 @@ import secrets
import time import time
import warnings import warnings
import httpx from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,7 +59,7 @@ async def discover(issuer_url: str) -> dict:
if hit and now - hit[0] < _DISCOVERY_TTL: if hit and now - hit[0] < _DISCOVERY_TTL:
return hit[1] return hit[1]
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
doc = r.json() doc = r.json()
@@ -112,7 +112,7 @@ async def exchange_code(
if client_secret: if client_secret:
auth = (client_id, client_secret) auth = (client_id, client_secret)
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.post(doc["token_endpoint"], data=data, auth=auth) r = await client.post(doc["token_endpoint"], data=data, auth=auth)
except Exception as err: except Exception as err:
raise OIDCError(f"OIDC token request failed: {err}") from err raise OIDCError(f"OIDC token request failed: {err}") from err
@@ -133,7 +133,7 @@ async def fetch_userinfo(doc: dict, access_token: str) -> dict:
if not endpoint or not access_token: if not endpoint or not access_token:
return {} return {}
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"}) r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
if r.status_code != 200: if r.status_code != 200:
return {} return {}
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.26.0", version="7.28.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+24 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import re
import secrets import secrets
import time import time
from collections import defaultdict from collections import defaultdict
@@ -74,16 +75,24 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20). # besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = ( CSP_VALUE = (
"default-src 'self'; " "default-src 'self'; "
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues # A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
# chart/map de collections — l'upgrade est de les vendoriser dans # (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
# /static/js puis de retirer ces deux hôtes. # `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' " # ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
"https://cdn.jsdelivr.net https://unpkg.com; " "script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
"script-src-attr 'unsafe-inline'; " "script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; " # ponytail: aucun @font-face Google (grep négatif) → les deux
# hôtes fonts étaient morts, supprimés.
"style-src 'self' 'unsafe-inline'; "
# ponytail: `https:` reste ouvert — unfurls (YouTube/Vimeo/…) et
# tuiles OSM sont inénumérables ; plafond assumé, à resserrer si
# un proxy d'images local arrive.
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; " "font-src 'self' data:; "
"connect-src 'self' https: wss: ws:; " # connect-src fermé : plus de `https:` (aucun fetch cross-origin
# côté front — grep négatif) et websockets scopés à l'hôte de la
# requête ({host}) → plus de canal d'exfil vers un tiers.
"connect-src 'self' ws://{host} wss://{host}; "
"media-src 'self' blob:; " "media-src 'self' blob:; "
"frame-src 'self'; " "frame-src 'self'; "
"object-src 'none'; " "object-src 'none'; "
@@ -101,7 +110,13 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# Only set CSP on HTML responses # Only set CSP on HTML responses
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
if "text/html" in content_type: if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce) # Host du navigateur (uvicorn rejette les Host invalides) ;
# on retire quand même tout caractère hors base URL par sécurité.
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
request.headers.get("host", ""))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
nonce=nonce, host=host
)
return response return response
+2 -2
View File
@@ -16,6 +16,7 @@ from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event, run_event_sync from app.services.automations import fire_event, run_event_sync
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.services.http_client import shared_client
from app.services.permission_manager import PermissionManager from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -1880,8 +1881,7 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
if token: if token:
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo) info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
else: else:
import httpx async with shared_client(timeout=10) as client:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get( r = await client.get(
f"https://api.github.com/repos/{owner}/{repo}", f"https://api.github.com/repos/{owner}/{repo}",
headers={"Accept": "application/vnd.github+json"}, headers={"Accept": "application/vnd.github+json"},
+3 -3
View File
@@ -2292,7 +2292,7 @@ def _render_chart(view_type: str, collection: dict, pages: list[dict], config: d
canvas{{max-height:400px}} canvas{{max-height:400px}}
</style> </style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div> <div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="/static/js/vendor/chart.umd.js"></script>
<script nonce="{CSP_NONCE.get()}"> <script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{ new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}', type: '{chart_type}',
@@ -2404,9 +2404,9 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<style> <style>
#map{{height:400px;border-radius:8px}} #map{{height:400px;border-radius:8px}}
</style> </style>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" /> <link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> <script src="/static/js/vendor/leaflet.js"></script>
<script nonce="{CSP_NONCE.get()}"> <script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6); const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map); L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
+2 -2
View File
@@ -18,6 +18,7 @@ from fastapi.responses import JSONResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
from app.services.api_v2_helpers import audit_log from app.services.api_v2_helpers import audit_log
from app.services.http_client import shared_client
router = APIRouter(tags=["scim"]) router = APIRouter(tags=["scim"])
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"] SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
@@ -246,7 +247,6 @@ def create_domain(request: Request, body: dict = Body(default={})):
@router.post("/api/v2/domain-claims/{domain_id}/verify") @router.post("/api/v2/domain-claims/{domain_id}/verify")
async def verify_domain(domain_id: int, request: Request): async def verify_domain(domain_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
import httpx
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone() row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
if not row: if not row:
@@ -254,7 +254,7 @@ async def verify_domain(domain_id: int, request: Request):
claim = dict(row) claim = dict(row)
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt" url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
try: try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: async with shared_client(timeout=10, follow_redirects=True) as client:
resp = await client.get(url) resp = await client.get(url)
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "") ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
except Exception: # noqa: BLE001 — unreachable domain = not verified except Exception: # noqa: BLE001 — unreachable domain = not verified
+2 -2
View File
@@ -24,6 +24,7 @@ from app.auth.providers import oidc_provider, saml_provider
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.services import sso_provisioning as sso from app.services import sso_provisioning as sso
from app.services.api_v2_helpers import has_scope, resolve_bearer_token from app.services.api_v2_helpers import has_scope, resolve_bearer_token
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["sso"]) router = APIRouter(tags=["sso"])
@@ -436,10 +437,9 @@ async def _fetch_jwks(doc: dict) -> dict:
url = doc.get("jwks_uri") url = doc.get("jwks_uri")
if not url: if not url:
raise oidc_provider.OIDCError("Discovery document has no jwks_uri") raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
import httpx
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
+4 -5
View File
@@ -26,10 +26,9 @@ import logging
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn from app.db import get_conn
from app.services import notifications from app.services import notifications
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -179,7 +178,7 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")} headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret: if secret:
headers["X-FlowDeck-Secret"] = secret headers["X-FlowDeck-Secret"] = secret
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post(url, json=context, headers=headers) resp = await client.post(url, json=context, headers=headers)
if resp.status_code >= 400: if resp.status_code >= 400:
raise RuntimeError(f"webhook returned HTTP {resp.status_code}") raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
@@ -719,7 +718,7 @@ async def fire_stepped_event(event: str, payload: dict) -> None:
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ─────── # ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
async def _post_slack(webhook_url: str, text: str) -> str: async def _post_slack(webhook_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post(webhook_url, json={"text": text}) resp = await client.post(webhook_url, json={"text": text})
if resp.status_code >= 400: if resp.status_code >= 400:
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}") raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
@@ -765,7 +764,7 @@ async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
if provider == "github": if provider == "github":
if not token: if not token:
raise ValueError("github action needs a linked GitHub account (token)") raise ValueError("github action needs a linked GitHub account (token)")
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.post( resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues", f"https://api.github.com/repos/{owner}/{repo}/issues",
headers={"Authorization": f"Bearer {token}", headers={"Authorization": f"Bearer {token}",
+5 -6
View File
@@ -19,9 +19,8 @@ import time
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn from app.db import get_conn
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -125,7 +124,7 @@ async def google_list_events(tokens: dict, calendar_id: str,
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}" url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
f"/events?singleEvents=true&orderBy=startTime" f"/events?singleEvents=true&orderBy=startTime"
f"&timeMin={time_min}&timeMax={time_max}") f"&timeMin={time_min}&timeMax={time_max}")
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"}) resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
if resp.status_code == 401: if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar") raise SyncError("google token expired — relink the calendar")
@@ -150,7 +149,7 @@ async def google_push_event(tokens: dict, calendar_id: str, event: dict,
"start": {"date": event.get("start", "")[:10]}, "start": {"date": event.get("start", "")[:10]},
"end": {"date": event.get("start", "")[:10]}} "end": {"date": event.get("start", "")[:10]}}
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events" base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
if remote_id: if remote_id:
resp = await client.patch(f"{base}/{remote_id}", resp = await client.patch(f"{base}/{remote_id}",
headers={"Authorization": f"Bearer {access}"}, json=body) headers={"Authorization": f"Bearer {access}"}, json=body)
@@ -224,7 +223,7 @@ async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[
body = _CALDAV_REPORT.format( body = _CALDAV_REPORT.format(
start=time_min.replace("-", "").split("T")[0] + "T000000Z", start=time_min.replace("-", "").split("T")[0] + "T000000Z",
end=time_max.replace("-", "").split("T")[0] + "T000000Z") end=time_max.replace("-", "").split("T")[0] + "T000000Z")
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client: async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.request("REPORT", url, content=body, resp = await client.request("REPORT", url, content=body,
headers={"Depth": "1", headers={"Depth": "1",
"Content-Type": "application/xml"}) "Content-Type": "application/xml"})
@@ -245,7 +244,7 @@ async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> st
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}") remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""), ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
event.get("start", ""), event.get("description", "")) event.get("start", ""), event.get("description", ""))
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client: async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"}) resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
if resp.status_code >= 400: if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}") raise SyncError(f"caldav returned HTTP {resp.status_code}")
+22 -23
View File
@@ -5,9 +5,8 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +47,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/user/repos", f"{self._base}/user/repos",
headers=self._headers, headers=self._headers,
@@ -65,7 +64,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/orgs/{org}/repos", f"{self._base}/orgs/{org}/repos",
headers=self._headers, headers=self._headers,
@@ -82,7 +81,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}", f"{self._base}/repos/{owner}/{repo}",
headers=self._headers, headers=self._headers,
@@ -109,7 +108,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/user/orgs", headers=self._headers f"{self._base}/user/orgs", headers=self._headers
) )
@@ -128,7 +127,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues", f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers, headers=self._headers,
@@ -140,7 +139,7 @@ class GiteaClient:
return data return data
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict: async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers, headers=self._headers,
@@ -163,7 +162,7 @@ class GiteaClient:
payload["assignees"] = [assignee] payload["assignees"] = [assignee]
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post( resp = await client.post(
f"{self._base}/repos/{owner}/{repo}/issues", f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers, headers=self._headers,
@@ -177,7 +176,7 @@ class GiteaClient:
) -> dict: ) -> dict:
"""Update an issue (title, body, state, labels, milestone, assignees).""" """Update an issue (title, body, state, labels, milestone, assignees)."""
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.patch( resp = await client.patch(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers, headers=self._headers,
@@ -191,7 +190,7 @@ class GiteaClient:
) -> list[dict]: ) -> list[dict]:
"""Replace all labels on an issue (PUT endpoint).""" """Replace all labels on an issue (PUT endpoint)."""
self._invalidate_issue_cache(owner, repo) self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.put( resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels",
headers=self._headers, headers=self._headers,
@@ -210,7 +209,7 @@ class GiteaClient:
self, owner: str, repo: str, issue_id: int self, owner: str, repo: str, issue_id: int
) -> list[dict]: ) -> list[dict]:
"""Get comments for an issue.""" """Get comments for an issue."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments", f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
headers=self._headers, headers=self._headers,
@@ -232,7 +231,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/labels", f"{self._base}/repos/{owner}/{repo}/labels",
headers=self._headers, headers=self._headers,
@@ -252,7 +251,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/milestones", f"{self._base}/repos/{owner}/{repo}/milestones",
headers=self._headers, headers=self._headers,
@@ -267,7 +266,7 @@ class GiteaClient:
async def list_webhooks(self, owner: str, repo: str) -> list[dict]: async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
"""List webhooks for a repo.""" """List webhooks for a repo."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/hooks", f"{self._base}/repos/{owner}/{repo}/hooks",
headers=self._headers, headers=self._headers,
@@ -292,7 +291,7 @@ class GiteaClient:
"events": events, "events": events,
"active": True, "active": True,
} }
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.post( resp = await client.post(
f"{self._base}/repos/{owner}/{repo}/hooks", f"{self._base}/repos/{owner}/{repo}/hooks",
headers=self._headers, headers=self._headers,
@@ -303,7 +302,7 @@ class GiteaClient:
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool: async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
"""Delete a webhook.""" """Delete a webhook."""
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.delete( resp = await client.delete(
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}", f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
headers=self._headers, headers=self._headers,
@@ -319,7 +318,7 @@ class GiteaClient:
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/collaborators", f"{self._base}/repos/{owner}/{repo}/collaborators",
headers=self._headers, headers=self._headers,
@@ -341,7 +340,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get(url, headers=self._headers) resp = await client.get(url, headers=self._headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -357,7 +356,7 @@ class GiteaClient:
cached = self._cached(cache_key) cached = self._cached(cache_key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -382,7 +381,7 @@ class GiteaClient:
} }
if sha: if sha:
body["sha"] = sha body["sha"] = sha
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.put( resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -398,7 +397,7 @@ class GiteaClient:
async def delete_file(self, owner, repo, path, sha, message): async def delete_file(self, owner, repo, path, sha, message):
"""Delete a file from the repo.""" """Delete a file from the repo."""
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.delete( resp = await client.delete(
f"{self._base}/repos/{owner}/{repo}/contents/{path}", f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers, headers=self._headers,
@@ -422,7 +421,7 @@ class GiteaClient:
cached = self._cached(key) cached = self._cached(key)
if cached: if cached:
return cached return cached
async with httpx.AsyncClient(timeout=15) as client: async with shared_client(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/commits", f"{self._base}/repos/{owner}/{repo}/commits",
headers=self._headers, headers=self._headers,
+68
View File
@@ -0,0 +1,68 @@
"""Client HTTP partagé (A42 — reliquat du audit).
51 créations `httpx.AsyncClient(...)` éparpillées dans 15 fichiers = un
nouveau pool de connexions par appel (pas de keep-alive). Ici le pool est
réutilisé, **closé par boucle d'event** : un `AsyncClient` ne traverse pas
de boucle à boucle (les tests en créent une par test → client propre par
boucle, collecté avec elle).
`async with shared_client(timeout=15) as client:` — même syntaxe que
before, l'`__aexit__` est un no-op (on ne ferme pas le client partagé).
ponytail: pas d'`aclose` explicite — le client meurt avec sa boucle
(WeakKeyDictionary, les sockets sont fermés par le GC) ; plafond : le pool
du loop produit n'est pas fermé à la main. Upgrade si besoin : lifespan
qui ferme le client du loop principal.
"""
from __future__ import annotations
import asyncio
import weakref
import httpx
_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict] = (
weakref.WeakKeyDictionary()
)
def _hashable(v) -> bool:
try:
hash(v)
return True
except TypeError:
return False
def _key(kwargs: dict) -> tuple:
"""Clé de cache = kwargs (timeout/auth/transport/headers…). Valeurs non
hashables (dict `headers=`…) → repr, même contrat que la clé."""
return tuple(
(k, v if _hashable(v) else repr(v))
for k, v in sorted(kwargs.items())
)
def get_shared_client(**kwargs) -> httpx.AsyncClient:
loop = asyncio.get_running_loop()
per_loop = _clients.setdefault(loop, {})
k = _key(kwargs)
client = per_loop.get(k)
if client is None:
client = httpx.AsyncClient(**kwargs)
per_loop[k] = client
return client
class shared_client:
"""Context manager async : yield du client partagé, no-op à la sortie."""
def __init__(self, **kwargs):
self._kwargs = kwargs
async def __aenter__(self) -> httpx.AsyncClient:
return get_shared_client(**self._kwargs)
async def __aexit__(self, *exc) -> bool:
return False
+2 -1
View File
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
import httpx import httpx
from app.services.export import markdown_to_blocks from app.services.export import markdown_to_blocks
from app.services.http_client import shared_client
from app.services.importers.base import ImportPage, ImportResult from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown from app.services.importers.html_notes import _html_to_markdown
@@ -61,7 +62,7 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
safe_url = _validate_url(url) safe_url = _validate_url(url)
result = ImportResult(source="url") result = ImportResult(source="url")
try: try:
async with httpx.AsyncClient( async with shared_client(
timeout=15, follow_redirects=True, transport=transport, timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"}, headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client: ) as client:
+2 -1
View File
@@ -27,6 +27,7 @@ from dataclasses import dataclass, field
import httpx import httpx
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -270,7 +271,7 @@ class LLMClient:
if self.api_key: if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
try: try:
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client: async with shared_client(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers) resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
+3 -4
View File
@@ -14,6 +14,7 @@ import json
import time import time
from app.config import settings from app.config import settings
from app.services.http_client import shared_client
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions # Providers whose /v1/models lists far more entries than /v1/chat/completions
@@ -331,7 +332,6 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
Anthropic's native model listing uses `x-api-key` + `anthropic-version`. Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
Returns a de-duplicated list capped at 300 models. Returns a de-duplicated list capped at 300 models.
""" """
import httpx
provider = provider.lower() provider = provider.lower()
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0] base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
@@ -345,7 +345,7 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
elif api_key: elif api_key:
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
async with httpx.AsyncClient(timeout=timeout) as client: async with shared_client(timeout=timeout) as client:
resp = await client.get(f"{base_url}/models", headers=headers) resp = await client.get(f"{base_url}/models", headers=headers)
if resp.status_code >= 400: if resp.status_code >= 400:
body = (resp.text or "").strip() body = (resp.text or "").strip()
@@ -405,7 +405,6 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
""" """
import asyncio import asyncio
import httpx
sem = asyncio.Semaphore(concurrency) sem = asyncio.Semaphore(concurrency)
start = time.monotonic() start = time.monotonic()
@@ -427,7 +426,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
return None return None
try: try:
async with sem: async with sem:
async with httpx.AsyncClient(timeout=timeout) as client: async with shared_client(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload) resp = await client.post(url, headers=headers, json=payload)
code = resp.status_code code = resp.status_code
if code < 300 or code == 429: if code < 300 or code == 429:
+3 -2
View File
@@ -12,6 +12,8 @@ import logging
import re import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I) _META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
@@ -143,7 +145,6 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
src = "https://" + src src = "https://" + src
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""} base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
try: try:
import httpx
headers = { headers = {
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)", "User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
@@ -152,7 +153,7 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
kwargs = {"timeout": timeout} kwargs = {"timeout": timeout}
if transport is not None: if transport is not None:
kwargs["transport"] = transport kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client: async with shared_client(**kwargs) as client:
resp = await _get_checked(client, src, headers) resp = await _get_checked(client, src, headers)
except ValueError: except ValueError:
# A12 : hôte privé/loopback ou trop de redirections → refus explicite. # A12 : hôte privé/loopback ou trop de redirections → refus explicite.
+3 -2
View File
@@ -15,6 +15,7 @@ import time
import httpx import httpx
from app.db import get_conn from app.db import get_conn
from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -189,7 +190,7 @@ async def fire_event(event: str, payload: dict):
if not subs: if not subs:
logger.debug("No subscribers for event: %s", event) logger.debug("No subscribers for event: %s", event)
return return
async with httpx.AsyncClient(timeout=30.0) as client: async with shared_client(timeout=30.0) as client:
for sub in subs: for sub in subs:
try: try:
await deliver_to_sub(sub["id"], sub["url"], event, await deliver_to_sub(sub["id"], sub["url"], event,
@@ -219,7 +220,7 @@ async def retry_due_deliveries(now: float | None = None) -> int:
).fetchall() ).fetchall()
if not rows: if not rows:
return 0 return 0
async with httpx.AsyncClient(timeout=10) as client: async with shared_client(timeout=10) as client:
for r in rows: for r in rows:
try: try:
payload = json.loads(r["payload"] or "{}") payload = json.loads(r["payload"] or "{}")
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.26.0" "version": "7.28.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
+14
View File
File diff suppressed because one or more lines are too long
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+3 -2
View File
@@ -2492,14 +2492,15 @@ def test_dashboard_create_default_layout(client):
def test_view_chart_renders(client): def test_view_chart_renders(client):
"""Chart view renders with Chart.js CDN.""" """Chart view renders with vendored Chart.js (A20 : plus de CDN)."""
resp = client.post("/db/api", json={"name": "Chart DB"}) resp = client.post("/db/api", json={"name": "Chart DB"})
coll_id = resp.json()["id"] coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item A", "properties": {"Count": "5"}}) client.post(f"/db/{coll_id}/pages/api", json={"title": "Item A", "properties": {"Count": "5"}})
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item B", "properties": {"Count": "8"}}) client.post(f"/db/{coll_id}/pages/api", json={"title": "Item B", "properties": {"Count": "8"}})
resp = client.get(f"/db/{coll_id}/view/chart") resp = client.get(f"/db/{coll_id}/view/chart")
assert resp.status_code == 200 assert resp.status_code == 200
assert "chart.js" in resp.text assert "/static/js/vendor/chart.umd.js" in resp.text
assert "cdn.jsdelivr" not in resp.text
def test_view_form_renders(client): def test_view_form_renders(client):
+66
View File
@@ -270,6 +270,72 @@ def _re_search_nonce(csp: str) -> str:
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1) return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
def test_csp_no_cdn_and_vendor(client):
"""A20 phase 2 : plus aucun hôte CDN tiers, chart/leaflet vendorisés,
connect-src fermé (scopé à l'hôte de la requête)."""
import pathlib as _pathlib
page = None
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
cand = client.get(url)
if cand.status_code == 200 and "htmx-config" in cand.text:
page = cand
break
assert page is not None, "aucune page base.html atteignable"
csp = page.headers.get("content-security-policy", "")
assert "cdn.jsdelivr" not in csp, csp
assert "unpkg.com" not in csp, csp
assert "fonts.googleapis.com" not in csp, csp
assert "fonts.gstatic.com" not in csp, csp
import re as _re
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
assert conn == "'self' ws://testserver wss://testserver", conn
assert " https:" not in conn and not conn.startswith("https:"), conn
# vues chart/map : références locales (aucun CDN dans collections.py)
src = _pathlib.Path("app/routers/collections.py").read_text(encoding="utf-8")
assert "cdn.jsdelivr" not in src and "unpkg.com" not in src
# assets vendor servis
for path in (
"/static/js/vendor/chart.umd.js",
"/static/js/vendor/leaflet.js",
"/static/js/vendor/leaflet.css",
"/static/js/vendor/leaflet/images/marker-icon.png",
):
r = client.get(path)
assert r.status_code == 200, (path, r.status_code)
assert len(r.content) > 500, (path, len(r.content))
def test_http_client_shared_and_loop_scoped():
"""A42 : le client HTTP partagé est réutilisé dans la même boucle,
cloisonné par kwargs, et JAMAIS partagé entre deux boucles (un
AsyncClient lié à une boucle morte lèverait « Event loop is closed »)."""
import asyncio
from app.services.http_client import shared_client
async def same_loop():
async with shared_client(timeout=15) as a:
async with shared_client(timeout=15) as b:
assert a is b, "même boucle + mêmes kwargs = même client"
async with shared_client(timeout=30) as c:
assert c is not a, "kwargs différents = client différent"
return a
first = asyncio.run(same_loop())
# nouvelle boucle (façon tests : une boucle par test) → nouveau client
async def other_loop():
async with shared_client(timeout=15) as d:
assert d is not first, "client jamais réutilisé sur une boucle morte"
return d
second = asyncio.run(other_loop())
assert second is not first
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app
+6
View File
@@ -357,6 +357,12 @@ def _patch_async_client(monkeypatch, handler) -> None:
RequestError=httpx.RequestError, RequestError=httpx.RequestError,
) )
monkeypatch.setattr(webhook_outbound, "httpx", stub) monkeypatch.setattr(webhook_outbound, "httpx", stub)
# A42 : la fabrique partagée crée les clients — elle doit voir le stub
# (boucle neuve par test → aucun cache à purger, on purge par sécurité).
from app.services import http_client as _hc
monkeypatch.setattr(_hc, "httpx", stub)
_hc._clients.clear()
@pytest.mark.asyncio @pytest.mark.asyncio