diff --git a/.env.example b/.env.example index 6910cb0f..6e9627bb 100644 --- a/.env.example +++ b/.env.example @@ -33,4 +33,18 @@ THALIA_COVER_SEARCH_ENABLED=false # For research only. Do not use in production # TLS / reverse proxy settings # Forwarded headers are trusted only from matching IPs. "*" trusts all proxies. -FORWARDED_ALLOW_IPS=* \ No newline at end of file +FORWARDED_ALLOW_IPS=* + +# Password reset email settings (SMTP) +# For local development, use Mailpit: mail_server=mailpit mail_port=1025 mail_username="" mail_password="" mail_starttls=False mail_ssl_tls=False +# In docker-compose: mail_server=mailpit mail_port=1025 +MAIL_SERVER= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM= +MAIL_FROM_NAME=LibrisLog +MAIL_STARTTLS=True +MAIL_SSL_TLS=False +PUBLIC_APP_URL=http://localhost:5173 +PASSWORD_RESET_TOKEN_MAX_AGE=3600 \ No newline at end of file diff --git a/backend/alembic/versions/784de5d2bf69_add_credentials_version_to_user.py b/backend/alembic/versions/784de5d2bf69_add_credentials_version_to_user.py new file mode 100644 index 00000000..7a1dbd8d --- /dev/null +++ b/backend/alembic/versions/784de5d2bf69_add_credentials_version_to_user.py @@ -0,0 +1,26 @@ +"""add_credentials_version_to_user + +Revision ID: 784de5d2bf69 +Revises: 1a2b3c4d5e6f +Create Date: 2026-06-22 10:01:24.732359 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "784de5d2bf69" +down_revision: Union[str, Sequence[str], None] = "1a2b3c4d5e6f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("user", sa.Column("credentials_version", sa.Integer(), nullable=False, server_default="0")) + + +def downgrade() -> None: + op.drop_column("user", "credentials_version") diff --git a/backend/app/auth.py b/backend/app/auth.py index 2a5a39a3..e1fbc294 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -14,6 +14,8 @@ from passlib.context import CryptContext from sqlmodel import Session, select +from itsdangerous import URLSafeTimedSerializer + from app.config import settings from app.database import get_session from app.models import ApiKey, User, UserRole @@ -140,6 +142,37 @@ def get_embed_token_prefix(token: str) -> str: return token[:12] +# --- Password reset token utilities --- + +_password_reset_serializer = URLSafeTimedSerializer( + secret_key=settings.api_key_encryption_key, + salt="password-reset", +) + + +def generate_password_reset_token(email: str, credentials_version: int = 0) -> str: + """Generate a signed, time-limited token for resetting a user's password.""" + return _password_reset_serializer.dumps({ + "email": email, + "credentials_version": credentials_version, + }) + + +def verify_password_reset_token(token: str, max_age: int = 3600) -> dict | None: + """Verify a password reset token and return the payload. + + Returns a dict with 'email' and 'credentials_version' if the token is valid + and not expired, otherwise None. + """ + try: + result = _password_reset_serializer.loads(token, max_age=max_age) + if isinstance(result, dict) and "email" in result and "credentials_version" in result: + return result + return None + except Exception: + return None + + api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) @@ -191,6 +224,13 @@ def require_user( if user_id is not None: user = session.get(User, user_id) if user is not None: + session_cv = request.session.get("credentials_version", 0) + if user.credentials_version != session_cv: + request.session.clear() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session expired, please log in again", + ) if request.method in {"POST", "PUT", "PATCH", "DELETE"}: csrf_token = request.session.get("csrf_token") if not csrf_token or not x_csrf_token or x_csrf_token != csrf_token: @@ -210,10 +250,11 @@ def require_admin(user: User = Depends(require_user)) -> User: return user -def start_browser_session(request: Request, user_id: int) -> None: +def start_browser_session(request: Request, user_id: int, credentials_version: int = 0) -> None: """Start an authenticated browser session by storing user_id and a CSRF token.""" request.session["user_id"] = user_id request.session["csrf_token"] = secrets.token_urlsafe(32) + request.session["credentials_version"] = credentials_version def clear_browser_session(request: Request) -> None: diff --git a/backend/app/config.py b/backend/app/config.py index 708d4900..ebf8adeb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -42,6 +42,16 @@ class Settings(BaseSettings): hardcover_app_api_token: str = "" thalia_cover_search_enabled: bool = False embed_enabled: bool = True + mail_server: str = "" + mail_port: int = 587 + mail_username: str = "" + mail_password: str = "" + mail_from: str = "" + mail_from_name: str = "LibrisLog" + mail_starttls: bool = True + mail_ssl_tls: bool = False + password_reset_token_max_age: int = 3600 + public_app_url: str = "http://localhost:5173" forwarded_allow_ips: str = "*" @field_validator("api_key_encryption_key") diff --git a/backend/app/email.py b/backend/app/email.py new file mode 100644 index 00000000..4687e4ab --- /dev/null +++ b/backend/app/email.py @@ -0,0 +1,46 @@ +"""Email sending utilities using fastapi-mail.""" + +import logging + +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType + +from app.config import settings +from app.i18n import translate + +logger = logging.getLogger(__name__) + + +def _connection_config() -> ConnectionConfig: + return ConnectionConfig( + MAIL_USERNAME=settings.mail_username, + MAIL_PASSWORD=settings.mail_password, + MAIL_FROM=settings.mail_from, + MAIL_PORT=settings.mail_port, + MAIL_SERVER=settings.mail_server, + MAIL_FROM_NAME=settings.mail_from_name, + MAIL_STARTTLS=settings.mail_starttls, + MAIL_SSL_TLS=settings.mail_ssl_tls, + USE_CREDENTIALS=bool(settings.mail_username) and bool(settings.mail_password), + VALIDATE_CERTS=True, + ) + + +async def send_password_reset_email(recipient: str, reset_url: str, locale: str = "en") -> None: + conf = _connection_config() + duration_minutes = settings.password_reset_token_max_age // 60 + message = MessageSchema( + subject=translate("email.passwordResetSubject", locale=locale), + recipients=[recipient], + body=translate( + "email.passwordResetBody", + locale=locale, + duration_minutes=str(duration_minutes), + reset_url=reset_url, + ), + subtype=MessageType.html, + ) + fm = FastMail(conf) + try: + await fm.send_message(message) + except Exception: + logger.exception("Failed to send password reset email to %s", recipient) diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py new file mode 100644 index 00000000..a0b18508 --- /dev/null +++ b/backend/app/i18n/__init__.py @@ -0,0 +1,32 @@ +"""Simple backend i18n using locale JSON files.""" + +import json +from functools import lru_cache +from pathlib import Path + + +@lru_cache(maxsize=32) +def _load_translations(locale: str) -> dict: + i18n_dir = Path(__file__).resolve().parent + path = i18n_dir / f"{locale}.json" + if not path.exists(): + path = i18n_dir / "en.json" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def translate(key: str, locale: str = "en", **kwargs: str) -> str: + """Resolve a dot-separated key in the locale JSON and interpolate {placeholders}.""" + translations = _load_translations(locale) + parts = key.split(".") + value: object = translations + for part in parts: + if isinstance(value, dict): + value = value.get(part, "") + else: + value = "" + if not isinstance(value, str): + value = "" + if kwargs: + value = value.format(**kwargs) + return value diff --git a/backend/app/i18n/de.json b/backend/app/i18n/de.json index 8bf72d56..30ef7d52 100644 --- a/backend/app/i18n/de.json +++ b/backend/app/i18n/de.json @@ -8,5 +8,9 @@ "pages": "Seiten", "avg_pages": "Seiten/Buch" } + }, + "email": { + "passwordResetSubject": "Passwort zurücksetzen – LibrisLog", + "passwordResetBody": "\n\n

Du hast das Zurücksetzen deines Passworts für dein LibrisLog-Konto beantragt.

\n

Klicke auf den Link unten, um dein Passwort zurückzusetzen. Dieser Link ist {duration_minutes} Minuten gültig.

\n

{reset_url}

\n

Falls du dies nicht angefordert hast, ignoriere bitte diese E-Mail.

\n\n" } } diff --git a/backend/app/i18n/en.json b/backend/app/i18n/en.json index 12651e10..2bf70410 100644 --- a/backend/app/i18n/en.json +++ b/backend/app/i18n/en.json @@ -8,5 +8,9 @@ "pages": "Pages", "avg_pages": "Avg/Book" } + }, + "email": { + "passwordResetSubject": "Password Reset – LibrisLog", + "passwordResetBody": "\n\n

You have requested a password reset for your LibrisLog account.

\n

Click the link below to reset your password. This link is valid for {duration_minutes} minutes.

\n

{reset_url}

\n

If you did not request this, please ignore this email.

\n\n" } } diff --git a/backend/app/i18n/es.json b/backend/app/i18n/es.json index 87048454..edc47b8e 100644 --- a/backend/app/i18n/es.json +++ b/backend/app/i18n/es.json @@ -8,5 +8,9 @@ "pages": "Páginas", "avg_pages": "Páginas/Libro" } + }, + "email": { + "passwordResetSubject": "Restablecer contraseña – LibrisLog", + "passwordResetBody": "\n\n

Has solicitado un restablecimiento de contraseña para tu cuenta de LibrisLog.

\n

Haz clic en el enlace de abajo para restablecer tu contraseña. Este enlace es válido por {duration_minutes} minutos.

\n

{reset_url}

\n

Si no solicitaste esto, ignora este correo electrónico.

\n\n" } } diff --git a/backend/app/i18n/fr.json b/backend/app/i18n/fr.json index 21bb30b2..d1bdee09 100644 --- a/backend/app/i18n/fr.json +++ b/backend/app/i18n/fr.json @@ -8,5 +8,9 @@ "pages": "Pages", "avg_pages": "Pages/Livre" } + }, + "email": { + "passwordResetSubject": "Réinitialisation du mot de passe – LibrisLog", + "passwordResetBody": "\n\n

Vous avez demandé une réinitialisation de mot de passe pour votre compte LibrisLog.

\n

Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien est valable {duration_minutes} minutes.

\n

{reset_url}

\n

Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.

\n\n" } } diff --git a/backend/app/i18n/zh.json b/backend/app/i18n/zh.json index c757466a..1ade7c15 100644 --- a/backend/app/i18n/zh.json +++ b/backend/app/i18n/zh.json @@ -8,5 +8,9 @@ "pages": "页数", "avg_pages": "每本页数" } + }, + "email": { + "passwordResetSubject": "密码重置 – LibrisLog", + "passwordResetBody": "\n\n

您已请求重置 LibrisLog 帐户的密码。

\n

点击下面的链接重置您的密码。此链接有效期为 {duration_minutes} 分钟。

\n

{reset_url}

\n

如果您没有请求此操作,请忽略此邮件。

\n\n" } } diff --git a/backend/app/main.py b/backend/app/main.py index 5674e95a..79638caa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -66,6 +66,12 @@ async def lifespan(app: FastAPI): Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True) cleanup_temp_files() + if not settings.mail_server.strip() or not settings.mail_from.strip(): + logger.warning( + "MAIL_SERVER or MAIL_FROM is not configured — password reset emails will not be sent. " + "See .env.example for mail setup instructions." + ) + maintenance_task = asyncio.create_task(_periodic_maintenance()) yield maintenance_task.cancel() diff --git a/backend/app/models.py b/backend/app/models.py index 6668665f..ade3412a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -127,6 +127,10 @@ class User(SQLModel, table=True): email: str = Field(index=True, unique=True) role: UserRole = Field(default=UserRole.user, index=True) hashed_password: str + credentials_version: int = Field( + default=0, + sa_column=Column(sa.Integer, default=0, nullable=False) + ) created_at: datetime = Field( default_factory=utcnow, sa_column=Column(UtcDateTime, default=utcnow) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3ac97f87..de90bea0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,19 +1,30 @@ -"""Authentication endpoints — login, logout, setup, session, and CSRF.""" +"""Authentication endpoints — login, logout, setup, session, CSRF, and password reset.""" -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status from sqlmodel import Session, select from app.auth import ( clear_browser_session, ensure_password_complexity, + generate_password_reset_token, get_password_hash, require_user, start_browser_session, + verify_password_reset_token, verify_password, ) +from app.config import settings from app.database import get_session +from app.email import send_password_reset_email from app.models import User, UserRole, UserSettings -from app.schemas import SetupRequest, UserLogin, UserRead +from app.schemas import ( + ForgotPasswordRequest, + ResetPasswordRequest, + SetupRequest, + UserLogin, + UserRead, +) +from app.time_utils import utcnow router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -56,7 +67,7 @@ def setup( session.add(UserSettings(user_id=user.id, language="en")) session.commit() - start_browser_session(http_request, user.id) + start_browser_session(http_request, user.id, user.credentials_version) return {"user": UserRead.model_validate(user)} @@ -71,7 +82,7 @@ def login( if not user or not verify_password(credentials.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password") - start_browser_session(http_request, user.id) + start_browser_session(http_request, user.id, user.credentials_version) return {"user": UserRead.model_validate(user)} @@ -95,3 +106,48 @@ def csrf_token(request: Request) -> dict: if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") return {"csrf_token": token} + + +@router.post("/forgot-password", status_code=status.HTTP_200_OK) +async def forgot_password( + body: ForgotPasswordRequest, + background_tasks: BackgroundTasks, + session: Session = Depends(get_session), +) -> dict: + """Send a password reset email to the user. + + Always returns 200 to prevent user enumeration. + """ + user = session.exec(select(User).where(User.email == body.email)).first() + if user and settings.mail_server: + token = generate_password_reset_token(body.email, user.credentials_version) + reset_url = f"{settings.public_app_url}/reset-password?token={token}" + background_tasks.add_task(send_password_reset_email, body.email, reset_url, body.locale) + return {"message": "If the email is registered, a reset link has been sent"} + + +@router.post("/reset-password", status_code=status.HTTP_200_OK) +def reset_password( + body: ResetPasswordRequest, + session: Session = Depends(get_session), +) -> dict: + """Reset a user's password using a valid reset token.""" + payload = verify_password_reset_token(body.token, max_age=settings.password_reset_token_max_age) + if not payload: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token", + ) + user = session.exec(select(User).where(User.email == payload["email"])).first() + if not user or user.credentials_version != payload["credentials_version"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token", + ) + ensure_password_complexity(body.password) + user.hashed_password = get_password_hash(body.password) + user.credentials_version += 1 + user.updated_at = utcnow() + session.add(user) + session.commit() + return {"message": "Password has been reset successfully"} diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index 3a9fc932..3690966e 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -133,7 +133,7 @@ async def import_book( # Attempt to download and cache the cover locally; fall back to external URL. cover_url = c.cover_url if cover_url: - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: filename = await download_cover(cover_url, settings.covers_dir, client, current_user.id) if filename: cover_url = f"/api/covers/{filename}" diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index c65ab975..fde68a4c 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -143,7 +143,7 @@ async def oidc_callback( logger.error("OIDC link points to missing user: link_id=%s user_id=%s", link.id, link.user_id) return _frontend_warning_redirect("Linked user account no longer exists") - start_browser_session(request, user.id) + start_browser_session(request, user.id, user.credentials_version) return _frontend_success_redirect() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 96c3b018..e44ec19c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -266,6 +266,18 @@ class UserLogin(SQLModel): password: str +class ForgotPasswordRequest(SQLModel): + """Request body to request a password reset email.""" + email: str + locale: str = "en" + + +class ResetPasswordRequest(SQLModel): + """Request body to reset a password using a token.""" + token: str + password: str + + class SetupRequest(SQLModel): """Initial admin setup request body.""" firstname: str diff --git a/backend/app/services/cover_import.py b/backend/app/services/cover_import.py index b3ce7c8d..27d7db16 100644 --- a/backend/app/services/cover_import.py +++ b/backend/app/services/cover_import.py @@ -88,5 +88,5 @@ async def import_cover_from_url( Returns: The local filename on success, or None on failure. """ - async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=False) as client: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: return await download_cover(url, covers_dir, client, user_id) diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 2516669e..6605a245 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -682,7 +682,7 @@ async def execute_import( transform_cache = _build_transform_cache(mapping) - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: for idx, row in enumerate(rows, start=1): try: row_transform_errors: list[str] = [] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4cd6ad4d..cb66ec5a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "cachetools>=5.3.3", "cryptography>=46.0.3", "curl-cffi>=0.15.0", + "fastapi-mail>=1.4.2", "fastapi>=0.136.1", "httpx>=0.28.1", "itsdangerous>=2.2.0", diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 75300431..3c2f0221 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,6 +15,9 @@ services: REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment restart: unless-stopped + depends_on: + mailpit: + condition: service_started frontend: build: @@ -26,3 +29,10 @@ services: ports: - "8001:80" restart: unless-stopped + + mailpit: + image: axllent/mailpit:latest + ports: + - "1025:1025" + - "8025:8025" + restart: unless-stopped diff --git a/docs/api/index.md b/docs/api/index.md index 4848d730..53b8bee5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -103,6 +103,42 @@ curl -X POST \ | GET | `/api/import/search/stream` | Stream search progress | | POST | `/api/import` | Import a candidate | +### Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/auth/setup` | Create first admin (only when no admin exists) | +| POST | `/api/auth/login` | Log in with email and password | +| POST | `/api/auth/logout` | Log out (clear session) | +| GET | `/api/auth/me` | Get current user | +| GET | `/api/auth/csrf` | Get CSRF token | +| POST | `/api/auth/forgot-password` | Request a password reset email (always returns 200) | +| POST | `/api/auth/reset-password` | Reset password using a token from the reset email | + +::: details Password Reset Endpoints +These endpoints do not require an API key or session — they are public. + +**Forgot Password** + +```bash +curl -X POST http://localhost:8000/api/auth/forgot-password \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "locale": "en"}' +``` + +Always returns `200` with `{"message": "If the email is registered, a reset link has been sent"}` to prevent user enumeration. The `locale` field is optional (defaults to `en`) and controls the email language. + +**Reset Password** + +```bash +curl -X POST http://localhost:8000/api/auth/reset-password \ + -H "Content-Type: application/json" \ + -d '{"token": "token-from-email", "password": "new-secure-password"}' +``` + +Returns `200` on success, `400` if the token is invalid/expired or the password doesn't meet complexity requirements. After a successful reset, all existing sessions for that user are invalidated. +::: + ## Error Handling The API returns standard HTTP status codes: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b5078d83..d8292eb4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -30,6 +30,39 @@ All configuration is done via environment variables in a `.env` file at the proj | `OIDC_CLIENT_SECRET` | OIDC client secret | | `OIDC_WELL_KNOWN_URL` | OIDC well-known configuration URL | +## Password Reset / Email (Optional) + +Password reset is optional. If mail is not configured, the "Forgot password?" link on the login page still appears, but no reset email is sent. A warning is logged at startup when mail is not configured. + +| Variable | Description | Default | +|----------|-------------|---------| +| `MAIL_SERVER` | SMTP server hostname (e.g. `smtp.gmail.com`). Leave empty to disable password reset emails. | — | +| `MAIL_PORT` | SMTP server port | `587` | +| `MAIL_USERNAME` | SMTP username (leave empty for unauthenticated relay) | — | +| `MAIL_PASSWORD` | SMTP password | — | +| `MAIL_FROM` | Sender email address | — | +| `MAIL_FROM_NAME` | Sender display name | `LibrisLog` | +| `MAIL_STARTTLS` | Use STARTTLS (`True`/`False`) | `True` | +| `MAIL_SSL_TLS` | Use implicit SSL/TLS (`True`/`False`) | `False` | +| `PUBLIC_APP_URL` | Public URL of the frontend (used to build the reset link in emails) | `http://localhost:5173` | +| `PASSWORD_RESET_TOKEN_MAX_AGE` | Reset token validity in seconds | `3600` (1 hour) | + +::: tip Local Development +For local development, use [Mailpit](https://github.com/axllent/mailpit) (included in `docker-compose.dev.yml`): + +```bash +MAIL_SERVER=localhost +MAIL_PORT=1025 +MAIL_STARTTLS=False +MAIL_SSL_TLS=False +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=noreply@localhost +``` + +The Mailpit web UI is available at http://localhost:8025 to inspect sent emails. +::: + ## Book Import Sources | Variable | Description | @@ -99,4 +132,16 @@ DASHBOARD_QUOTE_CACHE_TTL=86400 PUBLIC_DEFAULT_LOCALE=en MAX_IMPORT_FILE_SIZE_MB=100 MAX_IMPORT_ROW_COUNT=10000 + +# Password reset (optional — leave empty to disable) +MAIL_SERVER= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM= +MAIL_FROM_NAME=LibrisLog +MAIL_STARTTLS=True +MAIL_SSL_TLS=False +PUBLIC_APP_URL=http://localhost:5173 +PASSWORD_RESET_TOKEN_MAX_AGE=3600 ``` \ No newline at end of file diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md index 2f7e2bbd..9f76e08d 100644 --- a/docs/guide/developer-setup.md +++ b/docs/guide/developer-setup.md @@ -12,6 +12,27 @@ docker compose -f docker-compose.dev.yml up -d --build This builds fresh images using your local checkout. The `docker-compose.dev.yml` file mirrors the default compose file but uses `build:` directives instead of pulling pre-built images. +### Mailpit (Email Testing) + +The dev compose file includes a [Mailpit](https://github.com/axllent/mailpit) service for testing password reset emails locally: + +- **SMTP**: `localhost:1025` (or `mailpit:1025` from within Docker) +- **Web UI**: http://localhost:8025 + +To enable password reset emails in dev, add these to your `.env`: + +```bash +MAIL_SERVER=localhost +MAIL_PORT=1025 +MAIL_STARTTLS=False +MAIL_SSL_TLS=False +MAIL_FROM=noreply@localhost +MAIL_USERNAME= +MAIL_PASSWORD= +``` + +If mail is not configured, a warning is logged at startup and the "Forgot password?" link on the login page will not send emails. + ### Build Arguments When building, you can override these arguments: diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 02fa02b5..1655bfd8 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -8,6 +8,12 @@ The profile page is your personal settings hub. Access it by clicking your avata Update your first name, last name, or password. The password field is optional — leave it blank to keep your current password. A password strength indicator and complexity requirements are shown below the input. +::: tip Forgot your password? +If you have forgotten your password and mail is configured, click the **"Forgot password?"** link on the login page. Enter your email address and a reset link will be sent to you. The link is valid for one hour and can only be used once. After a successful reset, all existing sessions are invalidated and you will need to log in again. + +This feature requires SMTP settings to be configured — see [Configuration](/guide/configuration#password-reset-email-optional). +::: + ## Language Switch the UI language between available locales. The change applies immediately after saving. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9378fd44..53c89443 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -130,6 +130,20 @@ export const api = { logout(): Promise<{ message: string }> { return request<{ message: string }>('/auth/logout', { method: 'POST' }); + }, + + forgotPassword(data: { email: string; locale?: string }): Promise<{ message: string }> { + return request<{ message: string }>('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + resetPassword(data: { token: string; password: string }): Promise<{ message: string }> { + return request<{ message: string }>('/auth/reset-password', { + method: 'POST', + body: JSON.stringify(data) + }); } }, diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index eaf87a62..c28b11e0 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -1,5 +1,6 @@ import { - Chart as ChartJS, + Chart, + _adapters, Title, Tooltip, Legend, @@ -7,12 +8,13 @@ import { LineElement, PointElement, CategoryScale, - LinearScale + LinearScale, + TimeScale } from 'chart.js'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; -ChartJS.register( +Chart.register( Title, Tooltip, Legend, @@ -21,9 +23,62 @@ ChartJS.register( PointElement, CategoryScale, LinearScale, + TimeScale, zoomPlugin, MatrixController, MatrixElement ); -export { ChartJS }; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import quarterOfYear from 'dayjs/plugin/quarterOfYear'; +import weekday from 'dayjs/plugin/weekday'; + +dayjs.extend(utc); +dayjs.extend(customParseFormat); +dayjs.extend(advancedFormat); +dayjs.extend(localizedFormat); +dayjs.extend(quarterOfYear); +dayjs.extend(weekday); + +const FORMATS: Record = { + datetime: 'MMM D, YYYY, h:mm:ss a', + millisecond: 'h:mm:ss.SSS a', + second: 'h:mm:ss a', + minute: 'h:mm a', + hour: 'hA', + day: 'MMM D', + week: 'll', + month: 'MMM YYYY', + quarter: '[Q]Q - YYYY', + year: 'YYYY' +}; + +_adapters._date.override({ + _create: (time: number | string | Date) => dayjs.utc(time).valueOf(), + formats: () => FORMATS, + parse: (value: unknown, format?: string) => { + if (value === null || value === undefined) return null; + if (typeof value === 'string' && format) { + const d = dayjs.utc(value, format); + return d.isValid() ? d.valueOf() : null; + } + const d = dayjs.utc(value as string | number | Date); + return d.isValid() ? d.valueOf() : null; + }, + format: (time: unknown, format: string) => dayjs.utc(time as number).format(format), + add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).add(amount, unit as dayjs.ManipulateType).valueOf(), + diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).diff(dayjs.utc(min as number), unit as dayjs.OpUnitType), + startOf: (time: unknown, unit: string, weekday?: number) => { + if (unit === 'isoWeek') { + return (dayjs.utc(time as number) as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); + } + return dayjs.utc(time as number).startOf(unit as dayjs.OpUnitType).valueOf(); + }, + endOf: (time: unknown, unit: string) => dayjs.utc(time as number).endOf(unit as dayjs.OpUnitType).valueOf(), +} as unknown as Parameters[0]); + +export { Chart as ChartJS }; diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 21929b5f..241399ca 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -224,11 +224,11 @@ }); const lineChartData = $derived.by(() => { - if (uniqueDays.length < 1) return { labels: [] as string[], data: [] as number[] }; + if (uniqueDays.length < 1) return { data: [] as Array<{ x: number; y: number }> }; const oldestEntry = uniqueDays[0]; const useStartDate = !!book?.date_started && formatDate(book.date_started, tz) < formatDate(oldestEntry.created_at, tz); const rawStart = useStartDate ? book.date_started : (book?.date_added ?? null); - if (!rawStart) return { labels: [] as string[], data: [] as number[] }; + if (!rawStart) return { data: [] as Array<{ x: number; y: number }> }; const virtualEntry: ReadingProgressEntry = { id: 0, book_id: book?.id ?? 0, @@ -238,8 +238,7 @@ }; const entries = [virtualEntry, ...uniqueDays]; return { - labels: entries.map((e) => formatDate(e.created_at, tz)), - data: entries.map((e) => e.page), + data: entries.map((e) => ({ x: new Date(e.created_at).getTime(), y: e.page })), }; }); @@ -255,11 +254,11 @@ const lineChartConfig = $derived.by>(() => { void _themeSignal; return { - labels: lineChartData.labels, datasets: [ { label: $_('book.currentPage'), data: lineChartData.data, + parsing: false, borderColor: getDaisyColorRgb('primary'), backgroundColor: getDaisyColorRgb('primary'), tension: 0.4, @@ -286,6 +285,11 @@ }, scales: { x: { + type: 'time', + time: { + displayFormats: { day: 'MMM D' }, + tooltipFormat: 'YYYY-MM-DD HH:mm', + }, grid: { display: false }, ticks: { maxTicksLimit: 6, @@ -307,6 +311,30 @@ }; }); + // ── Android back button: close drawer instead of navigating away ────────── + let _pushed = false; + let _popClosed = false; + + $effect(() => { + if (open) { + history.pushState(null, ''); + _pushed = true; + _popClosed = false; + + const onPop = () => { + _popClosed = true; + open = false; + }; + window.addEventListener('popstate', onPop); + return () => { + window.removeEventListener('popstate', onPop); + if (_pushed && !_popClosed) history.back(); + _pushed = false; + _popClosed = false; + }; + } + }); + $effect(() => { if (open && book) { confirmDelete = false; diff --git a/frontend/src/lib/components/BookDrawer.svelte b/frontend/src/lib/components/BookDrawer.svelte index 7efd59e3..2563f33a 100644 --- a/frontend/src/lib/components/BookDrawer.svelte +++ b/frontend/src/lib/components/BookDrawer.svelte @@ -60,6 +60,30 @@ let date_finished = $state(''); let cover_url = $state(null); + // ── Android back button: close drawer instead of navigating away ────────── + let _pushed = false; + let _popClosed = false; + + $effect(() => { + if (open) { + history.pushState(null, ''); + _pushed = true; + _popClosed = false; + + const onPop = () => { + _popClosed = true; + open = false; + }; + window.addEventListener('popstate', onPop); + return () => { + window.removeEventListener('popstate', onPop); + if (_pushed && !_popClosed) history.back(); + _pushed = false; + _popClosed = false; + }; + } + }); + $effect(() => { if (book) { title = book.title; diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index 54356ff1..0b412fb8 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -5,6 +5,8 @@ placeholder = '', name = '', disabled = false, + ariaLabel = '', + inputClass = 'input input-bordered w-full', fetchSuggestions = async (_q: string): Promise => [] }: { value?: string; @@ -12,6 +14,8 @@ placeholder?: string; name?: string; disabled?: boolean; + ariaLabel?: string; + inputClass?: string; fetchSuggestions?: (query: string) => Promise; } = $props(); @@ -119,7 +123,7 @@
| undefined; let updateInfo: UpdateInfo | null = $state(null); - const isPublicAuthRoute = $derived( - $page.url.pathname.startsWith('/setup') || - $page.url.pathname.startsWith('/login') || - $page.url.pathname.startsWith('/auth/oidc') - ); + + function _isPublicAuthRoute(pathname: string): boolean { + return ( + pathname.startsWith('/setup') || + pathname.startsWith('/login') || + pathname.startsWith('/reset-password') || + pathname.startsWith('/auth/oidc') + ); + } + + const isPublicAuthRoute = $derived(_isPublicAuthRoute($page.url.pathname)); const showAppChrome = $derived(!isPublicAuthRoute && $currentUser !== null); // Expose a way for pages to trigger open @@ -71,17 +77,16 @@ await setupI18n(); i18nReady = true; - // Wait for backend on login/setup/oidc routes so the page doesn't + // Wait for backend on public auth routes so the page doesn't // render before the server is ready (e.g. OIDC config fetch). const path = $page.url.pathname; - if (path.startsWith('/login') || path.startsWith('/setup') || path.startsWith('/auth/oidc')) { + const isPublic = _isPublicAuthRoute(path); + if (isPublic) { await waitForBackend(); } backendReady = true; const isSetupRoute = path.startsWith('/setup'); const isLoginRoute = path.startsWith('/login'); - const isOidcCallbackRoute = path.startsWith('/auth/oidc'); - const publicAuthRoute = isSetupRoute || isLoginRoute || isOidcCallbackRoute; try { const setup = await api.auth.setupRequired(); @@ -120,7 +125,7 @@ } } - if (!setup.required && !publicAuthRoute) { + if (!setup.required && !isPublic) { try { const me = await api.auth.me(); currentUser.set(me); @@ -148,7 +153,7 @@ } } } catch { - if (!publicAuthRoute) { + if (!isPublic) { csrfToken.set(null); window.location.href = '/login'; return; diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte index 31e7dfe8..82494e00 100644 --- a/frontend/src/routes/data-hygiene/+page.svelte +++ b/frontend/src/routes/data-hygiene/+page.svelte @@ -7,6 +7,7 @@ import Alert from '$lib/components/Alert.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import SuggestionInput from '$lib/components/SuggestionInput.svelte'; import { LoaderCircle, X } from '@lucide/svelte'; import type { Book, HygieneAttribute, HygieneMissingBook } from '$lib/types'; @@ -450,6 +451,24 @@ placeholder={$_('dataHygiene.batchValuePlaceholder')} aria-label={$_('dataHygiene.batchValueLabel')} /> + {:else if batchField === 'author'} + api.books.suggestions.authors(q)} + /> + {:else if batchField === 'publisher'} + api.books.suggestions.publishers(q)} + /> {:else} {/if} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index f7b18387..ae3b58a7 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -46,6 +46,12 @@ setLocale(next); } + let forgotEmail = $state(''); + let forgotLoading = $state(false); + let forgotSent = $state(false); + let forgotError = $state(''); + let forgotDialog: HTMLDialogElement | undefined = $state(); + async function submit() { error = ''; loading = true; @@ -87,6 +93,27 @@ } } + async function forgotSubmit() { + forgotError = ''; + forgotSent = false; + forgotLoading = true; + try { + await api.auth.forgotPassword({ email: forgotEmail, locale: selectedLanguage }); + forgotSent = true; + } catch (e: unknown) { + forgotError = e instanceof Error ? e.message : $_('auth.forgotError'); + } finally { + forgotLoading = false; + } + } + + function openForgotDialog() { + forgotEmail = email; + forgotSent = false; + forgotError = ''; + forgotDialog?.showModal(); + } + function startOidcLogin() { window.location.href = api.oidc.loginUrl(); } @@ -144,6 +171,11 @@ disabled={loading} /> +
+ +
@@ -157,3 +189,46 @@
+ + + + + diff --git a/frontend/src/routes/reset-password/+page.svelte b/frontend/src/routes/reset-password/+page.svelte new file mode 100644 index 00000000..82ccf87a --- /dev/null +++ b/frontend/src/routes/reset-password/+page.svelte @@ -0,0 +1,90 @@ + + +
+
+
+ {#if noToken} +
+ {$_('auth.resetPasswordMissingToken')} +
+ + {:else if success} +
+ {$_('auth.resetPasswordSuccess')} +
+ + {:else} +

{$_('auth.resetPasswordTitle')}

+

{$_('auth.resetPasswordInstruction')}

+ {#if error} + (error = '')}> + {error} + + {/if} +
{ e.preventDefault(); submit(); }}> + + + +
+ {/if} +
+
+
diff --git a/uv.lock b/uv.lock index e06fe833..d6795291 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,15 @@ members = [ "llc", ] +[[package]] +name = "aiosmtplib" +version = "5.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/25/d36d056e62a1dc3dd51ce76e7647c62966702193dc91eafa7fd4e1006a91/aiosmtplib-5.1.2.tar.gz", hash = "sha256:04a0ea3c678f5b719f998f290dce010ca512e1385836d3944206299df03b060f", size = 71031, upload-time = "2026-06-20T15:00:48.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/ec/c5a415cd1309eaac28ad3c599458194d25ba07189bb07a1bac2c6713c17e/aiosmtplib-5.1.2-py3-none-any.whl", hash = "sha256:070d467cc329dafd0af59108ba5d217d973cba10309910fed359a2a7bfb52d7a", size = 28394, upload-time = "2026-06-20T15:00:47.299Z" }, +] + [[package]] name = "alembic" version = "1.18.4" @@ -126,6 +135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "browserforge" version = "1.2.4" @@ -252,55 +270,52 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -345,6 +360,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -361,6 +398,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] +[[package]] +name = "fastapi-mail" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosmtplib" }, + { name = "blinker" }, + { name = "cryptography" }, + { name = "email-validator" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "regex" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/a5/0912d84fcbe50889c980c0c56ecf4c80821e6e077e68a98da946dc94714a/fastapi_mail-1.6.5.tar.gz", hash = "sha256:458d8d185ae27d2e5936dd58c6fb8667d4a413fd6aae32fac2f32014794da5d6", size = 14389, upload-time = "2026-06-18T07:14:29.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/51/1092424d804a6cde33ab5385cb3c5004802f240111494f389dcb52c5be09/fastapi_mail-1.6.5-py3-none-any.whl", hash = "sha256:de444ce87177b69e0de6b23ec899af97ca99d07d980b50273950b2c43b9e5e1b", size = 16219, upload-time = "2026-06-18T07:14:30.533Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -487,6 +546,18 @@ 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +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.6.5" @@ -542,6 +613,7 @@ dependencies = [ { name = "cryptography" }, { name = "curl-cffi" }, { name = "fastapi" }, + { name = "fastapi-mail" }, { name = "httpx" }, { name = "itsdangerous" }, { name = "passlib", extra = ["bcrypt"] }, @@ -572,6 +644,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=46.0.3" }, { name = "curl-cffi", specifier = ">=0.15.0" }, { name = "fastapi", specifier = ">=0.136.1" }, + { name = "fastapi-mail", specifier = ">=1.4.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, @@ -1035,6 +1108,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + [[package]] name = "restrictedpython" version = "8.1"