feat(phase-2): implement domain verification system
Implements complete domain verification flow with: - rel=me link verification service - HTML fetching with security controls - Rate limiting to prevent abuse - Email validation utilities - Authorization and verification API endpoints - User-facing templates for authorization and verification flows This completes Phase 2: Domain Verification as designed. Tests: - All Phase 2 unit tests passing - Coverage: 85% overall - Migration tests updated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ dependencies = [
|
|||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"dnspython>=2.4.0",
|
"dnspython>=2.4.0",
|
||||||
"aiosmtplib>=3.0.0",
|
"aiosmtplib>=3.0.0",
|
||||||
|
"beautifulsoup4>=4.12.0",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Validates required settings on startup and provides sensible defaults.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -32,8 +31,8 @@ class Config:
|
|||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
SMTP_HOST: str
|
SMTP_HOST: str
|
||||||
SMTP_PORT: int
|
SMTP_PORT: int
|
||||||
SMTP_USERNAME: Optional[str]
|
SMTP_USERNAME: str | None
|
||||||
SMTP_PASSWORD: Optional[str]
|
SMTP_PASSWORD: str | None
|
||||||
SMTP_FROM: str
|
SMTP_FROM: str
|
||||||
SMTP_USE_TLS: bool
|
SMTP_USE_TLS: bool
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ Provides database initialization, migration running, and health checks.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
@@ -37,7 +35,7 @@ class Database:
|
|||||||
database_url: SQLAlchemy database URL (e.g., sqlite:///./data/gondulf.db)
|
database_url: SQLAlchemy database URL (e.g., sqlite:///./data/gondulf.db)
|
||||||
"""
|
"""
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
self._engine: Optional[Engine] = None
|
self._engine: Engine | None = None
|
||||||
|
|
||||||
def ensure_database_directory(self) -> None:
|
def ensure_database_directory(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration 002: Add two_factor column to domains table
|
||||||
|
-- Adds two-factor verification method support for Phase 2
|
||||||
|
|
||||||
|
-- Add two_factor column with default value false
|
||||||
|
ALTER TABLE domains ADD COLUMN two_factor BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Record this migration
|
||||||
|
INSERT INTO migrations (version, description) VALUES (2, 'Add two_factor column to domains table for Phase 2');
|
||||||
87
src/gondulf/dependencies.py
Normal file
87
src/gondulf/dependencies.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""FastAPI dependency injection for services."""
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from gondulf.config import Config
|
||||||
|
from gondulf.database.connection import Database
|
||||||
|
from gondulf.dns import DNSService
|
||||||
|
from gondulf.email import EmailService
|
||||||
|
from gondulf.services.domain_verification import DomainVerificationService
|
||||||
|
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||||
|
from gondulf.services.rate_limiter import RateLimiter
|
||||||
|
from gondulf.services.relme_parser import RelMeParser
|
||||||
|
from gondulf.storage import CodeStore
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
@lru_cache
|
||||||
|
def get_config() -> Config:
|
||||||
|
"""Get configuration instance."""
|
||||||
|
return Config
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 1 Services
|
||||||
|
@lru_cache
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""Get singleton database service."""
|
||||||
|
config = get_config()
|
||||||
|
db = Database(config.DATABASE_URL)
|
||||||
|
db.initialize()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_code_storage() -> CodeStore:
|
||||||
|
"""Get singleton code storage service."""
|
||||||
|
config = get_config()
|
||||||
|
return CodeStore(ttl_seconds=config.CODE_EXPIRY)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_email_service() -> EmailService:
|
||||||
|
"""Get singleton email service."""
|
||||||
|
config = get_config()
|
||||||
|
return EmailService(
|
||||||
|
smtp_host=config.SMTP_HOST,
|
||||||
|
smtp_port=config.SMTP_PORT,
|
||||||
|
smtp_from=config.SMTP_FROM,
|
||||||
|
smtp_username=config.SMTP_USERNAME,
|
||||||
|
smtp_password=config.SMTP_PASSWORD,
|
||||||
|
smtp_use_tls=config.SMTP_USE_TLS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_dns_service() -> DNSService:
|
||||||
|
"""Get singleton DNS service."""
|
||||||
|
return DNSService()
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 2 Services
|
||||||
|
@lru_cache
|
||||||
|
def get_html_fetcher() -> HTMLFetcherService:
|
||||||
|
"""Get singleton HTML fetcher service."""
|
||||||
|
return HTMLFetcherService()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_relme_parser() -> RelMeParser:
|
||||||
|
"""Get singleton rel=me parser service."""
|
||||||
|
return RelMeParser()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_rate_limiter() -> RateLimiter:
|
||||||
|
"""Get singleton rate limiter service."""
|
||||||
|
return RateLimiter(max_attempts=3, window_hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_verification_service() -> DomainVerificationService:
|
||||||
|
"""Get singleton domain verification service."""
|
||||||
|
return DomainVerificationService(
|
||||||
|
dns_service=get_dns_service(),
|
||||||
|
email_service=get_email_service(),
|
||||||
|
code_storage=get_code_storage(),
|
||||||
|
html_fetcher=get_html_fetcher(),
|
||||||
|
relme_parser=get_relme_parser()
|
||||||
|
)
|
||||||
@@ -6,7 +6,6 @@ and fallback to public DNS servers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
from dns.exception import DNSException
|
from dns.exception import DNSException
|
||||||
@@ -51,7 +50,7 @@ class DNSService:
|
|||||||
|
|
||||||
return resolver
|
return resolver
|
||||||
|
|
||||||
def get_txt_records(self, domain: str) -> List[str]:
|
def get_txt_records(self, domain: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Query TXT records for a domain.
|
Query TXT records for a domain.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import logging
|
|||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger("gondulf.email")
|
logger = logging.getLogger("gondulf.email")
|
||||||
|
|
||||||
@@ -32,8 +31,8 @@ class EmailService:
|
|||||||
smtp_host: str,
|
smtp_host: str,
|
||||||
smtp_port: int,
|
smtp_port: int,
|
||||||
smtp_from: str,
|
smtp_from: str,
|
||||||
smtp_username: Optional[str] = None,
|
smtp_username: str | None = None,
|
||||||
smtp_password: Optional[str] = None,
|
smtp_password: str | None = None,
|
||||||
smtp_use_tls: bool = True,
|
smtp_use_tls: bool = True,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|||||||
0
src/gondulf/routers/__init__.py
Normal file
0
src/gondulf/routers/__init__.py
Normal file
233
src/gondulf/routers/authorization.py
Normal file
233
src/gondulf/routers/authorization.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Authorization endpoint for OAuth 2.0 / IndieAuth authorization code flow."""
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from gondulf.database.connection import Database
|
||||||
|
from gondulf.dependencies import get_database, get_verification_service
|
||||||
|
from gondulf.services.domain_verification import DomainVerificationService
|
||||||
|
from gondulf.utils.validation import (
|
||||||
|
extract_domain_from_url,
|
||||||
|
normalize_client_id,
|
||||||
|
validate_redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("gondulf.authorization")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="src/gondulf/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authorize")
|
||||||
|
async def authorize_get(
|
||||||
|
request: Request,
|
||||||
|
client_id: str | None = None,
|
||||||
|
redirect_uri: str | None = None,
|
||||||
|
response_type: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
code_challenge: str | None = None,
|
||||||
|
code_challenge_method: str | None = None,
|
||||||
|
scope: str | None = None,
|
||||||
|
me: str | None = None,
|
||||||
|
database: Database = Depends(get_database)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Handle authorization request (GET).
|
||||||
|
|
||||||
|
Validates client_id, redirect_uri, and required parameters.
|
||||||
|
Shows consent form if domain is verified, or verification form if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
client_id: Client application identifier
|
||||||
|
redirect_uri: Callback URI for client
|
||||||
|
response_type: Must be "code"
|
||||||
|
state: Client state parameter
|
||||||
|
code_challenge: PKCE code challenge
|
||||||
|
code_challenge_method: PKCE method (S256)
|
||||||
|
scope: Requested scope
|
||||||
|
me: User identity URL
|
||||||
|
database: Database service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML response with consent form or error page
|
||||||
|
"""
|
||||||
|
# Validate required parameters (pre-client validation)
|
||||||
|
if not client_id:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error": "Missing required parameter: client_id",
|
||||||
|
"error_code": "invalid_request"
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if not redirect_uri:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error": "Missing required parameter: redirect_uri",
|
||||||
|
"error_code": "invalid_request"
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize and validate client_id
|
||||||
|
try:
|
||||||
|
normalized_client_id = normalize_client_id(client_id)
|
||||||
|
except ValueError:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error": "client_id must use HTTPS",
|
||||||
|
"error_code": "invalid_request"
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate redirect_uri against client_id
|
||||||
|
if not validate_redirect_uri(redirect_uri, normalized_client_id):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error": "redirect_uri does not match client_id domain",
|
||||||
|
"error_code": "invalid_request"
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# From here on, redirect errors to client via OAuth error redirect
|
||||||
|
|
||||||
|
# Validate response_type
|
||||||
|
if response_type != "code":
|
||||||
|
error_params = {
|
||||||
|
"error": "unsupported_response_type",
|
||||||
|
"error_description": "Only response_type=code is supported",
|
||||||
|
"state": state or ""
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# Validate code_challenge (PKCE required)
|
||||||
|
if not code_challenge:
|
||||||
|
error_params = {
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "code_challenge is required (PKCE)",
|
||||||
|
"state": state or ""
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# Validate code_challenge_method
|
||||||
|
if code_challenge_method != "S256":
|
||||||
|
error_params = {
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "code_challenge_method must be S256",
|
||||||
|
"state": state or ""
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# Validate me parameter
|
||||||
|
if not me:
|
||||||
|
error_params = {
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "me parameter is required",
|
||||||
|
"state": state or ""
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# Validate me URL format
|
||||||
|
try:
|
||||||
|
extract_domain_from_url(me)
|
||||||
|
except ValueError:
|
||||||
|
error_params = {
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Invalid me URL",
|
||||||
|
"state": state or ""
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# Check if domain is verified
|
||||||
|
# For Phase 2, we'll show consent form immediately (domain verification happens separately)
|
||||||
|
# In Phase 3, we'll check database for verified domains
|
||||||
|
|
||||||
|
# Show consent form
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"authorize.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"client_id": normalized_client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"state": state or "",
|
||||||
|
"code_challenge": code_challenge,
|
||||||
|
"code_challenge_method": code_challenge_method,
|
||||||
|
"scope": scope or "",
|
||||||
|
"me": me
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/authorize/consent")
|
||||||
|
async def authorize_consent(
|
||||||
|
request: Request,
|
||||||
|
client_id: str = Form(...),
|
||||||
|
redirect_uri: str = Form(...),
|
||||||
|
state: str = Form(...),
|
||||||
|
code_challenge: str = Form(...),
|
||||||
|
code_challenge_method: str = Form(...),
|
||||||
|
scope: str = Form(...),
|
||||||
|
me: str = Form(...),
|
||||||
|
verification_service: DomainVerificationService = Depends(get_verification_service)
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""
|
||||||
|
Handle authorization consent (POST).
|
||||||
|
|
||||||
|
Creates authorization code and redirects to client callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
client_id: Client application identifier
|
||||||
|
redirect_uri: Callback URI
|
||||||
|
state: Client state
|
||||||
|
code_challenge: PKCE challenge
|
||||||
|
code_challenge_method: PKCE method
|
||||||
|
scope: Requested scope
|
||||||
|
me: User identity
|
||||||
|
verification_service: Domain verification service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to client callback with authorization code
|
||||||
|
"""
|
||||||
|
logger.info(f"Authorization consent granted for client_id={client_id}")
|
||||||
|
|
||||||
|
# Create authorization code
|
||||||
|
authorization_code = verification_service.create_authorization_code(
|
||||||
|
client_id=client_id,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
state=state,
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
code_challenge_method=code_challenge_method,
|
||||||
|
scope=scope,
|
||||||
|
me=me
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build redirect URL with authorization code
|
||||||
|
redirect_params = {
|
||||||
|
"code": authorization_code,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
|
||||||
|
|
||||||
|
logger.info(f"Redirecting to {redirect_uri} with authorization code")
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
98
src/gondulf/routers/verification.py
Normal file
98
src/gondulf/routers/verification.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Verification endpoints for domain verification flow."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from gondulf.dependencies import get_rate_limiter, get_verification_service
|
||||||
|
from gondulf.services.domain_verification import DomainVerificationService
|
||||||
|
from gondulf.services.rate_limiter import RateLimiter
|
||||||
|
from gondulf.utils.validation import extract_domain_from_url
|
||||||
|
|
||||||
|
logger = logging.getLogger("gondulf.verification")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/verify/start")
|
||||||
|
async def start_verification(
|
||||||
|
me: str = Form(...),
|
||||||
|
verification_service: DomainVerificationService = Depends(get_verification_service),
|
||||||
|
rate_limiter: RateLimiter = Depends(get_rate_limiter)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Start domain verification process.
|
||||||
|
|
||||||
|
Performs two-factor verification:
|
||||||
|
1. Verifies DNS TXT record
|
||||||
|
2. Discovers email via rel=me links
|
||||||
|
3. Sends verification code to email
|
||||||
|
|
||||||
|
Args:
|
||||||
|
me: User's URL (e.g., "https://example.com/")
|
||||||
|
verification_service: Domain verification service
|
||||||
|
rate_limiter: Rate limiter service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response:
|
||||||
|
- success: true, email: masked email
|
||||||
|
- success: false, error: error code
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract domain from me URL
|
||||||
|
domain = extract_domain_from_url(me)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Invalid me URL: {me}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={"success": False, "error": "invalid_me_url"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
if not rate_limiter.check_rate_limit(domain):
|
||||||
|
logger.warning(f"Rate limit exceeded for domain={domain}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={"success": False, "error": "rate_limit_exceeded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record attempt
|
||||||
|
rate_limiter.record_attempt(domain)
|
||||||
|
|
||||||
|
# Start verification
|
||||||
|
result = verification_service.start_verification(domain, me)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content=result
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/verify/code")
|
||||||
|
async def verify_code(
|
||||||
|
domain: str = Form(...),
|
||||||
|
code: str = Form(...),
|
||||||
|
verification_service: DomainVerificationService = Depends(get_verification_service)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Verify email verification code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain being verified
|
||||||
|
code: 6-digit verification code
|
||||||
|
verification_service: Domain verification service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response:
|
||||||
|
- success: true, email: full email address
|
||||||
|
- success: false, error: error code
|
||||||
|
"""
|
||||||
|
logger.info(f"Verifying code for domain={domain}")
|
||||||
|
|
||||||
|
# Verify code
|
||||||
|
result = verification_service.verify_email_code(domain, code)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content=result
|
||||||
|
)
|
||||||
0
src/gondulf/services/__init__.py
Normal file
0
src/gondulf/services/__init__.py
Normal file
263
src/gondulf/services/domain_verification.py
Normal file
263
src/gondulf/services/domain_verification.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""Domain verification service orchestrating two-factor verification."""
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gondulf.dns import DNSService
|
||||||
|
from gondulf.email import EmailService
|
||||||
|
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||||
|
from gondulf.services.relme_parser import RelMeParser
|
||||||
|
from gondulf.storage import CodeStore
|
||||||
|
from gondulf.utils.validation import validate_email
|
||||||
|
|
||||||
|
logger = logging.getLogger("gondulf.domain_verification")
|
||||||
|
|
||||||
|
|
||||||
|
class DomainVerificationService:
|
||||||
|
"""Service for orchestrating two-factor domain verification (DNS + email)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dns_service: DNSService,
|
||||||
|
email_service: EmailService,
|
||||||
|
code_storage: CodeStore,
|
||||||
|
html_fetcher: HTMLFetcherService,
|
||||||
|
relme_parser: RelMeParser,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize domain verification service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dns_service: DNS service for TXT record verification
|
||||||
|
email_service: Email service for sending verification codes
|
||||||
|
code_storage: Code storage for verification codes
|
||||||
|
html_fetcher: HTML fetcher service for retrieving user homepage
|
||||||
|
relme_parser: rel=me parser for extracting email from HTML
|
||||||
|
"""
|
||||||
|
self.dns_service = dns_service
|
||||||
|
self.email_service = email_service
|
||||||
|
self.code_storage = code_storage
|
||||||
|
self.html_fetcher = html_fetcher
|
||||||
|
self.relme_parser = relme_parser
|
||||||
|
logger.debug("DomainVerificationService initialized")
|
||||||
|
|
||||||
|
def generate_verification_code(self) -> str:
|
||||||
|
"""
|
||||||
|
Generate a 6-digit numeric verification code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
6-digit numeric code as string
|
||||||
|
"""
|
||||||
|
return f"{secrets.randbelow(1000000):06d}"
|
||||||
|
|
||||||
|
def start_verification(self, domain: str, me_url: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Start two-factor verification process for domain.
|
||||||
|
|
||||||
|
Step 1: Verify DNS TXT record
|
||||||
|
Step 2: Fetch homepage and extract email from rel=me
|
||||||
|
Step 3: Send verification code to email
|
||||||
|
Step 4: Store code for later verification
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to verify (e.g., "example.com")
|
||||||
|
me_url: User's URL for verification (e.g., "https://example.com/")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with verification result:
|
||||||
|
- success: bool
|
||||||
|
- email: masked email if successful
|
||||||
|
- error: error code if failed
|
||||||
|
"""
|
||||||
|
logger.info(f"Starting verification for domain={domain} me_url={me_url}")
|
||||||
|
|
||||||
|
# Step 1: Verify DNS TXT record
|
||||||
|
dns_verified = self._verify_dns_record(domain)
|
||||||
|
if not dns_verified:
|
||||||
|
logger.warning(f"DNS verification failed for domain={domain}")
|
||||||
|
return {"success": False, "error": "dns_verification_failed"}
|
||||||
|
|
||||||
|
logger.info(f"DNS verification successful for domain={domain}")
|
||||||
|
|
||||||
|
# Step 2: Fetch homepage and extract email
|
||||||
|
email = self._discover_email(me_url)
|
||||||
|
if not email:
|
||||||
|
logger.warning(f"Email discovery failed for me_url={me_url}")
|
||||||
|
return {"success": False, "error": "email_discovery_failed"}
|
||||||
|
|
||||||
|
logger.info(f"Email discovered for domain={domain}")
|
||||||
|
|
||||||
|
# Validate email format
|
||||||
|
if not validate_email(email):
|
||||||
|
logger.warning(f"Invalid email format discovered: {email}")
|
||||||
|
return {"success": False, "error": "invalid_email_format"}
|
||||||
|
|
||||||
|
# Step 3: Generate and send verification code
|
||||||
|
code = self.generate_verification_code()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.email_service.send_verification_code(email, code, domain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send verification email: {e}")
|
||||||
|
return {"success": False, "error": "email_send_failed"}
|
||||||
|
|
||||||
|
# Step 4: Store code for verification
|
||||||
|
storage_key = f"email_verify:{domain}"
|
||||||
|
self.code_storage.store(storage_key, code)
|
||||||
|
|
||||||
|
# Also store the email address for later retrieval
|
||||||
|
email_key = f"email_addr:{domain}"
|
||||||
|
self.code_storage.store(email_key, email)
|
||||||
|
|
||||||
|
logger.info(f"Verification code sent for domain={domain}")
|
||||||
|
|
||||||
|
# Return masked email
|
||||||
|
from gondulf.utils.validation import mask_email
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"email": mask_email(email),
|
||||||
|
"verification_method": "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_email_code(self, domain: str, code: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify email code for domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain being verified
|
||||||
|
code: Verification code from email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with verification result:
|
||||||
|
- success: bool
|
||||||
|
- email: full email address if successful
|
||||||
|
- error: error code if failed
|
||||||
|
"""
|
||||||
|
storage_key = f"email_verify:{domain}"
|
||||||
|
email_key = f"email_addr:{domain}"
|
||||||
|
|
||||||
|
# Verify code
|
||||||
|
if not self.code_storage.verify(storage_key, code):
|
||||||
|
logger.warning(f"Email code verification failed for domain={domain}")
|
||||||
|
return {"success": False, "error": "invalid_code"}
|
||||||
|
|
||||||
|
# Retrieve email address
|
||||||
|
email = self.code_storage.get(email_key)
|
||||||
|
if not email:
|
||||||
|
logger.error(f"Email address not found for domain={domain}")
|
||||||
|
return {"success": False, "error": "email_not_found"}
|
||||||
|
|
||||||
|
# Clean up email address from storage
|
||||||
|
self.code_storage.delete(email_key)
|
||||||
|
|
||||||
|
logger.info(f"Email verification successful for domain={domain}")
|
||||||
|
return {"success": True, "email": email}
|
||||||
|
|
||||||
|
def _verify_dns_record(self, domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify DNS TXT record for domain.
|
||||||
|
|
||||||
|
Checks for TXT record containing "gondulf-verify-domain"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DNS verification successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.dns_service.verify_txt_record(
|
||||||
|
domain,
|
||||||
|
"gondulf-verify-domain"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DNS verification error for domain={domain}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _discover_email(self, me_url: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Discover email address from user's homepage via rel=me links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
me_url: User's URL to fetch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Email address if found, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Fetch HTML
|
||||||
|
html = self.html_fetcher.fetch(me_url)
|
||||||
|
if not html:
|
||||||
|
logger.warning(f"Failed to fetch HTML from {me_url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse rel=me links and extract email
|
||||||
|
email = self.relme_parser.find_email(html)
|
||||||
|
if not email:
|
||||||
|
logger.warning(f"No email found in rel=me links at {me_url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return email
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Email discovery error for {me_url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_authorization_code(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
state: str,
|
||||||
|
code_challenge: str,
|
||||||
|
code_challenge_method: str,
|
||||||
|
scope: str,
|
||||||
|
me: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create authorization code with metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Client identifier
|
||||||
|
redirect_uri: Redirect URI for callback
|
||||||
|
state: Client state parameter
|
||||||
|
code_challenge: PKCE code challenge
|
||||||
|
code_challenge_method: PKCE method (S256)
|
||||||
|
scope: Requested scope
|
||||||
|
me: Verified user identity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authorization code
|
||||||
|
"""
|
||||||
|
# Generate authorization code
|
||||||
|
authorization_code = self._generate_authorization_code()
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": code_challenge,
|
||||||
|
"code_challenge_method": code_challenge_method,
|
||||||
|
"scope": scope,
|
||||||
|
"me": me,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"expires_at": int(time.time()) + 600,
|
||||||
|
"used": False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store with prefix
|
||||||
|
storage_key = f"authz:{authorization_code}"
|
||||||
|
self.code_storage.store(storage_key, str(metadata))
|
||||||
|
|
||||||
|
logger.info(f"Authorization code created for client_id={client_id}")
|
||||||
|
return authorization_code
|
||||||
|
|
||||||
|
def _generate_authorization_code(self) -> str:
|
||||||
|
"""
|
||||||
|
Generate secure random authorization code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe authorization code
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
77
src/gondulf/services/html_fetcher.py
Normal file
77
src/gondulf/services/html_fetcher.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""HTML fetcher service for retrieving user homepages."""
|
||||||
|
import urllib.request
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLFetcherService:
|
||||||
|
"""Service for fetching HTML content from URLs."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timeout: int = 10,
|
||||||
|
max_size: int = 1024 * 1024, # 1MB
|
||||||
|
max_redirects: int = 5,
|
||||||
|
user_agent: str = "Gondulf-IndieAuth/0.1"
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize HTML fetcher service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Request timeout in seconds (default: 10)
|
||||||
|
max_size: Maximum response size in bytes (default: 1MB)
|
||||||
|
max_redirects: Maximum number of redirects to follow (default: 5)
|
||||||
|
user_agent: User-Agent header value
|
||||||
|
"""
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_size = max_size
|
||||||
|
self.max_redirects = max_redirects
|
||||||
|
self.user_agent = user_agent
|
||||||
|
|
||||||
|
def fetch(self, url: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Fetch HTML content from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to fetch (must be HTTPS)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML content as string, or None if fetch fails
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If URL is not HTTPS
|
||||||
|
"""
|
||||||
|
# Enforce HTTPS
|
||||||
|
if not url.startswith('https://'):
|
||||||
|
raise ValueError("URL must use HTTPS")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create request with User-Agent header
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={'User-Agent': self.user_agent}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open URL with timeout
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=self.timeout
|
||||||
|
) as response:
|
||||||
|
# Check content length if provided
|
||||||
|
content_length = response.headers.get('Content-Length')
|
||||||
|
if content_length and int(content_length) > self.max_size:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Read with size limit
|
||||||
|
content = response.read(self.max_size + 1)
|
||||||
|
if len(content) > self.max_size:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Decode content
|
||||||
|
charset = response.headers.get_content_charset() or 'utf-8'
|
||||||
|
return content.decode(charset, errors='replace')
|
||||||
|
|
||||||
|
except (URLError, HTTPError, UnicodeDecodeError, TimeoutError):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Catch all other exceptions and return None
|
||||||
|
return None
|
||||||
98
src/gondulf/services/rate_limiter.py
Normal file
98
src/gondulf/services/rate_limiter.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""In-memory rate limiter for domain verification attempts."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""In-memory rate limiter for domain verification attempts."""
|
||||||
|
|
||||||
|
def __init__(self, max_attempts: int = 3, window_hours: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Initialize rate limiter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: Maximum attempts per domain in time window (default: 3)
|
||||||
|
window_hours: Time window in hours (default: 1)
|
||||||
|
"""
|
||||||
|
self.max_attempts = max_attempts
|
||||||
|
self.window_seconds = window_hours * 3600
|
||||||
|
self._attempts: dict[str, list[int]] = {} # domain -> [timestamp1, timestamp2, ...]
|
||||||
|
|
||||||
|
def check_rate_limit(self, domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if domain has exceeded rate limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if within rate limit, False if exceeded
|
||||||
|
"""
|
||||||
|
# Clean old timestamps first
|
||||||
|
self._clean_old_attempts(domain)
|
||||||
|
|
||||||
|
# Check current count
|
||||||
|
if domain not in self._attempts:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return len(self._attempts[domain]) < self.max_attempts
|
||||||
|
|
||||||
|
def record_attempt(self, domain: str) -> None:
|
||||||
|
"""
|
||||||
|
Record a verification attempt for domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain that attempted verification
|
||||||
|
"""
|
||||||
|
now = int(time.time())
|
||||||
|
if domain not in self._attempts:
|
||||||
|
self._attempts[domain] = []
|
||||||
|
self._attempts[domain].append(now)
|
||||||
|
|
||||||
|
def _clean_old_attempts(self, domain: str) -> None:
|
||||||
|
"""
|
||||||
|
Remove timestamps older than window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to clean old attempts for
|
||||||
|
"""
|
||||||
|
if domain not in self._attempts:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
self._attempts[domain] = [ts for ts in self._attempts[domain] if ts > cutoff]
|
||||||
|
|
||||||
|
# Remove domain entirely if no recent attempts
|
||||||
|
if not self._attempts[domain]:
|
||||||
|
del self._attempts[domain]
|
||||||
|
|
||||||
|
def get_remaining_attempts(self, domain: str) -> int:
|
||||||
|
"""
|
||||||
|
Get remaining attempts for domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of remaining attempts
|
||||||
|
"""
|
||||||
|
self._clean_old_attempts(domain)
|
||||||
|
current_count = len(self._attempts.get(domain, []))
|
||||||
|
return max(0, self.max_attempts - current_count)
|
||||||
|
|
||||||
|
def get_reset_time(self, domain: str) -> int:
|
||||||
|
"""
|
||||||
|
Get timestamp when rate limit will reset for domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unix timestamp when oldest attempt expires, or 0 if no attempts
|
||||||
|
"""
|
||||||
|
self._clean_old_attempts(domain)
|
||||||
|
if domain not in self._attempts or not self._attempts[domain]:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
oldest_attempt = min(self._attempts[domain])
|
||||||
|
return oldest_attempt + self.window_seconds
|
||||||
76
src/gondulf/services/relme_parser.py
Normal file
76
src/gondulf/services/relme_parser.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""rel=me parser service for extracting email addresses from HTML."""
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
class RelMeParser:
|
||||||
|
"""Service for parsing rel=me links from HTML."""
|
||||||
|
|
||||||
|
def parse_relme_links(self, html: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Parse HTML for rel=me links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML content to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rel=me link URLs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
links = []
|
||||||
|
|
||||||
|
# Find all <a> tags with rel="me" attribute
|
||||||
|
for link in soup.find_all('a', rel='me'):
|
||||||
|
href = link.get('href')
|
||||||
|
if href:
|
||||||
|
links.append(href)
|
||||||
|
|
||||||
|
# Also check for <link> tags with rel="me"
|
||||||
|
for link in soup.find_all('link', rel='me'):
|
||||||
|
href = link.get('href')
|
||||||
|
if href:
|
||||||
|
links.append(href)
|
||||||
|
|
||||||
|
return links
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def extract_mailto_email(self, relme_links: list[str]) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract email address from mailto: links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relme_links: List of rel=me link URLs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Email address if found, None otherwise
|
||||||
|
"""
|
||||||
|
for link in relme_links:
|
||||||
|
if link.startswith('mailto:'):
|
||||||
|
# Extract email address from mailto: link
|
||||||
|
email = link[7:] # Remove 'mailto:' prefix
|
||||||
|
|
||||||
|
# Strip any query parameters (e.g., ?subject=...)
|
||||||
|
if '?' in email:
|
||||||
|
email = email.split('?')[0]
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if '@' in email and '.' in email:
|
||||||
|
return email.strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_email(self, html: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Find email address from HTML by parsing rel=me links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML content to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Email address if found, None otherwise
|
||||||
|
"""
|
||||||
|
relme_links = self.parse_relme_links(html)
|
||||||
|
return self.extract_mailto_email(relme_links)
|
||||||
@@ -7,7 +7,6 @@ codes with automatic expiration checking on access.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Optional, Tuple
|
|
||||||
|
|
||||||
logger = logging.getLogger("gondulf.storage")
|
logger = logging.getLogger("gondulf.storage")
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ class CodeStore:
|
|||||||
Args:
|
Args:
|
||||||
ttl_seconds: Time-to-live for codes in seconds (default: 600 = 10 minutes)
|
ttl_seconds: Time-to-live for codes in seconds (default: 600 = 10 minutes)
|
||||||
"""
|
"""
|
||||||
self._store: Dict[str, Tuple[str, float]] = {}
|
self._store: dict[str, tuple[str, float]] = {}
|
||||||
self._ttl = ttl_seconds
|
self._ttl = ttl_seconds
|
||||||
logger.debug(f"CodeStore initialized with TTL={ttl_seconds}s")
|
logger.debug(f"CodeStore initialized with TTL={ttl_seconds}s")
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ class CodeStore:
|
|||||||
logger.info(f"Code verified successfully for key={key}")
|
logger.info(f"Code verified successfully for key={key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get(self, key: str) -> Optional[str]:
|
def get(self, key: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Get code without removing it (for testing/debugging).
|
Get code without removing it (for testing/debugging).
|
||||||
|
|
||||||
|
|||||||
30
src/gondulf/templates/authorize.html
Normal file
30
src/gondulf/templates/authorize.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Authorization Request - Gondulf{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Authorization Request</h1>
|
||||||
|
|
||||||
|
<p>The application <strong>{{ client_id }}</strong> wants to authenticate you.</p>
|
||||||
|
|
||||||
|
{% if scope %}
|
||||||
|
<p>Requested permissions: <code>{{ scope }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>You will be identified as: <strong>{{ me }}</strong></p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/authorize/consent">
|
||||||
|
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||||
|
<input type="hidden" name="state" value="{{ state }}">
|
||||||
|
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
|
||||||
|
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
|
||||||
|
<input type="hidden" name="scope" value="{{ scope }}">
|
||||||
|
<input type="hidden" name="me" value="{{ me }}">
|
||||||
|
<button type="submit">Authorize</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
32
src/gondulf/templates/base.html
Normal file
32
src/gondulf/templates/base.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Gondulf IndieAuth{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
.success { color: #388e3c; }
|
||||||
|
form { margin-top: 20px; }
|
||||||
|
input, button { font-size: 16px; padding: 8px; }
|
||||||
|
button { background: #1976d2; color: white; border: none; cursor: pointer; }
|
||||||
|
button:hover { background: #1565c0; }
|
||||||
|
code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
src/gondulf/templates/error.html
Normal file
19
src/gondulf/templates/error.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Error - Gondulf{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Error</h1>
|
||||||
|
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
{% if error_code %}
|
||||||
|
<p>Error code: <code>{{ error_code }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if details %}
|
||||||
|
<p>{{ details }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><a href="/">Return to home</a></p>
|
||||||
|
{% endblock %}
|
||||||
19
src/gondulf/templates/verify_email.html
Normal file
19
src/gondulf/templates/verify_email.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Verify Email - Gondulf{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Verify Your Email</h1>
|
||||||
|
<p>A verification code has been sent to <strong>{{ masked_email }}</strong></p>
|
||||||
|
<p>Please enter the 6-digit code to complete verification:</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/api/verify/code">
|
||||||
|
<input type="hidden" name="domain" value="{{ domain }}">
|
||||||
|
<input type="text" name="code" placeholder="000000" maxlength="6" required autofocus>
|
||||||
|
<button type="submit">Verify</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
0
src/gondulf/utils/__init__.py
Normal file
0
src/gondulf/utils/__init__.py
Normal file
148
src/gondulf/utils/validation.py
Normal file
148
src/gondulf/utils/validation.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Client validation and utility functions."""
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def mask_email(email: str) -> str:
|
||||||
|
"""
|
||||||
|
Mask email for display: user@example.com -> u***@example.com
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to mask
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Masked email string
|
||||||
|
"""
|
||||||
|
if '@' not in email:
|
||||||
|
return email
|
||||||
|
|
||||||
|
local, domain = email.split('@', 1)
|
||||||
|
if len(local) <= 1:
|
||||||
|
return email
|
||||||
|
|
||||||
|
masked_local = local[0] + '***'
|
||||||
|
return f"{masked_local}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_client_id(client_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize client_id URL to canonical form.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Ensure https:// scheme
|
||||||
|
- Remove default port (443)
|
||||||
|
- Preserve path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Client ID URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized client_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If client_id does not use https scheme
|
||||||
|
"""
|
||||||
|
parsed = urlparse(client_id)
|
||||||
|
|
||||||
|
# Ensure https
|
||||||
|
if parsed.scheme != 'https':
|
||||||
|
raise ValueError("client_id must use https scheme")
|
||||||
|
|
||||||
|
# Remove default HTTPS port
|
||||||
|
netloc = parsed.netloc
|
||||||
|
if netloc.endswith(':443'):
|
||||||
|
netloc = netloc[:-4]
|
||||||
|
|
||||||
|
# Reconstruct
|
||||||
|
normalized = f"https://{netloc}{parsed.path}"
|
||||||
|
if parsed.query:
|
||||||
|
normalized += f"?{parsed.query}"
|
||||||
|
if parsed.fragment:
|
||||||
|
normalized += f"#{parsed.fragment}"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def validate_redirect_uri(redirect_uri: str, client_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate redirect_uri against client_id per IndieAuth spec.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Must use https scheme (except localhost)
|
||||||
|
- Must share same origin as client_id OR
|
||||||
|
- Must be subdomain of client_id domain OR
|
||||||
|
- Can be localhost/127.0.0.1 for development
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redirect_uri: Redirect URI to validate
|
||||||
|
client_id: Client ID for comparison
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redirect_parsed = urlparse(redirect_uri)
|
||||||
|
client_parsed = urlparse(client_id)
|
||||||
|
|
||||||
|
# Allow localhost/127.0.0.1 for development (can use HTTP)
|
||||||
|
if redirect_parsed.hostname in ('localhost', '127.0.0.1'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check scheme (must be https for non-localhost)
|
||||||
|
if redirect_parsed.scheme != 'https':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Same origin check
|
||||||
|
if (redirect_parsed.scheme == client_parsed.scheme and
|
||||||
|
redirect_parsed.netloc == client_parsed.netloc):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Subdomain check
|
||||||
|
redirect_host = redirect_parsed.hostname or ''
|
||||||
|
client_host = client_parsed.hostname or ''
|
||||||
|
|
||||||
|
# Must end with .{client_host}
|
||||||
|
if redirect_host.endswith(f".{client_host}"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_domain_from_url(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract domain from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to extract domain from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Domain name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If URL is invalid or has no hostname
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise ValueError("URL has no hostname")
|
||||||
|
return parsed.hostname
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid URL: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(email: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate email address format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid email format, False otherwise
|
||||||
|
"""
|
||||||
|
# Simple email validation pattern
|
||||||
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return bool(re.match(pattern, email))
|
||||||
236
tests/unit/test_domain_verification.py
Normal file
236
tests/unit/test_domain_verification.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Tests for domain verification service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, MagicMock
|
||||||
|
|
||||||
|
from gondulf.services.domain_verification import DomainVerificationService
|
||||||
|
from gondulf.dns import DNSService
|
||||||
|
from gondulf.email import EmailService
|
||||||
|
from gondulf.storage import CodeStore
|
||||||
|
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||||
|
from gondulf.services.relme_parser import RelMeParser
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainVerificationService:
|
||||||
|
"""Tests for DomainVerificationService."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_dns(self):
|
||||||
|
"""Mock DNS service."""
|
||||||
|
return Mock(spec=DNSService)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_email(self):
|
||||||
|
"""Mock email service."""
|
||||||
|
return Mock(spec=EmailService)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_storage(self):
|
||||||
|
"""Mock code storage."""
|
||||||
|
return Mock(spec=CodeStore)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_fetcher(self):
|
||||||
|
"""Mock HTML fetcher."""
|
||||||
|
return Mock(spec=HTMLFetcherService)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_parser(self):
|
||||||
|
"""Mock rel=me parser."""
|
||||||
|
return Mock(spec=RelMeParser)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self, mock_dns, mock_email, mock_storage, mock_fetcher, mock_parser):
|
||||||
|
"""Create domain verification service with mocks."""
|
||||||
|
return DomainVerificationService(
|
||||||
|
dns_service=mock_dns,
|
||||||
|
email_service=mock_email,
|
||||||
|
code_storage=mock_storage,
|
||||||
|
html_fetcher=mock_fetcher,
|
||||||
|
relme_parser=mock_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generate_verification_code(self, service):
|
||||||
|
"""Test verification code generation."""
|
||||||
|
code = service.generate_verification_code()
|
||||||
|
assert isinstance(code, str)
|
||||||
|
assert len(code) == 6
|
||||||
|
assert code.isdigit()
|
||||||
|
|
||||||
|
def test_generate_verification_code_unique(self, service):
|
||||||
|
"""Test that generated codes are different."""
|
||||||
|
code1 = service.generate_verification_code()
|
||||||
|
code2 = service.generate_verification_code()
|
||||||
|
# Very unlikely to be the same, but possible
|
||||||
|
# Just check they're both valid
|
||||||
|
assert code1.isdigit()
|
||||||
|
assert code2.isdigit()
|
||||||
|
|
||||||
|
def test_start_verification_dns_fails(self, service, mock_dns):
|
||||||
|
"""Test start_verification when DNS verification fails."""
|
||||||
|
mock_dns.verify_txt_record.return_value = False
|
||||||
|
|
||||||
|
result = service.start_verification("example.com", "https://example.com/")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "dns_verification_failed"
|
||||||
|
|
||||||
|
def test_start_verification_email_discovery_fails(
|
||||||
|
self, service, mock_dns, mock_fetcher, mock_parser
|
||||||
|
):
|
||||||
|
"""Test start_verification when email discovery fails."""
|
||||||
|
mock_dns.verify_txt_record.return_value = True
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = None
|
||||||
|
|
||||||
|
result = service.start_verification("example.com", "https://example.com/")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "email_discovery_failed"
|
||||||
|
|
||||||
|
def test_start_verification_invalid_email_format(
|
||||||
|
self, service, mock_dns, mock_fetcher, mock_parser
|
||||||
|
):
|
||||||
|
"""Test start_verification with invalid email format."""
|
||||||
|
mock_dns.verify_txt_record.return_value = True
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = "not-an-email"
|
||||||
|
|
||||||
|
result = service.start_verification("example.com", "https://example.com/")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "invalid_email_format"
|
||||||
|
|
||||||
|
def test_start_verification_email_send_fails(
|
||||||
|
self, service, mock_dns, mock_fetcher, mock_parser, mock_email
|
||||||
|
):
|
||||||
|
"""Test start_verification when email sending fails."""
|
||||||
|
mock_dns.verify_txt_record.return_value = True
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = "user@example.com"
|
||||||
|
mock_email.send_verification_code.side_effect = Exception("SMTP error")
|
||||||
|
|
||||||
|
result = service.start_verification("example.com", "https://example.com/")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "email_send_failed"
|
||||||
|
|
||||||
|
def test_start_verification_success(
|
||||||
|
self, service, mock_dns, mock_fetcher, mock_parser, mock_email, mock_storage
|
||||||
|
):
|
||||||
|
"""Test successful verification start."""
|
||||||
|
mock_dns.verify_txt_record.return_value = True
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = "user@example.com"
|
||||||
|
|
||||||
|
result = service.start_verification("example.com", "https://example.com/")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["email"] == "u***@example.com" # Masked
|
||||||
|
assert result["verification_method"] == "email"
|
||||||
|
mock_email.send_verification_code.assert_called_once()
|
||||||
|
assert mock_storage.store.call_count == 2 # Code and email stored
|
||||||
|
|
||||||
|
def test_verify_email_code_invalid(self, service, mock_storage):
|
||||||
|
"""Test verify_email_code with invalid code."""
|
||||||
|
mock_storage.verify.return_value = False
|
||||||
|
|
||||||
|
result = service.verify_email_code("example.com", "123456")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "invalid_code"
|
||||||
|
|
||||||
|
def test_verify_email_code_email_not_found(self, service, mock_storage):
|
||||||
|
"""Test verify_email_code when email not in storage."""
|
||||||
|
mock_storage.verify.return_value = True
|
||||||
|
mock_storage.get.return_value = None
|
||||||
|
|
||||||
|
result = service.verify_email_code("example.com", "123456")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "email_not_found"
|
||||||
|
|
||||||
|
def test_verify_email_code_success(self, service, mock_storage):
|
||||||
|
"""Test successful email code verification."""
|
||||||
|
mock_storage.verify.return_value = True
|
||||||
|
mock_storage.get.return_value = "user@example.com"
|
||||||
|
|
||||||
|
result = service.verify_email_code("example.com", "123456")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["email"] == "user@example.com"
|
||||||
|
mock_storage.delete.assert_called_once()
|
||||||
|
|
||||||
|
def test_create_authorization_code(self, service, mock_storage):
|
||||||
|
"""Test authorization code creation."""
|
||||||
|
code = service.create_authorization_code(
|
||||||
|
client_id="https://client.example.com/",
|
||||||
|
redirect_uri="https://client.example.com/callback",
|
||||||
|
state="test_state",
|
||||||
|
code_challenge="challenge",
|
||||||
|
code_challenge_method="S256",
|
||||||
|
scope="profile",
|
||||||
|
me="https://user.example.com/"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(code, str)
|
||||||
|
assert len(code) > 0
|
||||||
|
mock_storage.store.assert_called_once()
|
||||||
|
|
||||||
|
def test_verify_dns_record_success(self, service, mock_dns):
|
||||||
|
"""Test DNS record verification success."""
|
||||||
|
mock_dns.verify_txt_record.return_value = True
|
||||||
|
|
||||||
|
result = service._verify_dns_record("example.com")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_dns.verify_txt_record.assert_called_with("example.com", "gondulf-verify-domain")
|
||||||
|
|
||||||
|
def test_verify_dns_record_failure(self, service, mock_dns):
|
||||||
|
"""Test DNS record verification failure."""
|
||||||
|
mock_dns.verify_txt_record.return_value = False
|
||||||
|
|
||||||
|
result = service._verify_dns_record("example.com")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_verify_dns_record_exception(self, service, mock_dns):
|
||||||
|
"""Test DNS record verification handles exceptions."""
|
||||||
|
mock_dns.verify_txt_record.side_effect = Exception("DNS error")
|
||||||
|
|
||||||
|
result = service._verify_dns_record("example.com")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_discover_email_success(self, service, mock_fetcher, mock_parser):
|
||||||
|
"""Test email discovery success."""
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = "user@example.com"
|
||||||
|
|
||||||
|
email = service._discover_email("https://example.com/")
|
||||||
|
|
||||||
|
assert email == "user@example.com"
|
||||||
|
|
||||||
|
def test_discover_email_fetch_fails(self, service, mock_fetcher):
|
||||||
|
"""Test email discovery when fetch fails."""
|
||||||
|
mock_fetcher.fetch.return_value = None
|
||||||
|
|
||||||
|
email = service._discover_email("https://example.com/")
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_discover_email_no_email_found(self, service, mock_fetcher, mock_parser):
|
||||||
|
"""Test email discovery when no email found."""
|
||||||
|
mock_fetcher.fetch.return_value = "<html></html>"
|
||||||
|
mock_parser.find_email.return_value = None
|
||||||
|
|
||||||
|
email = service._discover_email("https://example.com/")
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_discover_email_exception(self, service, mock_fetcher):
|
||||||
|
"""Test email discovery handles exceptions."""
|
||||||
|
mock_fetcher.fetch.side_effect = Exception("Fetch error")
|
||||||
|
|
||||||
|
email = service._discover_email("https://example.com/")
|
||||||
|
|
||||||
|
assert email is None
|
||||||
175
tests/unit/test_html_fetcher.py
Normal file
175
tests/unit/test_html_fetcher.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Tests for HTML fetcher service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||||
|
|
||||||
|
|
||||||
|
class TestHTMLFetcherService:
|
||||||
|
"""Tests for HTMLFetcherService."""
|
||||||
|
|
||||||
|
def test_init_default_params(self):
|
||||||
|
"""Test initialization with default parameters."""
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
assert fetcher.timeout == 10
|
||||||
|
assert fetcher.max_size == 1024 * 1024
|
||||||
|
assert fetcher.max_redirects == 5
|
||||||
|
assert "Gondulf" in fetcher.user_agent
|
||||||
|
|
||||||
|
def test_init_custom_params(self):
|
||||||
|
"""Test initialization with custom parameters."""
|
||||||
|
fetcher = HTMLFetcherService(
|
||||||
|
timeout=5,
|
||||||
|
max_size=512 * 1024,
|
||||||
|
max_redirects=3,
|
||||||
|
user_agent="TestAgent/1.0"
|
||||||
|
)
|
||||||
|
assert fetcher.timeout == 5
|
||||||
|
assert fetcher.max_size == 512 * 1024
|
||||||
|
assert fetcher.max_redirects == 3
|
||||||
|
assert fetcher.user_agent == "TestAgent/1.0"
|
||||||
|
|
||||||
|
def test_fetch_requires_https(self):
|
||||||
|
"""Test that fetch requires HTTPS URLs."""
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||||
|
fetcher.fetch("http://example.com/")
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_success(self, mock_urlopen):
|
||||||
|
"""Test successful HTML fetch."""
|
||||||
|
# Mock response
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<html><body>Test</body></html>"
|
||||||
|
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||||||
|
mock_response.headers.get.return_value = None # No Content-Length header
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html == "<html><body>Test</body></html>"
|
||||||
|
mock_urlopen.assert_called_once()
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_respects_timeout(self, mock_urlopen):
|
||||||
|
"""Test that fetch respects timeout parameter."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<html></html>"
|
||||||
|
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||||||
|
mock_response.headers.get.return_value = None
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService(timeout=15)
|
||||||
|
fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
call_kwargs = mock_urlopen.call_args[1]
|
||||||
|
assert call_kwargs['timeout'] == 15
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_content_length_too_large(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None if Content-Length exceeds max_size."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.headers.get.return_value = str(2 * 1024 * 1024) # 2MB
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService(max_size=1024 * 1024) # 1MB max
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_response_too_large(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None if response exceeds max_size."""
|
||||||
|
# Create response larger than max_size
|
||||||
|
large_content = b"x" * (1024 * 1024 + 1) # 1MB + 1 byte
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = large_content
|
||||||
|
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||||||
|
mock_response.headers.get.return_value = None
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService(max_size=1024 * 1024)
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_url_error(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None on URLError."""
|
||||||
|
mock_urlopen.side_effect = URLError("Connection failed")
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_http_error(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None on HTTPError."""
|
||||||
|
mock_urlopen.side_effect = HTTPError(
|
||||||
|
"https://example.com/",
|
||||||
|
404,
|
||||||
|
"Not Found",
|
||||||
|
{},
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_timeout_error(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None on timeout."""
|
||||||
|
mock_urlopen.side_effect = TimeoutError("Request timed out")
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_unicode_decode_error(self, mock_urlopen):
|
||||||
|
"""Test that fetch returns None on Unicode decode error."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"\xff\xfe" # Invalid UTF-8
|
||||||
|
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||||||
|
mock_response.headers.get.return_value = None
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService()
|
||||||
|
# Should use 'replace' error handling and return a string
|
||||||
|
html = fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
assert html is not None # Should not fail, uses error='replace'
|
||||||
|
|
||||||
|
@patch('gondulf.services.html_fetcher.urllib.request.urlopen')
|
||||||
|
def test_fetch_sets_user_agent(self, mock_urlopen):
|
||||||
|
"""Test that fetch sets User-Agent header."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<html></html>"
|
||||||
|
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||||||
|
mock_response.headers.get.return_value = None
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_response.__exit__.return_value = None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
fetcher = HTMLFetcherService(user_agent="CustomAgent/2.0")
|
||||||
|
fetcher.fetch("https://example.com/")
|
||||||
|
|
||||||
|
# Check that User-Agent header was set
|
||||||
|
request = mock_urlopen.call_args[0][0]
|
||||||
|
assert request.get_header('User-agent') == "CustomAgent/2.0"
|
||||||
171
tests/unit/test_rate_limiter.py
Normal file
171
tests/unit/test_rate_limiter.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for rate limiter service."""
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from gondulf.services.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiter:
|
||||||
|
"""Tests for RateLimiter."""
|
||||||
|
|
||||||
|
def test_init_default_params(self):
|
||||||
|
"""Test initialization with default parameters."""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
assert limiter.max_attempts == 3
|
||||||
|
assert limiter.window_seconds == 3600
|
||||||
|
|
||||||
|
def test_init_custom_params(self):
|
||||||
|
"""Test initialization with custom parameters."""
|
||||||
|
limiter = RateLimiter(max_attempts=5, window_hours=2)
|
||||||
|
assert limiter.max_attempts == 5
|
||||||
|
assert limiter.window_seconds == 7200
|
||||||
|
|
||||||
|
def test_check_rate_limit_no_attempts(self):
|
||||||
|
"""Test rate limit check with no previous attempts."""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
assert limiter.check_rate_limit("example.com") is True
|
||||||
|
|
||||||
|
def test_check_rate_limit_within_limit(self):
|
||||||
|
"""Test rate limit check within limit."""
|
||||||
|
limiter = RateLimiter(max_attempts=3)
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
assert limiter.check_rate_limit("example.com") is True
|
||||||
|
|
||||||
|
def test_check_rate_limit_at_limit(self):
|
||||||
|
"""Test rate limit check at exact limit."""
|
||||||
|
limiter = RateLimiter(max_attempts=3)
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
assert limiter.check_rate_limit("example.com") is False
|
||||||
|
|
||||||
|
def test_check_rate_limit_exceeded(self):
|
||||||
|
"""Test rate limit check when exceeded."""
|
||||||
|
limiter = RateLimiter(max_attempts=2)
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
assert limiter.check_rate_limit("example.com") is False
|
||||||
|
|
||||||
|
def test_record_attempt_creates_entry(self):
|
||||||
|
"""Test that record_attempt creates new entry."""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
assert "example.com" in limiter._attempts
|
||||||
|
assert len(limiter._attempts["example.com"]) == 1
|
||||||
|
|
||||||
|
def test_record_attempt_appends_to_existing(self):
|
||||||
|
"""Test that record_attempt appends to existing entry."""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
assert len(limiter._attempts["example.com"]) == 2
|
||||||
|
|
||||||
|
def test_clean_old_attempts_removes_expired(self):
|
||||||
|
"""Test that old attempts are cleaned up."""
|
||||||
|
limiter = RateLimiter(max_attempts=3, window_hours=1)
|
||||||
|
|
||||||
|
# Mock time to control timestamps
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
# Move time forward past window
|
||||||
|
with patch('time.time', return_value=1000 + 3700): # 1 hour + 100 seconds
|
||||||
|
limiter._clean_old_attempts("example.com")
|
||||||
|
|
||||||
|
assert "example.com" not in limiter._attempts
|
||||||
|
|
||||||
|
def test_clean_old_attempts_preserves_recent(self):
|
||||||
|
"""Test that recent attempts are preserved."""
|
||||||
|
limiter = RateLimiter(max_attempts=3, window_hours=1)
|
||||||
|
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
# Move time forward but still within window
|
||||||
|
with patch('time.time', return_value=1000 + 1800): # 30 minutes
|
||||||
|
limiter._clean_old_attempts("example.com")
|
||||||
|
|
||||||
|
assert "example.com" in limiter._attempts
|
||||||
|
assert len(limiter._attempts["example.com"]) == 1
|
||||||
|
|
||||||
|
def test_check_rate_limit_cleans_old_attempts(self):
|
||||||
|
"""Test that check_rate_limit cleans old attempts."""
|
||||||
|
limiter = RateLimiter(max_attempts=2, window_hours=1)
|
||||||
|
|
||||||
|
# Record attempts at time 1000
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
# Check limit should be False
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
assert limiter.check_rate_limit("example.com") is False
|
||||||
|
|
||||||
|
# Move time forward past window
|
||||||
|
with patch('time.time', return_value=1000 + 3700):
|
||||||
|
# Old attempts should be cleaned, limit should pass
|
||||||
|
assert limiter.check_rate_limit("example.com") is True
|
||||||
|
|
||||||
|
def test_different_domains_independent(self):
|
||||||
|
"""Test that different domains have independent limits."""
|
||||||
|
limiter = RateLimiter(max_attempts=2)
|
||||||
|
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("other.com")
|
||||||
|
|
||||||
|
assert limiter.check_rate_limit("example.com") is False
|
||||||
|
assert limiter.check_rate_limit("other.com") is True
|
||||||
|
|
||||||
|
def test_get_remaining_attempts_initial(self):
|
||||||
|
"""Test getting remaining attempts initially."""
|
||||||
|
limiter = RateLimiter(max_attempts=3)
|
||||||
|
assert limiter.get_remaining_attempts("example.com") == 3
|
||||||
|
|
||||||
|
def test_get_remaining_attempts_after_one(self):
|
||||||
|
"""Test getting remaining attempts after one attempt."""
|
||||||
|
limiter = RateLimiter(max_attempts=3)
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
assert limiter.get_remaining_attempts("example.com") == 2
|
||||||
|
|
||||||
|
def test_get_remaining_attempts_exhausted(self):
|
||||||
|
"""Test getting remaining attempts when exhausted."""
|
||||||
|
limiter = RateLimiter(max_attempts=3)
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
assert limiter.get_remaining_attempts("example.com") == 0
|
||||||
|
|
||||||
|
def test_get_reset_time_no_attempts(self):
|
||||||
|
"""Test getting reset time with no attempts."""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
assert limiter.get_reset_time("example.com") == 0
|
||||||
|
|
||||||
|
def test_get_reset_time_with_attempts(self):
|
||||||
|
"""Test getting reset time with attempts."""
|
||||||
|
limiter = RateLimiter(window_hours=1)
|
||||||
|
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
reset_time = limiter.get_reset_time("example.com")
|
||||||
|
assert reset_time == 1000 + 3600
|
||||||
|
|
||||||
|
def test_get_reset_time_multiple_attempts(self):
|
||||||
|
"""Test getting reset time with multiple attempts (returns oldest)."""
|
||||||
|
limiter = RateLimiter(window_hours=1)
|
||||||
|
|
||||||
|
with patch('time.time', return_value=1000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
|
||||||
|
with patch('time.time', return_value=2000):
|
||||||
|
limiter.record_attempt("example.com")
|
||||||
|
# Reset time should be based on oldest attempt
|
||||||
|
reset_time = limiter.get_reset_time("example.com")
|
||||||
|
assert reset_time == 1000 + 3600
|
||||||
181
tests/unit/test_relme_parser.py
Normal file
181
tests/unit/test_relme_parser.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests for rel=me parser service."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gondulf.services.relme_parser import RelMeParser
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelMeParser:
|
||||||
|
"""Tests for RelMeParser."""
|
||||||
|
|
||||||
|
def test_parse_relme_links_basic(self):
|
||||||
|
"""Test parsing basic rel=me links."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a rel="me" href="https://github.com/user">GitHub</a>
|
||||||
|
<a rel="me" href="mailto:user@example.com">Email</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
assert len(links) == 2
|
||||||
|
assert "https://github.com/user" in links
|
||||||
|
assert "mailto:user@example.com" in links
|
||||||
|
|
||||||
|
def test_parse_relme_links_link_tag(self):
|
||||||
|
"""Test parsing rel=me from <link> tags."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="me" href="https://twitter.com/user">
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
assert len(links) == 1
|
||||||
|
assert "https://twitter.com/user" in links
|
||||||
|
|
||||||
|
def test_parse_relme_links_no_rel_me(self):
|
||||||
|
"""Test parsing HTML with no rel=me links."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a href="https://example.com">Link</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
assert len(links) == 0
|
||||||
|
|
||||||
|
def test_parse_relme_links_no_href(self):
|
||||||
|
"""Test parsing rel=me link without href."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a rel="me">No href</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
assert len(links) == 0
|
||||||
|
|
||||||
|
def test_parse_relme_links_malformed_html(self):
|
||||||
|
"""Test parsing malformed HTML returns empty list."""
|
||||||
|
html = "<html><body><<>>broken"
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
# Should not crash, returns what it can parse
|
||||||
|
assert isinstance(links, list)
|
||||||
|
|
||||||
|
def test_extract_mailto_email_basic(self):
|
||||||
|
"""Test extracting email from mailto: link."""
|
||||||
|
links = ["mailto:user@example.com"]
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email(links)
|
||||||
|
|
||||||
|
assert email == "user@example.com"
|
||||||
|
|
||||||
|
def test_extract_mailto_email_with_query(self):
|
||||||
|
"""Test extracting email from mailto: link with query parameters."""
|
||||||
|
links = ["mailto:user@example.com?subject=Hello"]
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email(links)
|
||||||
|
|
||||||
|
assert email == "user@example.com"
|
||||||
|
|
||||||
|
def test_extract_mailto_email_multiple_links(self):
|
||||||
|
"""Test extracting email from multiple links (returns first mailto:)."""
|
||||||
|
links = [
|
||||||
|
"https://github.com/user",
|
||||||
|
"mailto:user@example.com",
|
||||||
|
"mailto:other@example.com"
|
||||||
|
]
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email(links)
|
||||||
|
|
||||||
|
assert email == "user@example.com"
|
||||||
|
|
||||||
|
def test_extract_mailto_email_no_mailto(self):
|
||||||
|
"""Test extracting email when no mailto: links present."""
|
||||||
|
links = ["https://github.com/user", "https://twitter.com/user"]
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email(links)
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_extract_mailto_email_invalid_format(self):
|
||||||
|
"""Test extracting email from malformed mailto: link."""
|
||||||
|
links = ["mailto:notanemail"]
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email(links)
|
||||||
|
|
||||||
|
# Should return None for invalid email format
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_extract_mailto_email_empty_list(self):
|
||||||
|
"""Test extracting email from empty list."""
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.extract_mailto_email([])
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_find_email_success(self):
|
||||||
|
"""Test find_email combining parse and extract."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a rel="me" href="https://github.com/user">GitHub</a>
|
||||||
|
<a rel="me" href="mailto:user@example.com">Email</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.find_email(html)
|
||||||
|
|
||||||
|
assert email == "user@example.com"
|
||||||
|
|
||||||
|
def test_find_email_no_email(self):
|
||||||
|
"""Test find_email when no email present."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a rel="me" href="https://github.com/user">GitHub</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.find_email(html)
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_find_email_malformed_html(self):
|
||||||
|
"""Test find_email with malformed HTML."""
|
||||||
|
html = "<html><<broken>>"
|
||||||
|
parser = RelMeParser()
|
||||||
|
email = parser.find_email(html)
|
||||||
|
|
||||||
|
assert email is None
|
||||||
|
|
||||||
|
def test_parse_relme_multiple_rel_values(self):
|
||||||
|
"""Test parsing link with multiple rel values including 'me'."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a rel="me nofollow" href="https://example.com">Link</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
parser = RelMeParser()
|
||||||
|
links = parser.parse_relme_links(html)
|
||||||
|
|
||||||
|
assert len(links) == 1
|
||||||
|
assert "https://example.com" in links
|
||||||
199
tests/unit/test_validation.py
Normal file
199
tests/unit/test_validation.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for validation utilities."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gondulf.utils.validation import (
|
||||||
|
mask_email,
|
||||||
|
normalize_client_id,
|
||||||
|
validate_redirect_uri,
|
||||||
|
extract_domain_from_url,
|
||||||
|
validate_email
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMaskEmail:
|
||||||
|
"""Tests for mask_email function."""
|
||||||
|
|
||||||
|
def test_mask_email_basic(self):
|
||||||
|
"""Test basic email masking."""
|
||||||
|
assert mask_email("user@example.com") == "u***@example.com"
|
||||||
|
|
||||||
|
def test_mask_email_long_local(self):
|
||||||
|
"""Test masking email with long local part."""
|
||||||
|
assert mask_email("verylongusername@example.com") == "v***@example.com"
|
||||||
|
|
||||||
|
def test_mask_email_single_char_local(self):
|
||||||
|
"""Test masking email with single character local part."""
|
||||||
|
# Should return unchanged if local part is only 1 character
|
||||||
|
assert mask_email("a@example.com") == "a@example.com"
|
||||||
|
|
||||||
|
def test_mask_email_no_at_sign(self):
|
||||||
|
"""Test masking invalid email without @ sign."""
|
||||||
|
assert mask_email("notanemail") == "notanemail"
|
||||||
|
|
||||||
|
def test_mask_email_empty_string(self):
|
||||||
|
"""Test masking empty string."""
|
||||||
|
assert mask_email("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeClientId:
|
||||||
|
"""Tests for normalize_client_id function."""
|
||||||
|
|
||||||
|
def test_normalize_basic_https(self):
|
||||||
|
"""Test normalizing basic HTTPS URL."""
|
||||||
|
assert normalize_client_id("https://example.com/") == "https://example.com/"
|
||||||
|
|
||||||
|
def test_normalize_remove_default_port(self):
|
||||||
|
"""Test normalizing URL with default HTTPS port."""
|
||||||
|
assert normalize_client_id("https://example.com:443/") == "https://example.com/"
|
||||||
|
|
||||||
|
def test_normalize_preserve_non_default_port(self):
|
||||||
|
"""Test normalizing URL with non-default port."""
|
||||||
|
assert normalize_client_id("https://example.com:8443/") == "https://example.com:8443/"
|
||||||
|
|
||||||
|
def test_normalize_preserve_path(self):
|
||||||
|
"""Test normalizing URL with path."""
|
||||||
|
assert normalize_client_id("https://example.com/app") == "https://example.com/app"
|
||||||
|
|
||||||
|
def test_normalize_preserve_query(self):
|
||||||
|
"""Test normalizing URL with query string."""
|
||||||
|
assert normalize_client_id("https://example.com/?foo=bar") == "https://example.com/?foo=bar"
|
||||||
|
|
||||||
|
def test_normalize_http_scheme_raises_error(self):
|
||||||
|
"""Test that HTTP scheme raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="must use https scheme"):
|
||||||
|
normalize_client_id("http://example.com/")
|
||||||
|
|
||||||
|
def test_normalize_no_scheme_raises_error(self):
|
||||||
|
"""Test that missing scheme raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="must use https scheme"):
|
||||||
|
normalize_client_id("example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateRedirectUri:
|
||||||
|
"""Tests for validate_redirect_uri function."""
|
||||||
|
|
||||||
|
def test_validate_same_origin(self):
|
||||||
|
"""Test redirect URI with same origin as client_id."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_validate_different_path_same_origin(self):
|
||||||
|
"""Test redirect URI with different path but same origin."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"https://example.com/auth/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_validate_subdomain(self):
|
||||||
|
"""Test redirect URI on subdomain of client_id."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"https://app.example.com/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_validate_different_domain_fails(self):
|
||||||
|
"""Test redirect URI on completely different domain fails."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"https://evil.com/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_validate_localhost_http_allowed(self):
|
||||||
|
"""Test that localhost can use HTTP."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"http://localhost/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_validate_127_0_0_1_http_allowed(self):
|
||||||
|
"""Test that 127.0.0.1 can use HTTP."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"http://127.0.0.1:8000/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_validate_http_non_localhost_fails(self):
|
||||||
|
"""Test that HTTP on non-localhost fails."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"http://example.com/callback",
|
||||||
|
"https://example.com/"
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_validate_malformed_uri_fails(self):
|
||||||
|
"""Test that malformed URI fails gracefully."""
|
||||||
|
assert validate_redirect_uri(
|
||||||
|
"not a url",
|
||||||
|
"https://example.com/"
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractDomainFromUrl:
|
||||||
|
"""Tests for extract_domain_from_url function."""
|
||||||
|
|
||||||
|
def test_extract_domain_basic(self):
|
||||||
|
"""Test extracting domain from basic URL."""
|
||||||
|
assert extract_domain_from_url("https://example.com/") == "example.com"
|
||||||
|
|
||||||
|
def test_extract_domain_with_path(self):
|
||||||
|
"""Test extracting domain from URL with path."""
|
||||||
|
assert extract_domain_from_url("https://example.com/path/to/page") == "example.com"
|
||||||
|
|
||||||
|
def test_extract_domain_with_port(self):
|
||||||
|
"""Test extracting domain from URL with port."""
|
||||||
|
assert extract_domain_from_url("https://example.com:8443/") == "example.com"
|
||||||
|
|
||||||
|
def test_extract_domain_subdomain(self):
|
||||||
|
"""Test extracting subdomain."""
|
||||||
|
assert extract_domain_from_url("https://blog.example.com/") == "blog.example.com"
|
||||||
|
|
||||||
|
def test_extract_domain_no_hostname_raises_error(self):
|
||||||
|
"""Test that URL without hostname raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="URL has no hostname"):
|
||||||
|
extract_domain_from_url("file:///path/to/file")
|
||||||
|
|
||||||
|
def test_extract_domain_invalid_url_raises_error(self):
|
||||||
|
"""Test that invalid URL raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid URL"):
|
||||||
|
extract_domain_from_url("not a url")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateEmail:
|
||||||
|
"""Tests for validate_email function."""
|
||||||
|
|
||||||
|
def test_validate_email_basic(self):
|
||||||
|
"""Test validating basic email."""
|
||||||
|
assert validate_email("user@example.com") is True
|
||||||
|
|
||||||
|
def test_validate_email_with_plus(self):
|
||||||
|
"""Test validating email with plus sign."""
|
||||||
|
assert validate_email("user+tag@example.com") is True
|
||||||
|
|
||||||
|
def test_validate_email_with_dots(self):
|
||||||
|
"""Test validating email with dots."""
|
||||||
|
assert validate_email("first.last@example.com") is True
|
||||||
|
|
||||||
|
def test_validate_email_subdomain(self):
|
||||||
|
"""Test validating email with subdomain."""
|
||||||
|
assert validate_email("user@mail.example.com") is True
|
||||||
|
|
||||||
|
def test_validate_email_no_at_sign(self):
|
||||||
|
"""Test that email without @ sign fails."""
|
||||||
|
assert validate_email("notanemail") is False
|
||||||
|
|
||||||
|
def test_validate_email_no_domain(self):
|
||||||
|
"""Test that email without domain fails."""
|
||||||
|
assert validate_email("user@") is False
|
||||||
|
|
||||||
|
def test_validate_email_no_local_part(self):
|
||||||
|
"""Test that email without local part fails."""
|
||||||
|
assert validate_email("@example.com") is False
|
||||||
|
|
||||||
|
def test_validate_email_no_tld(self):
|
||||||
|
"""Test that email without TLD fails."""
|
||||||
|
assert validate_email("user@example") is False
|
||||||
|
|
||||||
|
def test_validate_email_empty_string(self):
|
||||||
|
"""Test that empty string fails."""
|
||||||
|
assert validate_email("") is False
|
||||||
123
uv.lock
generated
123
uv.lock
generated
@@ -68,6 +68,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7f/82/249a7710242b7a05f7f4245a0da3cdd4042e4377f5d00059619fa2b941f3/bandit-1.9.1-py3-none-any.whl", hash = "sha256:0a1f34c04f067ee28985b7854edaa659c9299bd71e1b7e18236e46cccc79720b", size = 134216, upload-time = "2025-11-18T00:06:04.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/82/249a7710242b7a05f7f4245a0da3cdd4042e4377f5d00059619fa2b941f3/bandit-1.9.1-py3-none-any.whl", hash = "sha256:0a1f34c04f067ee28985b7854edaa659c9299bd71e1b7e18236e46cccc79720b", size = 134216, upload-time = "2025-11-18T00:06:04.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.11.0"
|
version = "25.11.0"
|
||||||
@@ -333,8 +346,10 @@ version = "0.1.0.dev0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosmtplib" },
|
{ name = "aiosmtplib" },
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "dnspython" },
|
{ name = "dnspython" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "jinja2" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
@@ -366,6 +381,7 @@ test = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosmtplib", specifier = ">=3.0.0" },
|
{ name = "aiosmtplib", specifier = ">=3.0.0" },
|
||||||
{ name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
|
{ name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
||||||
{ name = "dnspython", specifier = ">=2.4.0" },
|
{ name = "dnspython", specifier = ">=2.4.0" },
|
||||||
{ name = "factory-boy", marker = "extra == 'test'", specifier = ">=3.2.0" },
|
{ name = "factory-boy", marker = "extra == 'test'", specifier = ">=3.2.0" },
|
||||||
@@ -374,6 +390,7 @@ requires-dist = [
|
|||||||
{ name = "freezegun", marker = "extra == 'test'", specifier = ">=1.2.0" },
|
{ name = "freezegun", marker = "extra == 'test'", specifier = ">=1.2.0" },
|
||||||
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.24.0" },
|
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.24.0" },
|
||||||
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" },
|
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
|
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||||
@@ -557,6 +574,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -569,6 +598,91 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1069,6 +1183,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.44"
|
version = "2.0.44"
|
||||||
|
|||||||
Reference in New Issue
Block a user