Started merging the two projects for simplicity
This commit is contained in:
596
src/alpine_bits_python/api.py
Normal file
596
src/alpine_bits_python/api.py
Normal file
@@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>99tales - Under Construction</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏗️ 99tales</h1>
|
||||
<h2>Under Construction</h2>
|
||||
<p>We're working hard to bring you something amazing!</p>
|
||||
<p><a href="/api">API Documentation</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user