Started merging the two projects for simplicity

This commit is contained in:
Jonas Linter
2025-09-27 10:09:58 +02:00
parent 4cfc00abb1
commit 0f7f1532a0
15 changed files with 1538 additions and 188 deletions

View 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)