Add opt-in OIDC sign-in with session cookie and roles
Auth is enabled only when issuer, client id and client secret are all set; otherwise the dashboard stays an open LAN dashboard. Provider endpoints are discovered from <issuer>/.well-known/openid-configuration via Authlib, so no provider-specific code is needed. - auth.py: /auth/login, /auth/authorize, /auth/callback, /auth/logout (RP-initiated logout when the issuer advertises end_session_endpoint) - users.py: atomic, lock-guarded data/users.json keyed by the OIDC sub - role resolution: admin claim wins, else first user bootstraps as admin, else viewer - api.py/app.py: session middleware and require_login/require_admin gating on every route except /health, /auth/* and /static - config.py/main.py: UWUDASH_OIDC_* / UWUDASH_SESSION_* env vars plus --oidc-issuer and --public-url flags - templates/login.html, static app.js/styles.css: sign-in page and settings UI
This commit is contained in:
1 parent
2e78143812
commit
3187b8cf05
15 files changed
+1070
-41
No files matched your search
@@ -31,6 +31,8 @@ uv run uwudash-mk3 --reload
|
||||
| `--port` | `8000` | bind port |
|
||||
| `--data-dir` | `data` | directory holding `services.json` |
|
||||
| `--services-file` | `<data-dir>/services.json` | explicit store path |
|
||||
| `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars |
|
||||
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
|
||||
| `--reload` | off | uvicorn reload on source changes |
|
||||
|
||||
### Environment variables
|
||||
@@ -43,9 +45,76 @@ uv run uwudash-mk3 --reload
|
||||
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
|
||||
| `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) |
|
||||
| `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses |
|
||||
| `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) |
|
||||
| `UWUDASH_OIDC_CLIENT_ID` | unset | OIDC client id |
|
||||
| `UWUDASH_OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||
| `UWUDASH_OIDC_SCOPE` | `openid email profile` | requested scope |
|
||||
| `UWUDASH_OIDC_ADMIN_CLAIM` | unset | claim to inspect for admin group membership, e.g. `groups` |
|
||||
| `UWUDASH_OIDC_ADMIN_VALUES` | `admin` | comma-separated claim values that grant `admin` |
|
||||
| `UWUDASH_SESSION_SECRET` | random per start | signed-session cookie secret (set it, or sessions die on restart) |
|
||||
| `UWUDASH_SESSION_TTL` | `3600` | session lifetime in seconds |
|
||||
| `UWUDASH_USERS_FILE` | `<data-dir>/users.json` | user store path |
|
||||
| `UWUDASH_PUBLIC_URL` | request origin | external base URL for the OIDC `redirect_uri` |
|
||||
|
||||
CLI flags override environment variables.
|
||||
|
||||
## Authentication (opt-in)
|
||||
|
||||
With no issuer set the dashboard is open, exactly as before: no `/auth/*` routes
|
||||
are registered and every API route is reachable from the LAN.
|
||||
|
||||
Setting `UWUDASH_OIDC_ISSUER` + `UWUDASH_OIDC_CLIENT_ID` + `UWUDASH_OIDC_CLIENT_SECRET`
|
||||
turns sign-in on. The provider is discovered from
|
||||
`<issuer>/.well-known/openid-configuration` (Authlib reads the authorize, token,
|
||||
JWKS and `end_session_endpoint` URLs), so nothing here is provider-specific -
|
||||
Keycloak, Authentik, Auth0 and the like all work.
|
||||
|
||||
```bash
|
||||
export UWUDASH_OIDC_ISSUER=https://id.example.com
|
||||
export UWUDASH_OIDC_CLIENT_ID=uwudash
|
||||
export UWUDASH_OIDC_CLIENT_SECRET=...
|
||||
export UWUDASH_OIDC_ADMIN_CLAIM=groups
|
||||
export UWUDASH_OIDC_ADMIN_VALUES=dash-admin
|
||||
export UWUDASH_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
uv run uwudash-mk3 --public-url https://dash.example.com
|
||||
```
|
||||
|
||||
Flow: `/` with no session redirects to `/auth/login` → `Sign in` →
|
||||
`/auth/authorize` → the provider → `/auth/callback` validates the `id_token`
|
||||
against the issuer's JWKS → a signed `session` cookie (`HttpOnly`, `SameSite=lax`,
|
||||
`max_age=UWUDASH_SESSION_TTL`) holds the user id → `/`.
|
||||
|
||||
Once auth is on, every route except `/health`, `/auth/*`, `/static` and FastAPI's
|
||||
own `/docs` / `/openapi.json` requires a session: unauthenticated requests get
|
||||
`401` (API) or a redirect to `/auth/login` (the HTML page).
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `admin` | everything a viewer can, plus create/update/delete services and list or change user roles |
|
||||
| `viewer` | read the service list, open services through the proxy, see their own profile |
|
||||
|
||||
The role is decided at **first** login and never changed by logging in again -
|
||||
promotions and demotions go through `PATCH /api/users/{id}` (or the Settings page):
|
||||
|
||||
1. if `UWUDASH_OIDC_ADMIN_CLAIM` is set and the token's claim (scalar or list)
|
||||
contains one of `UWUDASH_OIDC_ADMIN_VALUES` → `admin`;
|
||||
2. otherwise, if the user store is empty → `admin` (the first person to log in
|
||||
bootstraps the dashboard);
|
||||
3. otherwise → `viewer`.
|
||||
|
||||
Users are stored in `data/users.json` (same atomic-write format as
|
||||
`data/services.json`), keyed by the OIDC `sub`; `email` and `name` are refreshed
|
||||
on each login.
|
||||
|
||||
### Signing out
|
||||
|
||||
`/auth/logout` (GET or POST) always clears the local session. If the issuer
|
||||
advertises an `end_session_endpoint`, it then does RP-initiated logout with the
|
||||
saved `id_token` and returns to `/auth/login` afterwards; otherwise it returns to
|
||||
`/auth/login` directly.
|
||||
|
||||
## Services
|
||||
|
||||
Services live in `data/services.json`:
|
||||
@@ -88,14 +157,23 @@ Only the upstream **origin** (`scheme://host:port`) is mapped onto the dashboard
|
||||
So a service with `url = http://internal-host:8080/sub/page` is served at
|
||||
`/proxy/<id>/sub/page`, and any link the upstream page emits to
|
||||
`http://internal-host:8080/...` or `//internal-host:8080/...` is rewritten to
|
||||
`/proxy/<id>/...`. Relative links need no rewriting.
|
||||
`/proxy/<id>/...`. Root-relative links (`/foo/bar`) are pulled back onto the
|
||||
proxy prefix too - they would otherwise resolve against the dashboard and 404.
|
||||
Links relative to the current page (`foo/bar`) need no rewriting.
|
||||
|
||||
Details:
|
||||
|
||||
- `Location` headers on `3xx` responses are rewritten back onto the proxy prefix;
|
||||
redirects are **not** followed, so the browser stays on the proxy.
|
||||
- `text/html` responses are buffered and rewritten; everything else (JSON, CSS,
|
||||
images, downloads) is streamed through unchanged.
|
||||
- `text/html` responses are buffered and rewritten. `application/json` is
|
||||
rewritten as well, but only values that start with the service's own base path:
|
||||
single-page apps read their base path from an API config (Sonarr's
|
||||
`initialize.json` returns `urlBase`/`apiRoot`), while plain data fields such as
|
||||
filesystem paths are left alone. Everything else (CSS, images, downloads) is
|
||||
streamed through unchanged.
|
||||
- Upstream compression (`gzip`, `deflate`, `br`) is decoded by httpx, so
|
||||
`Content-Encoding` and `Content-Length` are never forwarded - the body the
|
||||
browser receives is always uncompressed and header-consistent.
|
||||
- `X-Frame-Options` and CSP `frame-ancestors` are stripped from proxied responses
|
||||
so the iframe can render (disable with `UWUDASH_STRIP_FRAME_OPTIONS=false`).
|
||||
- Hop-by-hop request/response headers are dropped; per-service `headers` are merged
|
||||
@@ -113,7 +191,14 @@ Details:
|
||||
| `PATCH` / `PUT` | `/api/services/{id}` | update |
|
||||
| `DELETE` | `/api/services/{id}` | delete |
|
||||
| `GET` | `/api/services/{id}/health` | upstream reachability check |
|
||||
| `GET` | `/api/config` | effective config |
|
||||
| `GET` | `/api/config` | effective config (never includes the OIDC secret) |
|
||||
| `GET` | `/api/me` | current user (only when auth is enabled) |
|
||||
| `GET` | `/api/users` | list users - admin only |
|
||||
| `PATCH` | `/api/users/{id}` | change a role - admin only |
|
||||
| `GET` | `/auth/login` | sign-in page |
|
||||
| `GET` | `/auth/authorize` | start the OIDC flow |
|
||||
| `GET` | `/auth/callback` | provider redirect target |
|
||||
| `GET` / `POST` | `/auth/logout` | clear the session, then RP-initiated logout |
|
||||
| `GET` | `/health` | dashboard health |
|
||||
| `ANY` | `/proxy/{id}` and `/proxy/{id}/{path}` | reverse proxy |
|
||||
|
||||
@@ -122,19 +207,28 @@ Details:
|
||||
```
|
||||
src/uwudash_mk3/
|
||||
config.py env-driven Config dataclass
|
||||
models.py Service / ServiceInput / ServicePatch + URL helpers
|
||||
models.py Service / ServiceInput / ServicePatch / User / UserPatch + URL helpers
|
||||
store.py JSON file store (atomic writes, lock)
|
||||
users.py JSON user store (same pattern as store.py)
|
||||
auth.py OIDC client, login/callback/logout, role dependencies
|
||||
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
|
||||
api.py /api routes
|
||||
app.py FastAPI factory, static mount, proxy routes
|
||||
app.py FastAPI factory, session middleware, static mount, proxy routes
|
||||
main.py CLI entrypoint
|
||||
templates/ index.html
|
||||
templates/ index.html, login.html
|
||||
static/ styles.css, app.js
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- No authentication on the dashboard itself - it is meant for a trusted LAN.
|
||||
- No WebSocket proxying.
|
||||
- Authentication is opt-in OIDC (see above). Without an issuer the dashboard is
|
||||
still open by design - it is meant for a trusted LAN. Sign-in gates the
|
||||
dashboard, not the upstream services: the proxy does not forward the dashboard
|
||||
session, so each service still needs its own login or a per-service `headers`
|
||||
token.
|
||||
- Roles are decided at first login and only change through the admin API; there
|
||||
is no group sync after that, and no per-service permissions.
|
||||
- No WebSocket proxying. Apps that use WebSockets for live updates (Sonarr's
|
||||
SignalR) fall back to SSE/long-polling through the proxy.
|
||||
- Upstream cookies work, but they are scoped to the dashboard origin, so
|
||||
cross-service cookie sharing is possible if two services share a proxy host.
|
||||
@@ -5,8 +5,10 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Authlib>=1.9.1",
|
||||
"fastapi>=0.162.0",
|
||||
"httpx>=0.28.1",
|
||||
"itsdangerous>=2.2",
|
||||
"jinja2>=3.1.6",
|
||||
"uvicorn[standard]>=0.54.0",
|
||||
]
|
||||
|
||||
+63
-10
@@ -1,19 +1,35 @@
|
||||
"""JSON API used by the dashboard UI to manage services."""
|
||||
"""JSON API used by the dashboard UI to manage services and users."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from .config import Config
|
||||
from .models import Service, ServiceInput, ServicePatch
|
||||
from .models import Service, ServiceInput, ServicePatch, User, UserPatch
|
||||
from .proxy import Proxy
|
||||
from .store import ServiceStore
|
||||
from .users import UserStore
|
||||
|
||||
|
||||
def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIRouter:
|
||||
router = APIRouter(prefix="/api")
|
||||
def create_api_router(
|
||||
store: ServiceStore,
|
||||
proxy: Proxy,
|
||||
config: Config,
|
||||
users: Optional[UserStore] = None,
|
||||
require_login=None,
|
||||
require_admin=None,
|
||||
) -> APIRouter:
|
||||
"""Build the ``/api`` router.
|
||||
|
||||
``users``/``require_login``/``require_admin`` are only supplied when OIDC
|
||||
is configured; without them the router is exactly the unauthenticated one.
|
||||
"""
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
dependencies=[Depends(require_login)] if require_login else [],
|
||||
)
|
||||
|
||||
@router.get("/config")
|
||||
def get_config() -> dict:
|
||||
@@ -23,6 +39,7 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
|
||||
"services_file": str(config.services_file),
|
||||
"strip_frame_options": config.strip_frame_options,
|
||||
"proxy_timeout": config.proxy_timeout,
|
||||
"auth_enabled": config.auth_enabled,
|
||||
}
|
||||
|
||||
@router.get("/services")
|
||||
@@ -36,18 +53,28 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
return service
|
||||
|
||||
@router.post("/services", status_code=201)
|
||||
@router.post(
|
||||
"/services",
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def create_service(payload: ServiceInput) -> Service:
|
||||
return store.create(payload)
|
||||
|
||||
@router.patch("/services/{service_id}")
|
||||
@router.patch(
|
||||
"/services/{service_id}",
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def update_service(service_id: str, payload: ServicePatch) -> Service:
|
||||
service = store.update(service_id, payload)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
return service
|
||||
|
||||
@router.put("/services/{service_id}")
|
||||
@router.put(
|
||||
"/services/{service_id}",
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def replace_service(service_id: str, payload: ServiceInput) -> Service:
|
||||
patch = ServicePatch(**payload.model_dump())
|
||||
service = store.update(service_id, patch)
|
||||
@@ -55,7 +82,11 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
return service
|
||||
|
||||
@router.delete("/services/{service_id}", status_code=204)
|
||||
@router.delete(
|
||||
"/services/{service_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def delete_service(service_id: str) -> None:
|
||||
if not store.delete(service_id):
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
@@ -67,4 +98,26 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
return await proxy.check(service)
|
||||
|
||||
if users is not None:
|
||||
@router.get("/me")
|
||||
def me(user: User = Depends(require_login)) -> User:
|
||||
return user
|
||||
|
||||
@router.get(
|
||||
"/users",
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def list_users() -> List[User]:
|
||||
return users.list()
|
||||
|
||||
@router.patch(
|
||||
"/users/{user_id}",
|
||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||
)
|
||||
def update_user(user_id: str, payload: UserPatch) -> User:
|
||||
user = users.set_role(user_id, payload.role)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown user: {user_id}")
|
||||
return user
|
||||
|
||||
return router
|
||||
+79
-13
@@ -1,55 +1,121 @@
|
||||
"""FastAPI application factory: static frontend + API + reverse proxy."""
|
||||
"""FastAPI application factory.
|
||||
|
||||
The app is a single process that serves the dashboard UI, a JSON API for
|
||||
managing services, and a same-origin proxy that lets the UI embed internal
|
||||
services that would otherwise refuse to be framed.
|
||||
|
||||
Authentication is opt-in: with an OIDC issuer, client id and client secret the
|
||||
dashboard requires a signed session cookie on every route except ``/health``,
|
||||
``/auth/*`` and ``/static``. Without them it behaves exactly as before - an
|
||||
open LAN dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .api import create_api_router
|
||||
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
|
||||
from .config import Config
|
||||
from .proxy import Proxy
|
||||
from .store import ServiceStore
|
||||
from .users import UserStore
|
||||
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
||||
|
||||
|
||||
def create_app(config: Optional[Config] = None) -> FastAPI:
|
||||
def create_app(config: Config | None = None) -> FastAPI:
|
||||
config = config or Config.from_env()
|
||||
|
||||
store = ServiceStore(config.services_file)
|
||||
proxy = Proxy(store, config)
|
||||
users = UserStore(config.users_file) if config.auth_enabled else None
|
||||
oauth = create_oauth(config) if config.auth_enabled else None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if config.auth_enabled and not config.session_secret:
|
||||
print(
|
||||
"warning: UWUDASH_SESSION_SECRET is not set - a random secret is "
|
||||
"generated at startup, so sessions do not survive a restart."
|
||||
)
|
||||
yield
|
||||
proxy.close()
|
||||
await proxy.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="UwuDash MK3",
|
||||
description="Self-hosted internal service dashboard.",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
if config.auth_enabled:
|
||||
# httponly is always set by SessionMiddleware; the cookie is signed with
|
||||
# itsdangerous and expires after UWUDASH_SESSION_TTL.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=config.session_secret or secrets.token_hex(32),
|
||||
session_cookie="session",
|
||||
max_age=config.session_ttl_seconds,
|
||||
same_site="lax",
|
||||
)
|
||||
|
||||
app = FastAPI(title="UwuDash MK3", version="0.1.0", lifespan=lifespan)
|
||||
app.state.config = config
|
||||
app.state.store = store
|
||||
app.state.proxy = proxy
|
||||
app.state.users = users
|
||||
app.state.oauth = oauth
|
||||
|
||||
app.mount("/static", StaticFiles(directory=PACKAGE_DIR / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=PACKAGE_DIR / "templates")
|
||||
|
||||
app.include_router(create_api_router(store, proxy, config))
|
||||
if config.auth_enabled:
|
||||
require_login, require_admin = auth_dependencies(users)
|
||||
app.include_router(create_auth_router(oauth, users, config, templates))
|
||||
else:
|
||||
require_login = require_admin = None
|
||||
|
||||
@app.get("/", response_class=None)
|
||||
app.include_router(
|
||||
create_api_router(store, proxy, config, users, require_login, require_admin)
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html", {"services": store.list()})
|
||||
if config.auth_enabled and get_current_user(request, users) is None:
|
||||
return RedirectResponse("/auth/login")
|
||||
return templates.TemplateResponse(
|
||||
request, "index.html", {"services": store.list()}
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "services": len(store.list())}
|
||||
|
||||
# Reverse proxy: the only route that can reach an upstream service.
|
||||
@app.api_route("/proxy/{service_id}", methods=ALL_METHODS)
|
||||
@app.api_route("/proxy/{service_id}/{path:path}", methods=ALL_METHODS)
|
||||
auth_gate = [Depends(require_login)] if require_login else []
|
||||
|
||||
# The proxy is a catch-all passthrough, not a documented API: keeping it out
|
||||
# of the schema avoids a duplicate-operation-id warning per method.
|
||||
@app.api_route(
|
||||
"/proxy/{service_id}",
|
||||
methods=ALL_METHODS,
|
||||
dependencies=auth_gate,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@app.api_route(
|
||||
"/proxy/{service_id}/{path:path}",
|
||||
methods=ALL_METHODS,
|
||||
dependencies=auth_gate,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def proxy_route(service_id: str, request: Request, path: str = ""):
|
||||
return await proxy.handle(service_id, path, request)
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""OIDC sign-in, session handling and role checks.
|
||||
|
||||
The provider is discovered from ``<issuer>/.well-known/openid-configuration``
|
||||
(authlib reads ``authorize_url``, ``access_token_url``, ``jwks_uri`` and, when
|
||||
present, ``end_session_endpoint``), so nothing here is provider-specific.
|
||||
|
||||
Identity lives in the signed session cookie installed by
|
||||
``starlette.middleware.sessions.SessionMiddleware`` - there is no second cookie
|
||||
and no hand-rolled token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .config import Config
|
||||
from .models import User
|
||||
from .users import UserStore
|
||||
|
||||
|
||||
def create_oauth(config: Config) -> OAuth:
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="oidc",
|
||||
client_id=config.oidc_client_id,
|
||||
client_secret=config.oidc_client_secret,
|
||||
server_metadata_url=config.oidc_issuer + "/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": config.oidc_scope},
|
||||
)
|
||||
return oauth
|
||||
|
||||
|
||||
def base_url(request: Request, config: Config) -> str:
|
||||
"""External base URL of the dashboard (``--public-url`` wins)."""
|
||||
return (config.public_url or str(request.base_url)).rstrip("/")
|
||||
|
||||
|
||||
def get_current_user(request: Request, users: UserStore) -> Optional[User]:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
return users.get(user_id)
|
||||
|
||||
|
||||
def auth_dependencies(users: UserStore) -> Tuple:
|
||||
"""Return ``(require_login, require_admin)`` bound to ``users``."""
|
||||
|
||||
def require_login(request: Request) -> User:
|
||||
user = get_current_user(request, users)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
return user
|
||||
|
||||
def require_admin(request: Request) -> User:
|
||||
user = require_login(request)
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin role required")
|
||||
return user
|
||||
|
||||
return require_login, require_admin
|
||||
|
||||
|
||||
def resolve_role(claims: dict, config: Config, users: UserStore) -> str:
|
||||
"""Decide the role for a freshly authenticated subject.
|
||||
|
||||
1. an explicit admin claim (``UWUDASH_OIDC_ADMIN_CLAIM``) wins;
|
||||
2. otherwise the very first user to log in is bootstrapped as admin;
|
||||
3. everyone else is a viewer.
|
||||
"""
|
||||
if config.oidc_admin_claim:
|
||||
value = claims.get(config.oidc_admin_claim)
|
||||
if value is not None:
|
||||
values = value if isinstance(value, (list, tuple, set)) else [value]
|
||||
if any(str(item) in config.oidc_admin_values for item in values):
|
||||
return "admin"
|
||||
if users.count() == 0:
|
||||
return "admin"
|
||||
return "viewer"
|
||||
|
||||
|
||||
def create_auth_router(
|
||||
oauth: OAuth, users: UserStore, config: Config, templates: Jinja2Templates
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/auth/login")
|
||||
def login_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"error": request.query_params.get("error")}
|
||||
)
|
||||
|
||||
@router.get("/auth/authorize")
|
||||
async def authorize(request: Request):
|
||||
redirect_uri = base_url(request, config) + "/auth/callback"
|
||||
return await oauth.oidc.authorize_redirect(request, redirect_uri)
|
||||
|
||||
@router.get("/auth/callback")
|
||||
async def callback(request: Request):
|
||||
try:
|
||||
token = await oauth.oidc.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
# Redirect back to the login page so the failure is readable in the
|
||||
# browser instead of a bare JSON 400.
|
||||
return RedirectResponse(f"/auth/login?error={quote(str(exc))}")
|
||||
|
||||
claims = token.get("userinfo") or {}
|
||||
subject = claims.get("sub") or token.get("sub")
|
||||
if not subject:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="provider did not return a subject (sub) claim",
|
||||
)
|
||||
|
||||
role = resolve_role(claims, config, users)
|
||||
user = users.upsert(
|
||||
subject=str(subject),
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name") or claims.get("preferred_username"),
|
||||
role=role,
|
||||
)
|
||||
|
||||
request.session["user_id"] = user.id
|
||||
# RP-initiated logout needs the id_token; only keep it when the issuer
|
||||
# advertises an end_session_endpoint.
|
||||
if token.get("id_token") and "end_session_endpoint" in (
|
||||
oauth.oidc.server_metadata or {}
|
||||
):
|
||||
request.session["id_token"] = token["id_token"]
|
||||
|
||||
return RedirectResponse("/")
|
||||
|
||||
@router.get("/auth/logout", operation_id="auth_logout_get")
|
||||
@router.post("/auth/logout", operation_id="auth_logout_post")
|
||||
async def logout(request: Request):
|
||||
# The local session is always dropped; the provider round-trip is only
|
||||
# RP-initiated logout on top of that.
|
||||
id_token = request.session.get("id_token")
|
||||
request.session.clear()
|
||||
if id_token:
|
||||
try:
|
||||
return await oauth.oidc.logout_redirect(
|
||||
request,
|
||||
post_logout_redirect_uri=base_url(request, config) + "/auth/login",
|
||||
id_token_hint=id_token,
|
||||
)
|
||||
except RuntimeError:
|
||||
# Issuer has no end_session_endpoint: fall back to local logout.
|
||||
pass
|
||||
return RedirectResponse("/auth/login")
|
||||
|
||||
return router
|
||||
@@ -5,6 +5,14 @@ dashboard can be deployed without editing code:
|
||||
|
||||
UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE,
|
||||
UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS
|
||||
|
||||
Authentication is opt-in: the OIDC routes and the session middleware only take
|
||||
effect when an issuer, client id and client secret are all present.
|
||||
|
||||
UWUDASH_OIDC_ISSUER, UWUDASH_OIDC_CLIENT_ID, UWUDASH_OIDC_CLIENT_SECRET,
|
||||
UWUDASH_OIDC_SCOPE, UWUDASH_OIDC_ADMIN_CLAIM, UWUDASH_OIDC_ADMIN_VALUES,
|
||||
UWUDASH_SESSION_SECRET, UWUDASH_SESSION_TTL, UWUDASH_USERS_FILE,
|
||||
UWUDASH_PUBLIC_URL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,11 +20,15 @@ from __future__ import annotations
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8000
|
||||
DEFAULT_DATA_DIR = Path("data")
|
||||
DEFAULT_PROXY_TIMEOUT = 30.0
|
||||
DEFAULT_OIDC_SCOPE = "openid email profile"
|
||||
DEFAULT_SESSION_TTL = 3600
|
||||
DEFAULT_ADMIN_VALUES = ("admin",)
|
||||
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
@@ -46,6 +58,13 @@ def _bool(name: str, default: bool) -> bool:
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _csv(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
host: str
|
||||
@@ -56,6 +75,29 @@ class Config:
|
||||
# Internal apps often send X-Frame-Options, which would block the iframe.
|
||||
strip_frame_options: bool
|
||||
|
||||
# OIDC / session settings. All optional: without issuer + client id +
|
||||
# secret the dashboard stays in unauthenticated LAN mode.
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
oidc_scope: str = DEFAULT_OIDC_SCOPE
|
||||
oidc_admin_claim: Optional[str] = None
|
||||
oidc_admin_values: tuple[str, ...] = DEFAULT_ADMIN_VALUES
|
||||
session_secret: Optional[str] = None
|
||||
session_ttl_seconds: int = DEFAULT_SESSION_TTL
|
||||
users_file: Optional[Path] = None
|
||||
public_url: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.users_file is None:
|
||||
object.__setattr__(self, "users_file", self.data_dir / "users.json")
|
||||
if self.oidc_issuer:
|
||||
object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/"))
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
return bool(self.oidc_issuer and self.oidc_client_id and self.oidc_client_secret)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
data_dir = Path(os.environ.get("UWUDASH_DATA_DIR", DEFAULT_DATA_DIR))
|
||||
@@ -69,4 +111,16 @@ class Config:
|
||||
services_file=services_file,
|
||||
proxy_timeout=_float("UWUDASH_PROXY_TIMEOUT", DEFAULT_PROXY_TIMEOUT),
|
||||
strip_frame_options=_bool("UWUDASH_STRIP_FRAME_OPTIONS", True),
|
||||
oidc_issuer=os.environ.get("UWUDASH_OIDC_ISSUER") or None,
|
||||
oidc_client_id=os.environ.get("UWUDASH_OIDC_CLIENT_ID") or None,
|
||||
oidc_client_secret=os.environ.get("UWUDASH_OIDC_CLIENT_SECRET") or None,
|
||||
oidc_scope=os.environ.get("UWUDASH_OIDC_SCOPE", DEFAULT_OIDC_SCOPE),
|
||||
oidc_admin_claim=os.environ.get("UWUDASH_OIDC_ADMIN_CLAIM") or None,
|
||||
oidc_admin_values=_csv("UWUDASH_OIDC_ADMIN_VALUES", DEFAULT_ADMIN_VALUES),
|
||||
session_secret=os.environ.get("UWUDASH_SESSION_SECRET") or None,
|
||||
session_ttl_seconds=_int("UWUDASH_SESSION_TTL", DEFAULT_SESSION_TTL),
|
||||
users_file=Path(
|
||||
os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json")
|
||||
),
|
||||
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
|
||||
)
|
||||
@@ -19,6 +19,14 @@ def parse_args(argv=None) -> argparse.Namespace:
|
||||
parser.add_argument("--data-dir", help="directory holding services.json")
|
||||
parser.add_argument("--services-file", help="path to the services JSON file")
|
||||
parser.add_argument("--reload", action="store_true", help="restart on source changes")
|
||||
parser.add_argument(
|
||||
"--oidc-issuer",
|
||||
help="OIDC issuer URL (client id/secret come from UWUDASH_OIDC_CLIENT_* env vars)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--public-url",
|
||||
help="External base URL of the dashboard, used for the OIDC redirect_uri",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -34,6 +42,10 @@ def build_config(args: argparse.Namespace) -> Config:
|
||||
config = replace(config, services_file=Path(args.services_file))
|
||||
elif args.data_dir:
|
||||
config = replace(config, services_file=Path(args.data_dir) / "services.json")
|
||||
if args.oidc_issuer:
|
||||
config = replace(config, oidc_issuer=args.oidc_issuer)
|
||||
if args.public_url:
|
||||
config = replace(config, public_url=args.public_url)
|
||||
return config
|
||||
|
||||
|
||||
@@ -41,4 +53,11 @@ def main(argv=None) -> None:
|
||||
args = parse_args(argv)
|
||||
config = build_config(args)
|
||||
print(f"UwuDash MK3 listening on http://{config.host}:{config.port}")
|
||||
if config.auth_enabled:
|
||||
print(f"OIDC sign-in enabled (issuer: {config.oidc_issuer})")
|
||||
else:
|
||||
print(
|
||||
"Authentication disabled - set UWUDASH_OIDC_ISSUER, "
|
||||
"UWUDASH_OIDC_CLIENT_ID and UWUDASH_OIDC_CLIENT_SECRET to enable it"
|
||||
)
|
||||
uvicorn.run(create_app(config), host=config.host, port=config.port, reload=args.reload)
|
||||
@@ -117,3 +117,36 @@ class ServicePatch(BaseModel):
|
||||
if value not in OPEN_MODES:
|
||||
raise ValueError(f"open_in must be one of {OPEN_MODES}")
|
||||
return value
|
||||
|
||||
|
||||
ROLES = ("admin", "viewer")
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""A dashboard user, provisioned locally on first OIDC login."""
|
||||
|
||||
id: str
|
||||
subject: str
|
||||
email: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
role: str = "viewer"
|
||||
created_at: str
|
||||
last_login_at: str
|
||||
|
||||
@field_validator("role")
|
||||
def _validate_role(cls, value: str) -> str:
|
||||
if value not in ROLES:
|
||||
raise ValueError(f"role must be one of {ROLES}")
|
||||
return value
|
||||
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
"""Role change payload for ``PATCH /api/users/{user_id}``."""
|
||||
|
||||
role: str
|
||||
|
||||
@field_validator("role")
|
||||
def _validate_role(cls, value: str) -> str:
|
||||
if value not in ROLES:
|
||||
raise ValueError(f"role must be one of {ROLES}")
|
||||
return value
|
||||
@@ -40,12 +40,26 @@ RESPONSE_BLOCKED = {
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length", # recomputed by the response class
|
||||
# httpx transparently decodes gzip/deflate/br before we see the body, so
|
||||
# forwarding the upstream Content-Encoding would describe a body that is
|
||||
# already decompressed (browsers then fail with a decoding error).
|
||||
"content-encoding",
|
||||
}
|
||||
|
||||
MAX_BODY_BYTES = 10 * 1024 * 1024
|
||||
|
||||
_FRAME_ANCESTORS = re.compile(r"frame-ancestors\s*=?[^;]*;?")
|
||||
|
||||
# Root-relative URLs in upstream HTML resolve against the *dashboard* origin,
|
||||
# not the upstream host, so every quoted "/..." value (attributes and inline
|
||||
# app config such as Sonarr's `urlBase: '/sonarr'`) has to be pulled back onto
|
||||
# the proxy prefix or the framed app 404s its own assets.
|
||||
#
|
||||
# The value class excludes `<`, `>` and whitespace: without them a closing
|
||||
# quote followed by a self-closing slash (`href="x"/><meta name="`) matches as
|
||||
# a "path" and the rewrite splices the prefix into the markup.
|
||||
_ROOT_RELATIVE = re.compile(r'(["\'])(/[^"\'<>\s]*)\1')
|
||||
|
||||
|
||||
class Proxy:
|
||||
def __init__(self, store: ServiceStore, config: Config) -> None:
|
||||
@@ -57,8 +71,8 @@ class Proxy:
|
||||
headers={"user-agent": "uwudash-mk3/0.1"},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.client.close()
|
||||
async def close(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
@staticmethod
|
||||
@@ -109,12 +123,32 @@ class Proxy:
|
||||
return prefix + location
|
||||
return location
|
||||
|
||||
def _rewrite_html(self, text: str, service: Service) -> str:
|
||||
def _rewrite_text(
|
||||
self, text: str, service: Service, *, base_only: bool = False
|
||||
) -> str:
|
||||
"""Rewrite absolute origin URLs and root-relative ``"/..."`` values
|
||||
onto the proxy prefix.
|
||||
|
||||
``base_only`` (used for JSON) limits the rewrite to values under the
|
||||
service's own base path: JSON also carries plain data such as
|
||||
filesystem paths, which must not be touched.
|
||||
"""
|
||||
prefix = self._prefix(service)
|
||||
return text.replace(service.origin, prefix).replace(
|
||||
base = f"/{service.base_path.strip('/')}"
|
||||
text = text.replace(service.origin, prefix).replace(
|
||||
f"//{self._netloc(service)}", prefix
|
||||
)
|
||||
|
||||
def pull_back(match: re.Match) -> str:
|
||||
quote, value = match.groups()
|
||||
if value.startswith(prefix): # already rewritten by the origin pass
|
||||
return match.group(0)
|
||||
if base_only and not value.startswith(base):
|
||||
return match.group(0)
|
||||
return f"{quote}{prefix}{value}{quote}"
|
||||
|
||||
return _ROOT_RELATIVE.sub(pull_back, text)
|
||||
|
||||
# --------------------------------------------------------------- handler
|
||||
async def handle(self, service_id: str, path: str, request: Request) -> Response:
|
||||
service = self.store.get(service_id)
|
||||
@@ -150,13 +184,18 @@ class Proxy:
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
headers = self._response_headers(upstream, service)
|
||||
|
||||
if content_type.startswith("text/html"):
|
||||
html = content_type.startswith("text/html")
|
||||
json_data = content_type.startswith("application/json")
|
||||
if html or json_data:
|
||||
await upstream.aread()
|
||||
rewritten = self._rewrite_text(
|
||||
upstream.text, service, base_only=not html
|
||||
)
|
||||
return Response(
|
||||
content=self._rewrite_html(upstream.text, service),
|
||||
content=rewritten,
|
||||
status_code=upstream.status_code,
|
||||
headers=headers,
|
||||
media_type="text/html; charset=utf-8",
|
||||
media_type=content_type,
|
||||
)
|
||||
|
||||
async def stream():
|
||||
|
||||
@@ -25,9 +25,13 @@ const el = {
|
||||
enabled: document.getElementById("f-enabled"),
|
||||
formCancel: document.getElementById("form-cancel"),
|
||||
toast: document.getElementById("toast"),
|
||||
userChip: document.getElementById("user-chip"),
|
||||
userName: document.getElementById("user-name"),
|
||||
userRole: document.getElementById("user-role"),
|
||||
userTable: document.getElementById("user-table"),
|
||||
};
|
||||
|
||||
const state = { services: [], selected: null, editing: null };
|
||||
const state = { services: [], selected: null, editing: null, user: null };
|
||||
|
||||
/* ---------------------------------------------------------------- helpers */
|
||||
const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
@@ -124,6 +128,58 @@ function findService(id) {
|
||||
return state.services.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- auth */
|
||||
async function loadUser() {
|
||||
const response = await fetch("/api/me");
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null; // auth disabled: /api/me is not mounted
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderUser() {
|
||||
const user = state.user;
|
||||
if (!user) return;
|
||||
el.userChip.hidden = false;
|
||||
el.userName.textContent = user.name || user.email || user.subject;
|
||||
el.userRole.textContent = user.role;
|
||||
el.userRole.className = `role-badge ${user.role}`;
|
||||
if (user.role !== "admin") el.settingsToggle.hidden = true;
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
if (!state.user || state.user.role !== "admin") return;
|
||||
let users;
|
||||
try {
|
||||
users = await api("/api/users");
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
return;
|
||||
}
|
||||
el.userTable.hidden = false;
|
||||
el.userTable.innerHTML =
|
||||
'<h2>Users</h2>' +
|
||||
(users.length
|
||||
? users
|
||||
.map((user) => {
|
||||
const label = esc(user.name || user.email || user.subject);
|
||||
return (
|
||||
`<div class="user-row">` +
|
||||
`<div class="meta"><strong>${label}</strong>` +
|
||||
`<code>${esc(user.subject)}</code></div>` +
|
||||
`<select data-user-role="${esc(user.id)}">` +
|
||||
`<option value="admin"${user.role === "admin" ? " selected" : ""}>admin</option>` +
|
||||
`<option value="viewer"${user.role === "viewer" ? " selected" : ""}>viewer</option>` +
|
||||
`</select>` +
|
||||
`</div>`
|
||||
);
|
||||
})
|
||||
.join("")
|
||||
: '<p class="status">No users yet.</p>');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- actions */
|
||||
function openService(service) {
|
||||
state.selected = service.id;
|
||||
@@ -160,6 +216,7 @@ function showSettings() {
|
||||
el.viewer.hidden = true;
|
||||
el.viewerFrame.src = "";
|
||||
renderTable();
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
@@ -269,4 +326,28 @@ el.reloadBtn.addEventListener("click", () => {
|
||||
if (el.viewerFrame.src) el.viewerFrame.src = el.viewerFrame.src;
|
||||
});
|
||||
|
||||
refresh().catch((error) => toast(error.message, "error"));
|
||||
el.userTable.addEventListener("change", async (event) => {
|
||||
const select = event.target.closest("select[data-user-role]");
|
||||
if (!select) return;
|
||||
try {
|
||||
const updated = await api(`/api/users/${select.dataset.userRole}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ role: select.value }),
|
||||
});
|
||||
toast(`${updated.name || updated.subject} is now ${updated.role}`);
|
||||
if (updated.id === state.user.id) {
|
||||
state.user = updated;
|
||||
renderUser();
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
loadUser()
|
||||
.then((user) => {
|
||||
state.user = user;
|
||||
renderUser();
|
||||
return refresh();
|
||||
})
|
||||
.catch((error) => toast(error.message, "error"));
|
||||
@@ -75,6 +75,28 @@ body {
|
||||
|
||||
.sidebar-foot { margin-top: 12px; }
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-name { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.role-badge {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.role-badge.admin { color: var(--success); border-color: var(--success); }
|
||||
.user-chip .btn { margin-left: auto; }
|
||||
|
||||
/* ------------------------------------------------------------------- main */
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
@@ -156,6 +178,47 @@ body {
|
||||
.status.ok { color: var(--success); }
|
||||
.status.bad { color: var(--error); }
|
||||
|
||||
/* ------------------------------------------------------------------ auth */
|
||||
.login {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.login-card { max-width: 380px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.login-card p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.login-error {
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-table { display: flex; flex-direction: column; gap: 10px; }
|
||||
.user-table h2 { margin: 0; font-size: 16px; }
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.user-row .meta strong { display: block; }
|
||||
.user-row .meta code { color: var(--muted); font-size: 12px; }
|
||||
.user-row select {
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ btns */
|
||||
.btn {
|
||||
font: inherit;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<div class="service-list" id="service-list"></div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div class="user-chip" id="user-chip" hidden>
|
||||
<span class="user-name" id="user-name"></span>
|
||||
<span class="role-badge" id="user-role"></span>
|
||||
<a class="btn ghost" href="/auth/logout">Sign out</a>
|
||||
</div>
|
||||
<button class="btn ghost" id="settings-toggle">Settings</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -79,6 +84,7 @@
|
||||
</form>
|
||||
|
||||
<div class="service-table" id="service-table"></div>
|
||||
<div class="user-table" id="user-table" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UwuDash MK3 - sign in</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<div class="login-card card">
|
||||
<div class="brand">
|
||||
<span class="brand-name">UwuDash</span>
|
||||
<span class="brand-sub">mk3</span>
|
||||
</div>
|
||||
<p>Sign in with your organisation account to open the dashboard.</p>
|
||||
{% if error %}
|
||||
<p class="login-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<a class="btn" href="/auth/authorize">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Persistence for dashboard users.
|
||||
|
||||
Same shape as :mod:`store`: a plain JSON file, atomic writes (temp file +
|
||||
``os.replace``) and a lock so concurrent logins cannot interleave. Users are
|
||||
created on first OIDC login and refreshed on every subsequent one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .models import User, slugify
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class UserStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.path.exists():
|
||||
self._write([])
|
||||
|
||||
# ------------------------------------------------------------------ read
|
||||
def _read(self) -> List[User]:
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except json.JSONDecodeError as exc: # pragma: no cover - corrupt file is loud
|
||||
raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc
|
||||
items = raw.get("users", []) if isinstance(raw, dict) else raw
|
||||
return [User.model_validate(item) for item in items]
|
||||
|
||||
def _write(self, users: List[User]) -> None:
|
||||
payload = {"version": 1, "users": [u.model_dump() for u in users]}
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
def list(self) -> List[User]:
|
||||
with self._lock:
|
||||
return self._read()
|
||||
|
||||
def get(self, user_id: str) -> Optional[User]:
|
||||
with self._lock:
|
||||
for user in self._read():
|
||||
if user.id == user_id:
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_by_subject(self, subject: str) -> Optional[User]:
|
||||
with self._lock:
|
||||
for user in self._read():
|
||||
if user.subject == subject:
|
||||
return user
|
||||
return None
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._read())
|
||||
|
||||
# ----------------------------------------------------------------- write
|
||||
def upsert(
|
||||
self,
|
||||
subject: str,
|
||||
email: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
role: str = "viewer",
|
||||
) -> User:
|
||||
"""Create a user, or refresh an existing one on every login.
|
||||
|
||||
``role`` only applies to brand-new users: an existing admin is never
|
||||
demoted by a login, promotion/demotion goes through ``set_role``.
|
||||
"""
|
||||
with self._lock:
|
||||
users = self._read()
|
||||
now = _now()
|
||||
for user in users:
|
||||
if user.subject == subject:
|
||||
user.email = email or user.email
|
||||
user.name = name or user.name
|
||||
user.last_login_at = now
|
||||
self._write(users)
|
||||
return user
|
||||
|
||||
base = slugify(subject)
|
||||
candidate = base
|
||||
suffix = 1
|
||||
existing = {u.id for u in users}
|
||||
while candidate in existing:
|
||||
suffix += 1
|
||||
candidate = f"{base}-{suffix}"
|
||||
|
||||
user = User(
|
||||
id=candidate,
|
||||
subject=subject,
|
||||
email=email,
|
||||
name=name,
|
||||
role=role,
|
||||
created_at=now,
|
||||
last_login_at=now,
|
||||
)
|
||||
users.append(user)
|
||||
self._write(users)
|
||||
return user
|
||||
|
||||
def set_role(self, user_id: str, role: str) -> Optional[User]:
|
||||
with self._lock:
|
||||
users = self._read()
|
||||
for user in users:
|
||||
if user.id == user_id:
|
||||
user.role = role
|
||||
self._write(users)
|
||||
return user
|
||||
return None
|
||||
@@ -33,6 +33,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "joserfc" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/dc/087c08741a98d234c9b98beef8a28d3c309f814d14b6156422c5815bf6d6/authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974", size = 182700, upload-time = "2026-10-14T14:17:52.307Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/44/a46089266de32a0d809023b6bf31b80a1f26a46954446a7ce695a565cbea/authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b", size = 266042, upload-time = "2026-10-14T14:17:50.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.23"
|
||||
@@ -42,6 +55,104 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
@@ -51,6 +162,75 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.162.0"
|
||||
@@ -213,6 +393,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -225,6 +414,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.7.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/15/48ff246732232d35ee234228def22b52455fcc55577f06146602399a29b0/joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510", size = 237446, upload-time = "2026-10-19T00:30:29.974Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/9f/934db284aa22f37af59708e382903860d4b81329c137b1b0ed2745daeb65/joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c", size = 71758, upload-time = "2026-10-19T00:30:28.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.4"
|
||||
@@ -365,6 +566,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.14.1"
|
||||
@@ -689,16 +899,20 @@ name = "uwudash-mk3"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "authlib", specifier = ">=1.9.1" },
|
||||
{ name = "fastapi", specifier = ">=0.162.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "itsdangerous", specifier = ">=2.2" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user