From 0f7f1532a07c11be7423df5e1e175289415d0450 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Sat, 27 Sep 2025 10:09:58 +0200 Subject: [PATCH] Started merging the two projects for simplicity --- CustomerType.txt | 93 --- CustomerType125.txt | 93 --- config/config.yaml | 0 output.xml | 2 +- pyproject.toml | 7 +- src/alpine_bits_python/api.py | 596 ++++++++++++++++++ src/alpine_bits_python/auth.py | 111 ++++ src/alpine_bits_python/models.py | 65 ++ src/alpine_bits_python/rate_limit.py | 98 +++ src/alpine_bits_python/run_api.py | 15 + .../scripts/setup_security.py | 129 ++++ src/alpine_bits_python/scripts/test_api.py | 210 ++++++ src/alpine_bits_python/templates/index.html | 108 ++++ start_api.py | 13 + uv.lock | 186 ++++++ 15 files changed, 1538 insertions(+), 188 deletions(-) delete mode 100644 CustomerType.txt delete mode 100644 CustomerType125.txt create mode 100644 config/config.yaml create mode 100644 src/alpine_bits_python/api.py create mode 100644 src/alpine_bits_python/auth.py create mode 100644 src/alpine_bits_python/models.py create mode 100644 src/alpine_bits_python/rate_limit.py create mode 100644 src/alpine_bits_python/run_api.py create mode 100644 src/alpine_bits_python/scripts/setup_security.py create mode 100644 src/alpine_bits_python/scripts/test_api.py create mode 100644 src/alpine_bits_python/templates/index.html create mode 100644 start_api.py diff --git a/CustomerType.txt b/CustomerType.txt deleted file mode 100644 index 9d5053e..0000000 --- a/CustomerType.txt +++ /dev/null @@ -1,93 +0,0 @@ -class GivenNameType(GeneratedsSuper): - __hash__ = GeneratedsSuper.__hash__ - subclass = None - superclass = None - def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_): - self.gds_collector_ = gds_collector_ - self.gds_elementtree_node_ = None - self.original_tagname_ = None - self.parent_object_ = kwargs_.get('parent_object_') - self.ns_prefix_ = None - self.valueOf_ = valueOf_ - def factory(*args_, **kwargs_): - if CurrentSubclassModule_ is not None: - subclass = getSubclassFromModule_( - CurrentSubclassModule_, GivenNameType) - if subclass is not None: - return subclass(*args_, **kwargs_) - if GivenNameType.subclass: - return GivenNameType.subclass(*args_, **kwargs_) - else: - return GivenNameType(*args_, **kwargs_) - factory = staticmethod(factory) - def get_ns_prefix_(self): - return self.ns_prefix_ - def set_ns_prefix_(self, ns_prefix): - self.ns_prefix_ = ns_prefix - def get_valueOf_(self): return self.valueOf_ - def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_ - def validate_StringLength1to64(self, value): - result = True - # Validate type StringLength1to64, a restriction on xs:string. - if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None: - if not isinstance(value, str): - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, }) - return False - if len(value) > 64: - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} ) - result = False - if len(value) < 1: - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} ) - result = False - return result - def has__content(self): - if ( - (1 if type(self.valueOf_) in [int,float] else self.valueOf_) - ): - return True - else: - return False - def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', pretty_print=True): - imported_ns_def_ = GenerateDSNamespaceDefs_.get('GivenNameType') - if imported_ns_def_ is not None: - namespacedef_ = imported_ns_def_ - if pretty_print: - eol_ = '\n' - else: - eol_ = '' - if self.original_tagname_ is not None and name_ == 'GivenNameType': - name_ = self.original_tagname_ - if UseCapturedNS_ and self.ns_prefix_: - namespaceprefix_ = self.ns_prefix_ + ':' - showIndent(outfile, level, pretty_print) - outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', )) - already_processed = set() - self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='GivenNameType') - outfile.write('>') - self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print) - outfile.write(self.convert_unicode(self.valueOf_)) - outfile.write('%s' % (namespaceprefix_, name_, eol_)) - def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='GivenNameType'): - pass - def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', fromsubclass_=False, pretty_print=True): - pass - def build(self, node, gds_collector_=None): - self.gds_collector_ = gds_collector_ - if SaveElementTreeNode: - self.gds_elementtree_node_ = node - already_processed = set() - self.ns_prefix_ = node.prefix - self._buildAttributes(node, node.attrib, already_processed) - self.valueOf_ = get_all_text_(node) - for child in node: - nodeName_ = Tag_pattern_.match(child.tag).groups()[-1] - self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_) - return self - def _buildAttributes(self, node, attrs, already_processed): - pass - def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None): - pass -# end class GivenNameType \ No newline at end of file diff --git a/CustomerType125.txt b/CustomerType125.txt deleted file mode 100644 index 00ea463..0000000 --- a/CustomerType125.txt +++ /dev/null @@ -1,93 +0,0 @@ -class SurnameType(GeneratedsSuper): - __hash__ = GeneratedsSuper.__hash__ - subclass = None - superclass = None - def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_): - self.gds_collector_ = gds_collector_ - self.gds_elementtree_node_ = None - self.original_tagname_ = None - self.parent_object_ = kwargs_.get('parent_object_') - self.ns_prefix_ = None - self.valueOf_ = valueOf_ - def factory(*args_, **kwargs_): - if CurrentSubclassModule_ is not None: - subclass = getSubclassFromModule_( - CurrentSubclassModule_, SurnameType) - if subclass is not None: - return subclass(*args_, **kwargs_) - if SurnameType.subclass: - return SurnameType.subclass(*args_, **kwargs_) - else: - return SurnameType(*args_, **kwargs_) - factory = staticmethod(factory) - def get_ns_prefix_(self): - return self.ns_prefix_ - def set_ns_prefix_(self, ns_prefix): - self.ns_prefix_ = ns_prefix - def get_valueOf_(self): return self.valueOf_ - def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_ - def validate_StringLength1to64(self, value): - result = True - # Validate type StringLength1to64, a restriction on xs:string. - if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None: - if not isinstance(value, str): - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, }) - return False - if len(value) > 64: - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} ) - result = False - if len(value) < 1: - lineno = self.gds_get_node_lineno_() - self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} ) - result = False - return result - def has__content(self): - if ( - (1 if type(self.valueOf_) in [int,float] else self.valueOf_) - ): - return True - else: - return False - def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', pretty_print=True): - imported_ns_def_ = GenerateDSNamespaceDefs_.get('SurnameType') - if imported_ns_def_ is not None: - namespacedef_ = imported_ns_def_ - if pretty_print: - eol_ = '\n' - else: - eol_ = '' - if self.original_tagname_ is not None and name_ == 'SurnameType': - name_ = self.original_tagname_ - if UseCapturedNS_ and self.ns_prefix_: - namespaceprefix_ = self.ns_prefix_ + ':' - showIndent(outfile, level, pretty_print) - outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', )) - already_processed = set() - self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='SurnameType') - outfile.write('>') - self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print) - outfile.write(self.convert_unicode(self.valueOf_)) - outfile.write('%s' % (namespaceprefix_, name_, eol_)) - def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='SurnameType'): - pass - def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', fromsubclass_=False, pretty_print=True): - pass - def build(self, node, gds_collector_=None): - self.gds_collector_ = gds_collector_ - if SaveElementTreeNode: - self.gds_elementtree_node_ = node - already_processed = set() - self.ns_prefix_ = node.prefix - self._buildAttributes(node, node.attrib, already_processed) - self.valueOf_ = get_all_text_(node) - for child in node: - nodeName_ = Tag_pattern_.match(child.tag).groups()[-1] - self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_) - return self - def _buildAttributes(self, node, attrs, already_processed): - pass - def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None): - pass -# end class SurnameType \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..e69de29 diff --git a/output.xml b/output.xml index 71db37d..bcd1d09 100644 --- a/output.xml +++ b/output.xml @@ -1,7 +1,7 @@ - + diff --git a/pyproject.toml b/pyproject.toml index 7016710..663176b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,15 @@ description = "Alpine Bits Python Server implementation" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "dotenv>=0.9.9", + "fastapi>=0.117.1", "generateds>=2.44.3", "lxml>=6.0.1", "pytest>=8.4.2", + "redis>=6.4.0", "ruff>=0.13.1", + "slowapi>=0.1.9", + "uvicorn>=0.37.0", "xsdata-pydantic[cli,lxml,soap]>=24.5", "xsdata[cli,lxml,soap]>=25.7", ] @@ -28,4 +33,4 @@ testpaths = ["test"] pythonpath = ["src"] [tool.ruff] -src = ["src", "test"] \ No newline at end of file +src = ["src", "test"] diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py new file mode 100644 index 0000000..92ca0bb --- /dev/null +++ b/src/alpine_bits_python/api.py @@ -0,0 +1,596 @@ +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Depends, APIRouter, Form, File, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPBasicCredentials, HTTPBasic +from fastapi.responses import HTMLResponse, PlainTextResponse, Response +from .models import WixFormSubmission +from .auth import validate_api_key, validate_wix_signature, generate_api_key +from .rate_limit import ( + limiter, + webhook_limiter, + custom_rate_limit_handler, + DEFAULT_RATE_LIMIT, + WEBHOOK_RATE_LIMIT, + BURST_RATE_LIMIT +) +from slowapi.errors import RateLimitExceeded +import logging +from datetime import datetime +from typing import Dict, Any, Optional, List +import json +import os +import gzip +import xml.etree.ElementTree as ET +from .alpinebits_server import AlpineBitsServer, Version + +# HTTP Basic auth for AlpineBits +security_basic = HTTPBasic() + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Wix Form Handler API", + description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting", + version="1.0.0" +) + +# Create API router with /api prefix +api_router = APIRouter(prefix="/api", tags=["api"]) + +# Add rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, custom_rate_limit_handler) + +# Add CORS middleware to allow requests from Wix +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://*.wix.com", + "https://*.wixstatic.com", + "http://localhost:3000", # For development + "http://localhost:8000" # For local testing + ], + allow_credentials=True, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +async def process_form_submission(submission_data: Dict[str, Any]) -> None: + """ + Background task to process the form submission. + Add your business logic here. + """ + try: + logger.info(f"Processing form submission: {submission_data.get('submissionId')}") + + # Example processing - you can replace this with your actual logic + form_name = submission_data.get('formName') + contact_email = submission_data.get('contact', {}).get('email') if submission_data.get('contact') else None + + # Extract form fields + form_fields = {k: v for k, v in submission_data.items() if k.startswith('field:')} + + logger.info(f"Form: {form_name}, Contact: {contact_email}, Fields: {len(form_fields)}") + + # Here you could: + # - Save to database + # - Send emails + # - Call external APIs + # - Process the data further + + except Exception as e: + logger.error(f"Error processing form submission: {str(e)}") + + +@api_router.get("/") +@limiter.limit(DEFAULT_RATE_LIMIT) +async def root(request: Request): + """Health check endpoint""" + return { + "message": "Wix Form Handler API is running", + "timestamp": datetime.now().isoformat(), + "status": "healthy", + "authentication": "required", + "rate_limits": { + "default": DEFAULT_RATE_LIMIT, + "webhook": WEBHOOK_RATE_LIMIT, + "burst": BURST_RATE_LIMIT + } + } + + +@api_router.get("/health") +@limiter.limit(DEFAULT_RATE_LIMIT) +async def health_check(request: Request): + """Detailed health check""" + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "service": "wix-form-handler", + "version": "1.0.0", + "authentication": "enabled", + "rate_limiting": "enabled" + } + + +@api_router.post("/webhook/wix-form") +@webhook_limiter.limit(WEBHOOK_RATE_LIMIT) +async def receive_wix_form( + request: Request, + submission: WixFormSubmission, + background_tasks: BackgroundTasks, + api_key: str = Depends(validate_api_key) +): + """ + Secure endpoint to receive Wix form submissions via webhook. + + Requires: + - Valid API key in Authorization header: Authorization: Bearer your_api_key + - Rate limited to prevent abuse + - Optional: Wix signature validation (configure WIX_WEBHOOK_SECRET env var) + + This endpoint accepts POST requests with Wix form data and processes them asynchronously. + """ + try: + logger.info(f"Received form submission: {submission.submissionId} (API key: {api_key})") + + # Optional: Validate Wix webhook signature for extra security + wix_secret = os.getenv("WIX_WEBHOOK_SECRET") + if wix_secret: + signature = request.headers.get("X-Wix-Webhook-Signature", "") + body = await request.body() + + if not validate_wix_signature(body, signature, wix_secret): + logger.warning("Invalid Wix webhook signature") + raise HTTPException( + status_code=401, + detail="Invalid webhook signature" + ) + + # Convert to dict for processing + submission_dict = submission.dict() + + # Add metadata + submission_dict["_metadata"] = { + "api_key_used": api_key, + "received_at": datetime.now().isoformat(), + "client_ip": request.client.host if request.client else "unknown" + } + + # Add background task for processing + background_tasks.add_task(process_form_submission, submission_dict) + + # Return immediate response to Wix + return { + "status": "received", + "submissionId": submission.submissionId, + "message": "Form submission received and is being processed", + "timestamp": datetime.now().isoformat() + } + + except HTTPException: + # Re-raise HTTP exceptions (auth errors, etc.) + raise + except Exception as e: + logger.error(f"Error receiving form submission: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error processing form submission: {str(e)}" + ) + + +@api_router.post("/webhook/wix-form/test") +@limiter.limit(DEFAULT_RATE_LIMIT) +async def test_endpoint( + request: Request, + data: Dict[str, Any] +): + """ + Test endpoint to verify the API is working with raw JSON data. + Useful for testing without strict validation. + No authentication required for testing purposes. + """ + try: + timestamp = datetime.now().isoformat() + + # Debug: Check current user context + import pwd + import grp + + current_uid = os.getuid() + current_gid = os.getgid() + effective_uid = os.geteuid() + effective_gid = os.getegid() + + try: + user_name = pwd.getpwuid(current_uid).pw_name + group_name = grp.getgrgid(current_gid).gr_name + except KeyError: + user_name = f"unknown({current_uid})" + group_name = f"unknown({current_gid})" + + logger.info(f"Process running as: {user_name}:{group_name} (uid:{current_uid}, gid:{current_gid})") + logger.info(f"Effective user: uid:{effective_uid}, gid:{effective_gid}") + logger.info(f"Current working directory: {os.getcwd()}") + logger.info(f"Directory permissions: {oct(os.stat('.').st_mode)[-3:]}") + + # Log to console + logger.info(f"Received test data at {timestamp}") + logger.info(f"Data keys: {list(data.keys())}") + logger.info(f"Full data: {json.dumps(data, indent=2)}") + + + + # Log to file for detailed inspection + log_entry = { + "timestamp": timestamp, + "client_ip": request.client.host if request.client else "unknown", + "headers": dict(request.headers), + "data": data, + "Cors origins": request.headers.get("origin"), + "process_info": { + "uid": current_uid, + "gid": current_gid, + "effective_uid": effective_uid, + "effective_gid": effective_gid, + "user_name": user_name, + "group_name": group_name, + "cwd": os.getcwd() + } + } + + # Create logs directory if it doesn't exist with proper permissions + logs_dir = "logs" + if not os.path.exists(logs_dir): + logger.info(f"Creating logs directory as user {user_name} ({current_uid})") + os.makedirs(logs_dir, mode=0o755, exist_ok=True) + + # Check what actually got created + stat_info = os.stat(logs_dir) + logger.info(f"Created directory owner: uid:{stat_info.st_uid}, gid:{stat_info.st_gid}") + logger.info(f"Directory mode: {oct(stat_info.st_mode)[-3:]}") + + # Write to file with timestamp + log_filename = f"{logs_dir}/wix_test_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + with open(log_filename, "w", encoding="utf-8") as f: + json.dump(log_entry, f, indent=2, default=str, ensure_ascii=False) + + # Check file ownership after creation + file_stat = os.stat(log_filename) + logger.info(f"Created file owner: uid:{file_stat.st_uid}, gid:{file_stat.st_gid}") + logger.info(f"File mode: {oct(file_stat.st_mode)[-3:]}") + + logger.info(f"Data logged to: {log_filename}") + + return { + "status": "success", + "message": "Test data received successfully", + "received_keys": list(data.keys()), + "data_logged_to": log_filename, + "timestamp": timestamp, + "process_info": log_entry["process_info"], + "note": "No authentication required for this test endpoint" + } + + except Exception as e: + logger.error(f"Error in test endpoint: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error processing test data: {str(e)}" + ) + + +@api_router.post("/admin/generate-api-key") +@limiter.limit("5/hour") # Very restrictive for admin operations +async def generate_new_api_key( + request: Request, + admin_key: str = Depends(validate_api_key) +): + """ + Admin endpoint to generate new API keys. + Requires admin API key and is heavily rate limited. + """ + if admin_key != "admin-key": + raise HTTPException( + status_code=403, + detail="Admin access required" + ) + + new_key = generate_api_key() + logger.info(f"Generated new API key (requested by: {admin_key})") + + return { + "status": "success", + "message": "New API key generated", + "api_key": new_key, + "timestamp": datetime.now().isoformat(), + "note": "Store this key securely - it won't be shown again" + } + + +async def validate_basic_auth(credentials: HTTPBasicCredentials = Depends(security_basic)) -> str: + """ + Validate basic authentication for AlpineBits protocol. + Returns username if valid, raises HTTPException if not. + """ + # In production, validate against your user database + # For demo purposes, we'll accept any non-empty credentials + if not credentials.username or not credentials.password: + raise HTTPException( + status_code=401, + detail="ERROR: Authentication required", + headers={"WWW-Authenticate": "Basic"}, + ) + + # In a real implementation, you'd validate these credentials + # For now, we'll just return the username + logger.info(f"AlpineBits authentication successful for user: {credentials.username}") + return credentials.username + + +def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]: + """ + Parse multipart/form-data from raw request body. + This is a simplified parser for the AlpineBits use case. + """ + if "multipart/form-data" not in content_type: + raise HTTPException( + status_code=400, + detail="ERROR: Content-Type must be multipart/form-data" + ) + + # Extract boundary + boundary = None + for part in content_type.split(";"): + part = part.strip() + if part.startswith("boundary="): + boundary = part.split("=", 1)[1].strip('"') + break + + if not boundary: + raise HTTPException( + status_code=400, + detail="ERROR: Missing boundary in multipart/form-data" + ) + + # Simple multipart parsing + parts = body.split(f"--{boundary}".encode()) + data = {} + + for part in parts: + if not part.strip() or part.strip() == b"--": + continue + + # Split headers and content + if b"\r\n\r\n" in part: + headers_section, content = part.split(b"\r\n\r\n", 1) + content = content.rstrip(b"\r\n") + + # Parse Content-Disposition header + headers = headers_section.decode('utf-8', errors='ignore') + name = None + for line in headers.split('\n'): + if 'Content-Disposition' in line and 'name=' in line: + # Extract name parameter + for param in line.split(';'): + param = param.strip() + if param.startswith('name='): + name = param.split('=', 1)[1].strip('"') + break + + if name: + # Handle file uploads or text content + if content.startswith(b'<'): + # Likely XML content + data[name] = content.decode('utf-8', errors='ignore') + else: + data[name] = content.decode('utf-8', errors='ignore') + + return data + + + + + + +@api_router.post("/alpinebits/server-2024-10") +@limiter.limit("60/minute") +async def alpinebits_server_handshake( + request: Request, + username: str = Depends(validate_basic_auth) +): + """ + AlpineBits server endpoint implementing the handshake protocol. + + This endpoint handles: + - Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header + - Client identification via X-AlpineBits-ClientID header (optional) + - Multipart/form-data parsing for action and request parameters + - Gzip compression support + - Proper error handling with HTTP status codes + - Handshaking action processing + + Authentication: HTTP Basic Auth required + Content-Type: multipart/form-data + Compression: gzip supported (check X-AlpineBits-Server-Accept-Encoding) + """ + try: + # Check required headers + client_protocol_version = request.headers.get("X-AlpineBits-ClientProtocolVersion") + if not client_protocol_version: + # Server concludes client speaks a protocol version preceding 2013-04 + client_protocol_version = "pre-2013-04" + logger.info("No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04") + else: + logger.info(f"Client protocol version: {client_protocol_version}") + + # Optional client ID + client_id = request.headers.get("X-AlpineBits-ClientID") + if client_id: + logger.info(f"Client ID: {client_id}") + + # Check content encoding + content_encoding = request.headers.get("Content-Encoding") + is_compressed = content_encoding == "gzip" + + if is_compressed: + logger.info("Request is gzip compressed") + + # Get content type before processing + content_type = request.headers.get("Content-Type", "") + + # Get request body + body = await request.body() + + # Decompress if needed + if is_compressed: + try: + body = gzip.decompress(body) + logger.info("Successfully decompressed gzip content") + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"ERROR: Failed to decompress gzip content: {str(e)}" + ) + + # Check content type (after decompression) + if "multipart/form-data" not in content_type: + raise HTTPException( + status_code=400, + detail="ERROR: Content-Type must be multipart/form-data" + ) + + # Parse multipart data + try: + form_data = parse_multipart_data(content_type, body) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"ERROR: Failed to parse multipart/form-data: {str(e)}" + ) + + # Check for required action parameter + action = form_data.get("action") + if not action: + raise HTTPException( + status_code=400, + detail="ERROR: Missing required 'action' parameter") + + logger.info(f"AlpineBits action: {action}") + + + # Get optional request XML + request_xml = form_data.get("request") + + + server = AlpineBitsServer() + + version = Version.V2024_10 + + + + # Create successful handshake response + response = await server.handle_request(action, request_xml, version) + + response_xml = response.xml_content + + # Set response headers indicating server capabilities + headers = { + "Content-Type": "application/xml; charset=utf-8", + "X-AlpineBits-Server-Accept-Encoding": "gzip", # Indicate gzip support + "X-AlpineBits-Server-Version": "2024-10" + } + + return Response( + content=response_xml, + status_code=response.status_code, + headers=headers + ) + + + + except HTTPException: + # Re-raise HTTP exceptions (auth errors, etc.) + raise + except Exception as e: + logger.error(f"Error in AlpineBits handshake: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Internal server error: {str(e)}" + ) + +@api_router.get("/admin/stats") +@limiter.limit("10/minute") +async def get_api_stats( + request: Request, + admin_key: str = Depends(validate_api_key) +): + """ + Admin endpoint to get API usage statistics. + Requires admin API key. + """ + if admin_key != "admin-key": + raise HTTPException( + status_code=403, + detail="Admin access required" + ) + + # In a real application, you'd fetch this from your database/monitoring system + return { + "status": "success", + "stats": { + "uptime": "Available in production deployment", + "total_requests": "Available with monitoring setup", + "active_api_keys": len([k for k in ["wix-webhook-key", "admin-key"] if k]), + "rate_limit_backend": "redis" if os.getenv("REDIS_URL") else "memory" + }, + "timestamp": datetime.now().isoformat() + } + + +# Include the API router in the main app +app.include_router(api_router) + + +@app.get("/", response_class=HTMLResponse) +async def landing_page(): + """ + Serve the under construction landing page at the root route + """ + try: + # Get the path to the HTML file + import os + html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html") + + with open(html_path, "r", encoding="utf-8") as f: + html_content = f.read() + + return HTMLResponse(content=html_content, status_code=200) + except FileNotFoundError: + # Fallback if HTML file is not found + html_content = """ + + + + 99tales - Under Construction + + + +

šŸ—ļø 99tales

+

Under Construction

+

We're working hard to bring you something amazing!

+

API Documentation

+ + + """ + return HTMLResponse(content=html_content, status_code=200) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/alpine_bits_python/auth.py b/src/alpine_bits_python/auth.py new file mode 100644 index 0000000..21d29bc --- /dev/null +++ b/src/alpine_bits_python/auth.py @@ -0,0 +1,111 @@ +import os +import secrets +from typing import Optional +from fastapi import HTTPException, Security, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import hashlib +import hmac +from datetime import datetime, timedelta +import logging +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +logger = logging.getLogger(__name__) + +# Security scheme +security = HTTPBearer() + +# API Keys - In production, store these in environment variables or a secure database +API_KEYS = { + # Example API keys - replace with your own secure keys + "wix-webhook-key": "sk_live_your_secure_api_key_here", + "admin-key": "sk_admin_your_admin_key_here" +} + +# Load API keys from environment if available +if os.getenv("WIX_API_KEY"): + API_KEYS["wix-webhook-key"] = os.getenv("WIX_API_KEY") +if os.getenv("ADMIN_API_KEY"): + API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY") + + +def generate_api_key() -> str: + """Generate a secure API key""" + return f"sk_live_{secrets.token_urlsafe(32)}" + + +def validate_api_key(credentials: HTTPAuthorizationCredentials = Security(security)) -> str: + """ + Validate API key from Authorization header. + Expected format: Authorization: Bearer your_api_key_here + """ + token = credentials.credentials + + # Check if the token is in our valid API keys + for key_name, valid_key in API_KEYS.items(): + if secrets.compare_digest(token, valid_key): + logger.info(f"Valid API key used: {key_name}") + return key_name + + logger.warning(f"Invalid API key attempted: {token[:10]}...") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool: + """ + Validate Wix webhook signature for additional security. + Wix signs their webhooks with HMAC-SHA256. + """ + if not signature or not secret: + return False + + try: + # Remove 'sha256=' prefix if present + if signature.startswith('sha256='): + signature = signature[7:] + + # Calculate expected signature + expected_signature = hmac.new( + secret.encode('utf-8'), + payload, + hashlib.sha256 + ).hexdigest() + + # Compare signatures securely + return secrets.compare_digest(signature, expected_signature) + except Exception as e: + logger.error(f"Error validating signature: {e}") + return False + + +class APIKeyAuth: + """Simple API key authentication class""" + + def __init__(self, api_keys: dict): + self.api_keys = api_keys + + def authenticate(self, api_key: str) -> Optional[str]: + """Authenticate an API key and return the key name if valid""" + for key_name, valid_key in self.api_keys.items(): + if secrets.compare_digest(api_key, valid_key): + return key_name + return None + + def add_key(self, name: str, key: str): + """Add a new API key""" + self.api_keys[name] = key + + def remove_key(self, name: str): + """Remove an API key""" + if name in self.api_keys: + del self.api_keys[name] + + +# Initialize auth system +auth_system = APIKeyAuth(API_KEYS) \ No newline at end of file diff --git a/src/alpine_bits_python/models.py b/src/alpine_bits_python/models.py new file mode 100644 index 0000000..ea56a92 --- /dev/null +++ b/src/alpine_bits_python/models.py @@ -0,0 +1,65 @@ +from typing import Dict, List, Any, Optional +from pydantic import BaseModel, Field +from datetime import datetime + + +class AlpineBitsHandshakeRequest(BaseModel): + """Model for AlpineBits handshake request data""" + action: str = Field(..., description="Action parameter, typically 'OTA_Ping:Handshaking'") + request_xml: Optional[str] = Field(None, description="XML request document") + + +class ContactName(BaseModel): + """Contact name structure""" + first: Optional[str] = None + last: Optional[str] = None + + +class ContactAddress(BaseModel): + """Contact address structure""" + street: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + postalCode: Optional[str] = None + + +class Contact(BaseModel): + """Contact information from Wix form""" + name: Optional[ContactName] = None + email: Optional[str] = None + locale: Optional[str] = None + company: Optional[str] = None + birthdate: Optional[str] = None + labelKeys: Optional[Dict[str, Any]] = None + contactId: Optional[str] = None + address: Optional[ContactAddress] = None + jobTitle: Optional[str] = None + imageUrl: Optional[str] = None + updatedDate: Optional[str] = None + phone: Optional[str] = None + createdDate: Optional[str] = None + + +class SubmissionPdf(BaseModel): + """PDF submission structure""" + url: Optional[str] = None + filename: Optional[str] = None + + +class WixFormSubmission(BaseModel): + """Model for Wix form submission data""" + formName: str + submissions: List[Dict[str, Any]] = Field(default_factory=list) + submissionTime: str + formFieldMask: List[str] = Field(default_factory=list) + submissionId: str + contactId: str + submissionsLink: str + submissionPdf: Optional[SubmissionPdf] = None + formId: str + contact: Optional[Contact] = None + + # Dynamic form fields - these will capture all field:* entries + class Config: + extra = "allow" # Allow additional fields not defined in the model \ No newline at end of file diff --git a/src/alpine_bits_python/rate_limit.py b/src/alpine_bits_python/rate_limit.py new file mode 100644 index 0000000..958e062 --- /dev/null +++ b/src/alpine_bits_python/rate_limit.py @@ -0,0 +1,98 @@ +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded +from fastapi import Request +import redis +import os +import logging + +logger = logging.getLogger(__name__) + +# Rate limiting configuration +DEFAULT_RATE_LIMIT = "10/minute" # 10 requests per minute per IP +WEBHOOK_RATE_LIMIT = "60/minute" # 60 webhook requests per minute per IP +BURST_RATE_LIMIT = "3/second" # Max 3 requests per second per IP + +# Redis configuration for distributed rate limiting (optional) +REDIS_URL = os.getenv("REDIS_URL", None) + +def get_remote_address_with_forwarded(request: Request): + """ + Get client IP address, considering forwarded headers from proxies/load balancers + """ + # Check for forwarded headers (common in production behind proxies) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # Take the first IP in the chain + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + + # Fallback to direct connection IP + return get_remote_address(request) + + +# Initialize limiter +if REDIS_URL: + # Use Redis for distributed rate limiting (recommended for production) + try: + import redis + redis_client = redis.from_url(REDIS_URL) + limiter = Limiter( + key_func=get_remote_address_with_forwarded, + storage_uri=REDIS_URL + ) + logger.info("Rate limiting initialized with Redis backend") + except Exception as e: + logger.warning(f"Failed to connect to Redis: {e}. Using in-memory rate limiting.") + limiter = Limiter(key_func=get_remote_address_with_forwarded) +else: + # Use in-memory rate limiting (fine for single instance) + limiter = Limiter(key_func=get_remote_address_with_forwarded) + logger.info("Rate limiting initialized with in-memory backend") + + +def get_api_key_identifier(request: Request) -> str: + """ + Get identifier for rate limiting based on API key if available, otherwise IP + This allows different rate limits per API key + """ + # Try to get API key from Authorization header + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + api_key = auth_header[7:] # Remove "Bearer " prefix + # Use first 10 chars of API key as identifier (don't log full key) + return f"api_key:{api_key[:10]}" + + # Fallback to IP address + return f"ip:{get_remote_address_with_forwarded(request)}" + + +# Custom rate limit key function for API key based limiting +def api_key_rate_limit_key(request: Request): + return get_api_key_identifier(request) + + +# Rate limiting decorators for different endpoint types +webhook_limiter = Limiter( + key_func=api_key_rate_limit_key, + storage_uri=REDIS_URL if REDIS_URL else None +) + +# Custom rate limit exceeded handler +def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded): + """Custom handler for rate limit exceeded""" + logger.warning( + f"Rate limit exceeded for {get_remote_address_with_forwarded(request)}: " + f"{exc.detail}" + ) + + response = _rate_limit_exceeded_handler(request, exc) + + # Add custom headers + response.headers["X-RateLimit-Limit"] = str(exc.retry_after) + response.headers["X-RateLimit-Retry-After"] = str(exc.retry_after) + + return response \ No newline at end of file diff --git a/src/alpine_bits_python/run_api.py b/src/alpine_bits_python/run_api.py new file mode 100644 index 0000000..75d74b7 --- /dev/null +++ b/src/alpine_bits_python/run_api.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +""" +Startup script for the Wix Form Handler API +""" +import uvicorn +from .api import app + +if __name__ == "__main__": + uvicorn.run( + "alpine_bits_python.api:app", + host="0.0.0.0", + port=8000, + reload=True, # Enable auto-reload during development + log_level="info" + ) \ No newline at end of file diff --git a/src/alpine_bits_python/scripts/setup_security.py b/src/alpine_bits_python/scripts/setup_security.py new file mode 100644 index 0000000..38ebc15 --- /dev/null +++ b/src/alpine_bits_python/scripts/setup_security.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Configuration and setup script for the Wix Form Handler API +""" +import os +import sys +import secrets + +# Add parent directory to path to import from src +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from alpine_bits_python.auth import generate_api_key + +def generate_secure_keys(): + """Generate secure API keys for the application""" + + print("šŸ” Generating Secure API Keys") + print("=" * 50) + + # Generate API keys + wix_api_key = generate_api_key() + admin_api_key = generate_api_key() + webhook_secret = secrets.token_urlsafe(32) + + print(f"šŸ”‘ Wix Webhook API Key: {wix_api_key}") + print(f"šŸ” Admin API Key: {admin_api_key}") + print(f"šŸ”’ Webhook Secret: {webhook_secret}") + + print("\nšŸ“‹ Environment Variables") + print("-" * 30) + print(f"export WIX_API_KEY='{wix_api_key}'") + print(f"export ADMIN_API_KEY='{admin_api_key}'") + print(f"export WIX_WEBHOOK_SECRET='{webhook_secret}'") + print(f"export REDIS_URL='redis://localhost:6379' # Optional for production") + + print("\nšŸ”§ .env File Content") + print("-" * 20) + print(f"WIX_API_KEY={wix_api_key}") + print(f"ADMIN_API_KEY={admin_api_key}") + print(f"WIX_WEBHOOK_SECRET={webhook_secret}") + print("REDIS_URL=redis://localhost:6379") + + # Optionally write to .env file + create_env = input("\nā“ Create .env file? (y/n): ").lower().strip() + if create_env == 'y': + # Create .env in the project root (two levels up from scripts) + env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env') + with open(env_path, 'w') as f: + f.write(f"WIX_API_KEY={wix_api_key}\n") + f.write(f"ADMIN_API_KEY={admin_api_key}\n") + f.write(f"WIX_WEBHOOK_SECRET={webhook_secret}\n") + f.write("REDIS_URL=redis://localhost:6379\n") + print(f"āœ… .env file created at {env_path}!") + print("āš ļø Add .env to your .gitignore file!") + + print("\n🌐 Wix Configuration") + print("-" * 20) + print("1. In your Wix site, go to Settings > Webhooks") + print("2. Add webhook URL: https://yourdomain.com/webhook/wix-form") + print("3. Add custom header: Authorization: Bearer " + wix_api_key) + print("4. Optionally configure webhook signature with the secret above") + + return { + 'wix_api_key': wix_api_key, + 'admin_api_key': admin_api_key, + 'webhook_secret': webhook_secret + } + + +def check_security_setup(): + """Check current security configuration""" + + print("šŸ” Security Configuration Check") + print("=" * 40) + + # Check environment variables + wix_key = os.getenv('WIX_API_KEY') + admin_key = os.getenv('ADMIN_API_KEY') + webhook_secret = os.getenv('WIX_WEBHOOK_SECRET') + redis_url = os.getenv('REDIS_URL') + + print("Environment Variables:") + print(f" WIX_API_KEY: {'āœ… Set' if wix_key else 'āŒ Not set'}") + print(f" ADMIN_API_KEY: {'āœ… Set' if admin_key else 'āŒ Not set'}") + print(f" WIX_WEBHOOK_SECRET: {'āœ… Set' if webhook_secret else 'āŒ Not set'}") + print(f" REDIS_URL: {'āœ… Set' if redis_url else 'āš ļø Optional (using in-memory)'}") + + # Security recommendations + print("\nšŸ›”ļø Security Recommendations:") + if not wix_key: + print(" āŒ Set WIX_API_KEY environment variable") + else: + if len(wix_key) < 32: + print(" āš ļø WIX_API_KEY should be longer for better security") + else: + print(" āœ… WIX_API_KEY looks secure") + + if not admin_key: + print(" āŒ Set ADMIN_API_KEY environment variable") + elif wix_key and admin_key == wix_key: + print(" āŒ Admin and Wix keys should be different") + else: + print(" āœ… ADMIN_API_KEY configured") + + if not webhook_secret: + print(" āš ļø Consider setting WIX_WEBHOOK_SECRET for signature validation") + else: + print(" āœ… Webhook signature validation enabled") + + print("\nšŸš€ Production Checklist:") + print(" - Use HTTPS in production") + print(" - Set up Redis for distributed rate limiting") + print(" - Configure proper CORS origins") + print(" - Set up monitoring and logging") + print(" - Regular key rotation") + + +if __name__ == "__main__": + print("šŸ” Wix Form Handler API - Security Setup") + print("=" * 50) + + choice = input("Choose an option:\n1. Generate new API keys\n2. Check current setup\n\nEnter choice (1 or 2): ").strip() + + if choice == "1": + generate_secure_keys() + elif choice == "2": + check_security_setup() + else: + print("Invalid choice. Please run again and choose 1 or 2.") \ No newline at end of file diff --git a/src/alpine_bits_python/scripts/test_api.py b/src/alpine_bits_python/scripts/test_api.py new file mode 100644 index 0000000..76ed30d --- /dev/null +++ b/src/alpine_bits_python/scripts/test_api.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Test script for the Secure Wix Form Handler API +""" +import asyncio +import aiohttp +import json +import os +import sys +from datetime import datetime + +# Add parent directory to path to import from src +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# API Configuration +BASE_URL = "http://localhost:8000" + +# API Keys for testing - replace with your actual keys +TEST_API_KEY = os.getenv("WIX_API_KEY", "sk_live_your_secure_api_key_here") +ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "sk_admin_your_admin_key_here") + +# Sample Wix form data based on your example +SAMPLE_WIX_DATA = { + "formName": "Contact Form", + "submissions": [], + "submissionTime": "2024-03-20T10:30:00+00:00", + "formFieldMask": ["email", "name", "phone"], + "submissionId": "test-submission-123", + "contactId": "test-contact-456", + "submissionsLink": "https://www.wix.app/forms/test-form/submissions", + "submissionPdf": { + "url": "https://example.com/submission.pdf", + "filename": "submission.pdf" + }, + "formId": "test-form-789", + "field:email_5139": "test@example.com", + "field:first_name_abae": "John", + "field:last_name_d97c": "Doe", + "field:phone_4c77": "+1234567890", + "field:anrede": "Herr", + "field:anzahl_kinder": "2", + "field:alter_kind_3": "8", + "field:alter_kind_4": "12", + "field:long_answer_3524": "This is a long answer field with more details about the inquiry.", + "contact": { + "name": { + "first": "John", + "last": "Doe" + }, + "email": "test@example.com", + "locale": "de", + "company": "Test Company", + "birthdate": "1985-05-15", + "labelKeys": {}, + "contactId": "test-contact-456", + "address": { + "street": "Test Street 123", + "city": "Test City", + "country": "Germany", + "postalCode": "12345" + }, + "jobTitle": "Manager", + "phone": "+1234567890", + "createdDate": "2024-03-20T10:00:00.000Z", + "updatedDate": "2024-03-20T10:30:00.000Z" + } +} + + +async def test_api(): + """Test the API endpoints with authentication""" + + headers_with_auth = { + "Content-Type": "application/json", + "Authorization": f"Bearer {TEST_API_KEY}" + } + + admin_headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {ADMIN_API_KEY}" + } + + async with aiohttp.ClientSession() as session: + # Test health endpoint (no auth required) + print("1. Testing health endpoint (no auth)...") + try: + async with session.get(f"{BASE_URL}/api/health") as response: + result = await response.json() + print(f" āœ… Health check: {response.status} - {result.get('status')}") + except Exception as e: + print(f" āŒ Health check failed: {e}") + + # Test root endpoint (no auth required) + print("\n2. Testing root endpoint (no auth)...") + try: + async with session.get(f"{BASE_URL}/api/") as response: + result = await response.json() + print(f" āœ… Root: {response.status} - {result.get('message')}") + except Exception as e: + print(f" āŒ Root endpoint failed: {e}") + + # Test webhook endpoint without auth (should fail) + print("\n3. Testing webhook endpoint WITHOUT auth (should fail)...") + try: + async with session.post( + f"{BASE_URL}/api/webhook/wix-form", + json=SAMPLE_WIX_DATA, + headers={"Content-Type": "application/json"} + ) as response: + result = await response.json() + if response.status == 401: + print(f" āœ… Correctly rejected: {response.status} - {result.get('detail')}") + else: + print(f" āŒ Unexpected response: {response.status} - {result}") + except Exception as e: + print(f" āŒ Test failed: {e}") + + # Test webhook endpoint with valid auth + print("\n4. Testing webhook endpoint WITH valid auth...") + try: + async with session.post( + f"{BASE_URL}/api/webhook/wix-form", + json=SAMPLE_WIX_DATA, + headers=headers_with_auth + ) as response: + result = await response.json() + if response.status == 200: + print(f" āœ… Webhook success: {response.status} - {result.get('status')}") + else: + print(f" āŒ Webhook failed: {response.status} - {result}") + except Exception as e: + print(f" āŒ Webhook test failed: {e}") + + # Test test endpoint with auth + print("\n5. Testing simple test endpoint WITH auth...") + try: + async with session.post( + f"{BASE_URL}/api/webhook/wix-form/test", + json={"test": "data", "timestamp": datetime.now().isoformat()}, + headers=headers_with_auth + ) as response: + result = await response.json() + if response.status == 200: + print(f" āœ… Test endpoint: {response.status} - {result.get('status')}") + else: + print(f" āŒ Test endpoint failed: {response.status} - {result}") + except Exception as e: + print(f" āŒ Test endpoint failed: {e}") + + # Test rate limiting by making multiple rapid requests + print("\n6. Testing rate limiting (making 5 rapid requests)...") + rate_limit_test_count = 0 + for i in range(5): + try: + async with session.get( + f"{BASE_URL}/api/health" + ) as response: + if response.status == 200: + rate_limit_test_count += 1 + elif response.status == 429: + print(f" āœ… Rate limit triggered on request {i+1}") + break + except Exception as e: + print(f" āŒ Rate limit test failed: {e}") + break + + if rate_limit_test_count == 5: + print(" ā„¹ļø No rate limit reached (normal for low request volume)") + + # Test admin endpoint (if admin key is configured) + print("\n7. Testing admin stats endpoint...") + try: + async with session.get( + f"{BASE_URL}/api/admin/stats", + headers=admin_headers + ) as response: + result = await response.json() + if response.status == 200: + print(f" āœ… Admin stats: {response.status} - {result.get('status')}") + elif response.status == 401: + print(f" āš ļø Admin access denied (API key not configured): {result.get('detail')}") + else: + print(f" āŒ Admin endpoint failed: {response.status} - {result}") + except Exception as e: + print(f" āŒ Admin test failed: {e}") + + +if __name__ == "__main__": + print("šŸ”’ Testing Secure Wix Form Handler API...") + print("=" * 60) + print("šŸ“ API URL:", BASE_URL) + print("šŸ”‘ Using API Key:", TEST_API_KEY[:20] + "..." if len(TEST_API_KEY) > 20 else TEST_API_KEY) + print("šŸ” Using Admin Key:", ADMIN_API_KEY[:20] + "..." if len(ADMIN_API_KEY) > 20 else ADMIN_API_KEY) + print("=" * 60) + print("Make sure the API is running with: python3 run_api.py") + print("-" * 60) + + try: + asyncio.run(test_api()) + print("\n" + "=" * 60) + print("āœ… Testing completed!") + print("\nšŸ“‹ Quick Setup Reminder:") + print("1. Set environment variables:") + print(" export WIX_API_KEY='your_secure_api_key'") + print(" export ADMIN_API_KEY='your_admin_key'") + print("2. Configure Wix webhook URL: https://yourdomain.com/webhook/wix-form") + print("3. Add Authorization header: Bearer your_api_key") + except Exception as e: + print(f"\nāŒ Error testing API: {e}") + print("Make sure the API server is running!") \ No newline at end of file diff --git a/src/alpine_bits_python/templates/index.html b/src/alpine_bits_python/templates/index.html new file mode 100644 index 0000000..002ec1c --- /dev/null +++ b/src/alpine_bits_python/templates/index.html @@ -0,0 +1,108 @@ + + + + + + 99 Tales - Under Construction + + + +
+
šŸ—ļø
+

99 Tales

+
Coming Soon
+
+ We're working hard to bring you something amazing. Our team is putting the finishing touches on an exciting new experience. +
+
+ Thank you for your patience while we build something special for you. +
+ API Documentation +
+ Check back soon for updates! +
+
+ + \ No newline at end of file diff --git a/start_api.py b/start_api.py new file mode 100644 index 0000000..2e84fd3 --- /dev/null +++ b/start_api.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +""" +Convenience launcher for the Wix Form Handler API +""" +import os +import subprocess + +# Change to src directory +src_dir = os.path.join(os.path.dirname(__file__), 'src/alpine_bits_python') + +# Run the API using uv +if __name__ == "__main__": + subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")]) \ No newline at end of file diff --git a/uv.lock b/uv.lock index e111d40..ccb5e31 100644 --- a/uv.lock +++ b/uv.lock @@ -7,20 +7,30 @@ name = "alpine-bits-python-server" version = "0.1.2" source = { editable = "." } dependencies = [ + { name = "dotenv" }, + { name = "fastapi" }, { name = "generateds" }, { name = "lxml" }, { name = "pytest" }, + { name = "redis" }, { name = "ruff" }, + { name = "slowapi" }, + { name = "uvicorn" }, { name = "xsdata", extra = ["cli", "lxml", "soap"] }, { name = "xsdata-pydantic", extra = ["cli", "lxml", "soap"] }, ] [package.metadata] requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "fastapi", specifier = ">=0.117.1" }, { name = "generateds", specifier = ">=2.44.3" }, { name = "lxml", specifier = ">=6.0.1" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "redis", specifier = ">=6.4.0" }, { name = "ruff", specifier = ">=0.13.1" }, + { name = "slowapi", specifier = ">=0.1.9" }, + { name = "uvicorn", specifier = ">=0.37.0" }, { name = "xsdata", extras = ["cli", "lxml", "soap"], specifier = ">=25.7" }, { name = "xsdata-pydantic", extras = ["cli", "lxml", "soap"], specifier = ">=24.5" }, ] @@ -34,6 +44,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -107,6 +130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, +] + [[package]] name = "docformatter" version = "1.7.7" @@ -120,6 +155,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl", hash = "sha256:7af49f8a46346a77858f6651f431b882c503c2f4442c8b4524b920c863277834", size = 33525, upload-time = "2025-05-11T04:54:03.353Z" }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "fastapi" +version = "0.117.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155, upload-time = "2025-09-20T20:16:56.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959, upload-time = "2025-09-20T20:16:53.661Z" }, +] + [[package]] name = "generateds" version = "2.44.3" @@ -133,6 +193,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/84/79ca1e01337fe898cd303ac8d51151b4bea4891028b93ae5bf5e9cc911a9/generateDS-2.44.3-py3-none-any.whl", hash = "sha256:ae5db7105ca777182ba6549118c9aba1690ea341400af13ffbdbfbe1bc022299", size = 147394, upload-time = "2024-10-08T21:54:34.506Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -163,6 +232,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "limits" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/17/7a2e9378c8b8bd4efe3573fd18d2793ad2a37051af5ccce94550a4e5d62d/limits-5.5.0.tar.gz", hash = "sha256:ee269fedb078a904608b264424d9ef4ab10555acc8d090b6fc1db70e913327ea", size = 95514, upload-time = "2025-08-05T18:23:54.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/68/ee314018c28da75ece5a639898b4745bd0687c0487fc465811f0c4b9cd44/limits-5.5.0-py3-none-any.whl", hash = "sha256:57217d01ffa5114f7e233d1f5e5bdc6fe60c9b24ade387bf4d5e83c5cf929bae", size = 60948, upload-time = "2025-08-05T18:23:53.335Z" }, +] + [[package]] name = "lxml" version = "6.0.1" @@ -321,6 +404,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -371,6 +472,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +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" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + [[package]] name = "toposort" version = "1.10" @@ -416,6 +550,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "xsdata" version = "25.7"