Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a560273c3f | ||
|
|
d95ce1dc7a | ||
|
|
bd8dd3b93d | ||
|
|
b3e246a6ce | ||
|
|
57a5afbe48 | ||
|
|
4320b4cba9 | ||
|
|
a8ce535985 |
No files matched your search
+20
-9
@@ -30,23 +30,34 @@ BRICKSET_API_KEY=your_brickset_api_key
|
|||||||
# SSL Certificate Configuration (Optional)
|
# SSL Certificate Configuration (Optional)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# NOTE: Only needed if running HTTPS directly (not recommended with nginx)
|
# NOTE: Only needed if running HTTPS directly in Python (not recommended)
|
||||||
# For nginx reverse proxy setup, leave these empty
|
# For production, use nginx reverse proxy with Let's Encrypt instead
|
||||||
|
# nginx handles SSL/TLS, your Python app runs HTTP internally
|
||||||
|
|
||||||
# Path to SSL certificate in PEM format
|
# Path to SSL certificate in PEM format (if running HTTPS directly)
|
||||||
# SSL_CERT_PATH=/path/to/cert.pem
|
# SSL_CERT_PATH=/path/to/cert.pem
|
||||||
|
|
||||||
# Path to SSL private key in PEM format
|
# Path to SSL private key in PEM format (if running HTTPS directly)
|
||||||
# SSL_KEY_PATH=/path/to/key.pem
|
# SSL_KEY_PATH=/path/to/key.pem
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Square Webhook Certificate (P7B Format)
|
# Square Webhook Signature Verification
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Path to P7B certificate file for Square webhook signature verification
|
# CRITICAL: Get these from Square Developer Dashboard → Webhooks
|
||||||
# This file is UPLOADED to Square Dashboard, not used by your server
|
# 1. Go to https://developer.squareup.com/apps
|
||||||
# Generate with: python convert_to_p7b.py
|
# 2. Select your application
|
||||||
SQUARE_P7B_PATH=./cert.p7b
|
# 3. Click "Webhooks" section
|
||||||
|
# 4. Create webhook subscription (Notification URL + Events)
|
||||||
|
# 5. Copy the Signature Key that Square displays
|
||||||
|
|
||||||
|
# Signature key from Square Dashboard (a string like "asdf1234")
|
||||||
|
SQUARE_WEBHOOK_SIGNATURE_KEY=your_webhook_signature_key_from_square
|
||||||
|
|
||||||
|
# Your webhook notification URL (must match EXACTLY what's in Square Dashboard)
|
||||||
|
# This should be your public HTTPS URL (through nginx)
|
||||||
|
# Example: https://yourdomain.com/hook
|
||||||
|
SQUARE_WEBHOOK_URL=https://yourdomain.com/hook
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Server Configuration (Optional)
|
# Server Configuration (Optional)
|
||||||
|
|||||||
@@ -38,3 +38,6 @@ win/
|
|||||||
bin/
|
bin/
|
||||||
activate/
|
activate/
|
||||||
build/
|
build/
|
||||||
|
.pem
|
||||||
|
.p7b
|
||||||
|
nginx/
|
||||||
@@ -18,6 +18,9 @@ dependencies = [
|
|||||||
"aiosqlite (>=0.21.0,<0.22.0)",
|
"aiosqlite (>=0.21.0,<0.22.0)",
|
||||||
"bs4>=0.0.2",
|
"bs4>=0.0.2",
|
||||||
"reflex>=0.8.14.post1",
|
"reflex>=0.8.14.post1",
|
||||||
|
"fastapi>=0.128.0",
|
||||||
|
"uvicorn>=0.40.0",
|
||||||
|
"gunicorn>=23.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+4
-3
@@ -10,9 +10,10 @@ LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID")
|
|||||||
SSL_CERT_PATH = os.getenv("SSL_CERT_PATH")
|
SSL_CERT_PATH = os.getenv("SSL_CERT_PATH")
|
||||||
SSL_KEY_PATH = os.getenv("SSL_KEY_PATH")
|
SSL_KEY_PATH = os.getenv("SSL_KEY_PATH")
|
||||||
|
|
||||||
# P7B certificate for Square webhook signature verification
|
# Square Webhook Configuration
|
||||||
# This is uploaded to Square Dashboard, not used by the server
|
# Get signature key from Square Developer Dashboard → Webhooks
|
||||||
SQUARE_P7B_PATH = os.getenv("SQUARE_P7B_PATH")
|
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||||
|
SQUARE_WEBHOOK_URL = os.getenv("SQUARE_WEBHOOK_URL")
|
||||||
|
|
||||||
if os.getenv("SQUARE_ENV") == "production":
|
if os.getenv("SQUARE_ENV") == "production":
|
||||||
SQUARE_APP_ID = os.getenv("PROD_SQUARE_APP_ID")
|
SQUARE_APP_ID = os.getenv("PROD_SQUARE_APP_ID")
|
||||||
|
|||||||
+94
-42
@@ -22,12 +22,17 @@ from time import time
|
|||||||
time = int(time())
|
time = int(time())
|
||||||
import asquare as sq
|
import asquare as sq
|
||||||
|
|
||||||
|
# Square SDK webhook validation
|
||||||
|
from square.utils.webhooks_helper import verify_signature
|
||||||
|
|
||||||
# from asquareclass import AsyncSquareClient # type: ignore
|
# from asquareclass import AsyncSquareClient # type: ignore
|
||||||
from abricksetapi import AsyncBricksetAPI
|
from abricksetapi import AsyncBricksetAPI
|
||||||
|
|
||||||
print("Loading environment variables...")
|
print("Loading environment variables...")
|
||||||
dotenv.load_dotenv()
|
dotenv.load_dotenv()
|
||||||
SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID")
|
SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID")
|
||||||
|
SQUARE_WEBHOOK_SIGNATURE_KEY = os.environ.get("SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||||
|
SQUARE_WEBHOOK_URL = os.environ.get("SQUARE_WEBHOOK_URL")
|
||||||
|
|
||||||
from asyncio_throttle.throttler import Throttler
|
from asyncio_throttle.throttler import Throttler
|
||||||
|
|
||||||
@@ -82,6 +87,11 @@ def valid_uuid(val):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory store for processed event IDs (idempotency)
|
||||||
|
processed_event_ids = set()
|
||||||
|
MAX_STORED_EVENT_IDS = 10000 # Prevent memory growth
|
||||||
|
|
||||||
|
|
||||||
async def hook_handler(request: web.Request) -> web.Response:
|
async def hook_handler(request: web.Request) -> web.Response:
|
||||||
"""Handle incoming JSON webhook POSTs on /hook endpoint"""
|
"""Handle incoming JSON webhook POSTs on /hook endpoint"""
|
||||||
try:
|
try:
|
||||||
@@ -95,21 +105,94 @@ async def hook_handler(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
return web.Response(status=405, text="Method not allowed. Use POST.")
|
return web.Response(status=405, text="Method not allowed. Use POST.")
|
||||||
|
|
||||||
# Get JSON data from request
|
# Read raw body for signature verification
|
||||||
|
body = await request.read()
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
|
||||||
|
# Verify Square webhook signature using Square SDK
|
||||||
|
signature_header = request.headers.get("x-square-hmacsha256-signature", "")
|
||||||
|
|
||||||
|
if SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL:
|
||||||
|
if not signature_header:
|
||||||
|
logger.error(
|
||||||
|
f"{current_time}\tMissing signature header from {request.remote}"
|
||||||
|
)
|
||||||
|
print(f"[@DEBUG] Missing x-square-hmacsha256-signature header")
|
||||||
|
return web.Response(status=401, text="Unauthorized: Missing signature")
|
||||||
|
|
||||||
|
# Use Square SDK's official validation function
|
||||||
|
is_valid = verify_signature(
|
||||||
|
request_body=body_str,
|
||||||
|
signature_header=signature_header,
|
||||||
|
signature_key=SQUARE_WEBHOOK_SIGNATURE_KEY,
|
||||||
|
notification_url=SQUARE_WEBHOOK_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.error(f"{current_time}\tInvalid signature from {request.remote}")
|
||||||
|
print(f"[@DEBUG] Invalid webhook signature!")
|
||||||
|
return web.Response(status=401, text="Unauthorized: Invalid signature")
|
||||||
|
|
||||||
|
print(f"[@DEBUG] ✓ Webhook signature verified")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[@DEBUG] ⚠️ Webhook signature verification DISABLED (set SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL in .env)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON data
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = json.loads(body_str)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"[@DEBUG] Invalid JSON to /hook from {request.remote}: {str(e)}")
|
print(f"[@DEBUG] Invalid JSON to /hook from {request.remote}: {str(e)}")
|
||||||
logger.error(f"Invalid JSON to /hook from {request.remote}: {str(e)}")
|
logger.error(f"Invalid JSON to /hook from {request.remote}: {str(e)}")
|
||||||
return web.Response(status=400, text="Invalid JSON in request body")
|
return web.Response(status=400, text="Invalid JSON in request body")
|
||||||
|
|
||||||
|
# Check for idempotency (event_id)
|
||||||
|
event_id = data.get("event_id")
|
||||||
|
if event_id:
|
||||||
|
if event_id in processed_event_ids:
|
||||||
|
print(
|
||||||
|
f"[@DEBUG] Duplicate event {event_id} - already processed (idempotency)"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{current_time}\tDuplicate event {event_id} from {request.remote}"
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Event already processed (idempotency)",
|
||||||
|
"event_id": event_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to processed set
|
||||||
|
processed_event_ids.add(event_id)
|
||||||
|
|
||||||
|
# Prevent memory growth - remove oldest if too many
|
||||||
|
if len(processed_event_ids) > MAX_STORED_EVENT_IDS:
|
||||||
|
processed_event_ids.pop()
|
||||||
|
|
||||||
|
# Get event info for quick response
|
||||||
|
webhook_type = data.get("type", "unknown")
|
||||||
|
event_id_val = data.get("event_id", "none")
|
||||||
|
|
||||||
|
# RESPOND IMMEDIATELY - Square requires fast response (< 2 seconds)
|
||||||
|
# Don't do any processing before responding!
|
||||||
|
response_data = {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Webhook received",
|
||||||
|
"event_id": event_id_val,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log and process AFTER sending response (async background tasks)
|
||||||
|
async def log_and_process():
|
||||||
# Log the received webhook with detailed output
|
# Log the received webhook with detailed output
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f"[@DEBUG] WEBHOOK RECEIVED at {current_time}")
|
print(f"[@DEBUG] WEBHOOK RECEIVED at {current_time}")
|
||||||
print(f"[@DEBUG] From: {request.remote}")
|
print(f"[@DEBUG] From: {request.remote}")
|
||||||
print(f"[@DEBUG] Headers: {dict(request.headers)}")
|
print(f"[@DEBUG] Headers: {dict(request.headers)}")
|
||||||
print(f"[@DEBUG] Data type: {data.get('type', 'unknown')}")
|
print(f"[@DEBUG] Data type: {webhook_type}")
|
||||||
print(f"[@DEBUG] Event ID: {data.get('event_id', 'none')}")
|
print(f"[@DEBUG] Event ID: {event_id_val}")
|
||||||
print(f"[@DEBUG] Full payload:")
|
print(f"[@DEBUG] Full payload:")
|
||||||
print(json.dumps(data, indent=2))
|
print(json.dumps(data, indent=2))
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
@@ -118,10 +201,7 @@ async def hook_handler(request: web.Request) -> web.Response:
|
|||||||
f"{current_time}\tWebhook received from {request.remote}: {json.dumps(data)}"
|
f"{current_time}\tWebhook received from {request.remote}: {json.dumps(data)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the webhook data here
|
# Process the webhook data
|
||||||
# TODO: Add your webhook processing logic
|
|
||||||
webhook_type = data.get("type", "unknown")
|
|
||||||
|
|
||||||
if webhook_type == "inventory.count.updated":
|
if webhook_type == "inventory.count.updated":
|
||||||
print(f"[@DEBUG] Processing inventory count update...")
|
print(f"[@DEBUG] Processing inventory count update...")
|
||||||
# Extract inventory data
|
# Extract inventory data
|
||||||
@@ -129,18 +209,16 @@ async def hook_handler(request: web.Request) -> web.Response:
|
|||||||
data.get("data", {}).get("object", {}).get("inventory_counts", [])
|
data.get("data", {}).get("object", {}).get("inventory_counts", [])
|
||||||
)
|
)
|
||||||
for count in inventory_counts:
|
for count in inventory_counts:
|
||||||
print(f"[@DEBUG] Catalog Object: {count.get('catalog_object_id')}")
|
print(
|
||||||
|
f"[@DEBUG] Catalog Object: {count.get('catalog_object_id')}"
|
||||||
|
)
|
||||||
print(f"[@DEBUG] Quantity: {count.get('quantity')}")
|
print(f"[@DEBUG] Quantity: {count.get('quantity')}")
|
||||||
print(f"[@DEBUG] State: {count.get('state')}")
|
print(f"[@DEBUG] State: {count.get('state')}")
|
||||||
|
|
||||||
response_data = {
|
# Start background processing (don't wait for it)
|
||||||
"status": "success",
|
asyncio.create_task(log_and_process())
|
||||||
"message": f"Webhook received: {webhook_type}",
|
|
||||||
"timestamp": current_time,
|
|
||||||
"event_id": data.get("event_id"),
|
|
||||||
"data_received": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Return response immediately
|
||||||
return web.json_response(response_data)
|
return web.json_response(response_data)
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@@ -247,7 +325,6 @@ Examples:
|
|||||||
Environment Variables (.env):
|
Environment Variables (.env):
|
||||||
SSL_CERT_PATH - Default SSL certificate path
|
SSL_CERT_PATH - Default SSL certificate path
|
||||||
SSL_KEY_PATH - Default SSL key path
|
SSL_KEY_PATH - Default SSL key path
|
||||||
SQUARE_P7B_PATH - P7B file to upload to Square Dashboard
|
|
||||||
|
|
||||||
For more information, see doc/
|
For more information, see doc/
|
||||||
""",
|
""",
|
||||||
@@ -317,23 +394,12 @@ if __name__ == "__main__":
|
|||||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
ssl_context.load_cert_chain(args.ssl_cert, args.ssl_key)
|
ssl_context.load_cert_chain(args.ssl_cert, args.ssl_key)
|
||||||
print(f"✓ SSL enabled with cert: {args.ssl_cert}")
|
print(f"✓ SSL enabled with cert: {args.ssl_cert}")
|
||||||
|
|
||||||
# Show P7B info if configured
|
|
||||||
if get_sq_env.SQUARE_P7B_PATH:
|
|
||||||
print(
|
|
||||||
f"ℹ️ Square P7B certificate for upload: {get_sq_env.SQUARE_P7B_PATH}"
|
|
||||||
)
|
|
||||||
if os.path.exists(get_sq_env.SQUARE_P7B_PATH):
|
|
||||||
print(f" ✓ P7B file exists")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ P7B file not found - run: python convert_to_p7b.py")
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
print(f"ERROR: Certificate file not found: {e}")
|
print(f"ERROR: Certificate file not found: {e}")
|
||||||
exit(1)
|
exit(1)
|
||||||
except ssl.SSLError as e:
|
except ssl.SSLError as e:
|
||||||
print(f"ERROR: Invalid certificate or key file: {e}")
|
print(f"ERROR: Invalid certificate or key file: {e}")
|
||||||
print("\nMake sure you're using PEM format files.")
|
print("\nMake sure you're using PEM format files.")
|
||||||
print("Generate certificates with: python generate_cert.py")
|
|
||||||
exit(1)
|
exit(1)
|
||||||
elif args.ssl_cert or args.ssl_key:
|
elif args.ssl_cert or args.ssl_key:
|
||||||
print("ERROR: Both --ssl-cert and --ssl-key must be provided together")
|
print("ERROR: Both --ssl-cert and --ssl-key must be provided together")
|
||||||
@@ -343,20 +409,6 @@ if __name__ == "__main__":
|
|||||||
print(" For production with nginx reverse proxy, this is recommended.")
|
print(" For production with nginx reverse proxy, this is recommended.")
|
||||||
print(" Nginx will handle HTTPS, forward HTTP to this server.")
|
print(" Nginx will handle HTTPS, forward HTTP to this server.")
|
||||||
|
|
||||||
# Show P7B info even without SSL (for nginx setup)
|
|
||||||
if get_sq_env.SQUARE_P7B_PATH:
|
|
||||||
print(
|
|
||||||
f"\nℹ️ Square P7B certificate configured: {get_sq_env.SQUARE_P7B_PATH}"
|
|
||||||
)
|
|
||||||
if os.path.exists(get_sq_env.SQUARE_P7B_PATH):
|
|
||||||
print(f" ✓ P7B file exists - upload this to Square Dashboard")
|
|
||||||
print(f" Square Dashboard → Webhooks → Manage Notification Settings")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ P7B file not found")
|
|
||||||
print(
|
|
||||||
f" Generate with: python convert_to_p7b.py --cert /path/to/nginx/cert.pem"
|
|
||||||
)
|
|
||||||
|
|
||||||
# If hook-port is specified, run a separate server for webhooks
|
# If hook-port is specified, run a separate server for webhooks
|
||||||
if args.hook_port:
|
if args.hook_port:
|
||||||
protocol_main = "https" if (ssl_context and not args.hook_ssl_only) else "http"
|
protocol_main = "https" if (ssl_context and not args.hook_ssl_only) else "http"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Gunicorn configuration for StudSpider webhook server
|
||||||
|
Production-ready settings for running with nginx reverse proxy
|
||||||
|
"""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Server socket
|
||||||
|
bind = "127.0.0.1:7433" # nginx will proxy to this
|
||||||
|
backlog = 2048
|
||||||
|
|
||||||
|
# Worker processes
|
||||||
|
workers = multiprocessing.cpu_count() * 2 + 1 # Recommended formula
|
||||||
|
worker_class = "uvicorn.workers.UvicornWorker" # Async worker for FastAPI
|
||||||
|
worker_connections = 1000
|
||||||
|
max_requests = 1000 # Restart workers after this many requests
|
||||||
|
max_requests_jitter = 50 # Add randomness to prevent all workers restarting at once
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
timeout = 30 # Workers silent for more than this are killed
|
||||||
|
keepalive = 2 # Wait this long for requests on Keep-Alive connections
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = "./logs/webhook_access.log"
|
||||||
|
errorlog = "./logs/webhook_error.log"
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "studspider-webhook"
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False # Run in foreground (systemd will handle daemonization)
|
||||||
|
pidfile = "./logs/webhook.pid"
|
||||||
|
umask = 0
|
||||||
|
user = None # Run as current user (systemd will set user)
|
||||||
|
group = None
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# SSL (not needed with nginx, but here for reference)
|
||||||
|
# keyfile = None
|
||||||
|
# certfile = None
|
||||||
|
|
||||||
|
# Security
|
||||||
|
limit_request_line = 4096
|
||||||
|
limit_request_fields = 100
|
||||||
|
limit_request_field_size = 8190
|
||||||
|
|
||||||
|
# Debugging (disable in production)
|
||||||
|
reload = os.getenv("GUNICORN_RELOAD", "false").lower() == "true"
|
||||||
|
reload_engine = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
# Hooks
|
||||||
|
def on_starting(server):
|
||||||
|
"""Called just before the master process is initialized."""
|
||||||
|
print("Studspider WEB starting...")
|
||||||
|
|
||||||
|
|
||||||
|
def on_reload(server):
|
||||||
|
"""Called to recycle workers during a reload via SIGHUP."""
|
||||||
|
print("🔄 Reloading workers...")
|
||||||
|
|
||||||
|
|
||||||
|
def when_ready(server):
|
||||||
|
"""Called just after the server is started."""
|
||||||
|
print(f"STUDSPIDER HOOK v0.1 RUNNING ON {bind}")
|
||||||
|
print(f"WORKERS: {workers}")
|
||||||
|
print(f"WORKING CLASS: {worker_class}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def worker_int(worker):
|
||||||
|
"""Called just after a worker exited on SIGINT or SIGQUIT."""
|
||||||
|
print(f"⚠️ Worker {worker.pid} received INT or QUIT signal")
|
||||||
|
|
||||||
|
|
||||||
|
def worker_abort(worker):
|
||||||
|
"""Called when a worker received the SIGABRT signal."""
|
||||||
|
print(f"❌ Worker {worker.pid} aborted")
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
Production webhook server using FastAPI + Gunicorn/Uvicorn
|
||||||
|
Receives Square webhooks with signature verification
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
from fastapi import FastAPI, Request, Response, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
# Square webhook validation
|
||||||
|
from square.utils.webhooks_helper import verify_signature
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SANDBOX_SQUARE_WEBHOOK_INVENTORY_ID")
|
||||||
|
SQUARE_WEBHOOK_URL = os.getenv("SQUARE_WEBHOOK_URL")
|
||||||
|
|
||||||
|
# Debug: Print configuration (redact key)
|
||||||
|
if SQUARE_WEBHOOK_SIGNATURE_KEY:
|
||||||
|
print(f"[CONFIG] Signature key loaded: {SQUARE_WEBHOOK_SIGNATURE_KEY[:10]}...")
|
||||||
|
else:
|
||||||
|
print("[CONFIG] WARNING: SQUARE_WEBHOOK_SIGNATURE_KEY not set!")
|
||||||
|
|
||||||
|
if SQUARE_WEBHOOK_URL:
|
||||||
|
print(f"[CONFIG] Webhook URL: {SQUARE_WEBHOOK_URL}")
|
||||||
|
else:
|
||||||
|
print("[CONFIG] WARNING: SQUARE_WEBHOOK_URL not set!")
|
||||||
|
|
||||||
|
# In-memory store for processed event IDs (idempotency)
|
||||||
|
processed_event_ids = set()
|
||||||
|
MAX_STORED_EVENT_IDS = 10000
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(title="Studspider")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Landing page"""
|
||||||
|
return {
|
||||||
|
"service": "Studspider",
|
||||||
|
"status": "running",
|
||||||
|
"endpoints": {"webhook": "/hook (POST)", "health": "/health (GET)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint for monitoring"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"signature_verification": (
|
||||||
|
"enabled" if SQUARE_WEBHOOK_SIGNATURE_KEY else "disabled"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/hook")
|
||||||
|
async def webhook_handler(request: Request):
|
||||||
|
"""
|
||||||
|
Handle Square webhook events - MUST respond within 2 seconds!
|
||||||
|
|
||||||
|
Square sends POST requests with:
|
||||||
|
- x-square-hmacsha256-signature header
|
||||||
|
- JSON body with event data
|
||||||
|
- event_id for idempotency
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Log incoming request
|
||||||
|
print(f"\n[WEBHOOK] Incoming request from {request.client.host}")
|
||||||
|
print(f"[WEBHOOK] Method: {request.method}")
|
||||||
|
print(f"[WEBHOOK] URL: {request.url}")
|
||||||
|
print(f"[WEBHOOK] Headers: {dict(request.headers)}")
|
||||||
|
|
||||||
|
# Read raw body for signature verification
|
||||||
|
body = await request.body()
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
print(f"[WEBHOOK] Body length: {len(body_str)} bytes")
|
||||||
|
|
||||||
|
# Get signature header
|
||||||
|
signature_header = request.headers.get("x-square-hmacsha256-signature", "")
|
||||||
|
|
||||||
|
# Verify Square webhook signature (if configured)
|
||||||
|
if SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL:
|
||||||
|
if not signature_header:
|
||||||
|
print(
|
||||||
|
f"[ERROR] Missing x-square-hmacsha256-signature header from {request.client.host}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
content={"error": "Missing signature header"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debug signature verification inputs
|
||||||
|
print(f"[DEBUG] Signature header: {signature_header[:20]}...")
|
||||||
|
print(f"[DEBUG] Signature key (first 10 chars): {SQUARE_WEBHOOK_SIGNATURE_KEY[:10]}...")
|
||||||
|
print(f"[DEBUG] Notification URL: {SQUARE_WEBHOOK_URL}")
|
||||||
|
print(f"[DEBUG] Body (first 100 chars): {body_str[:100]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_valid = verify_signature(
|
||||||
|
request_body=body_str,
|
||||||
|
signature_header=signature_header,
|
||||||
|
signature_key=SQUARE_WEBHOOK_SIGNATURE_KEY,
|
||||||
|
notification_url=SQUARE_WEBHOOK_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
print(
|
||||||
|
f"[ERROR] Invalid webhook signature from {request.client.host}"
|
||||||
|
)
|
||||||
|
print(f"[ERROR] This usually means:")
|
||||||
|
print(f"[ERROR] 1. SQUARE_WEBHOOK_URL doesn't match Square Dashboard exactly")
|
||||||
|
print(f"[ERROR] 2. SQUARE_WEBHOOK_SIGNATURE_KEY is incorrect")
|
||||||
|
print(f"[ERROR] 3. Request body was modified in transit")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
content={"error": "Invalid signature"},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[INFO] ✓ Webhook signature verified from {request.client.host}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Signature verification exception: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
content={"error": f"Signature verification failed: {str(e)}"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[WARN] Webhook signature verification DISABLED - configure SQUARE_WEBHOOK_SIGNATURE_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON data
|
||||||
|
try:
|
||||||
|
data = json.loads(body_str)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[ERROR] Invalid JSON from {request.client.host}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content={"error": "Invalid JSON"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check idempotency (event_id)
|
||||||
|
event_id = data.get("event_id")
|
||||||
|
if event_id:
|
||||||
|
if event_id in processed_event_ids:
|
||||||
|
print(
|
||||||
|
f"[INFO] Duplicate event {event_id} - already processed (idempotency)"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
"message": "Event already processed",
|
||||||
|
"event_id": event_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to processed set
|
||||||
|
processed_event_ids.add(event_id)
|
||||||
|
|
||||||
|
# Prevent memory growth
|
||||||
|
if len(processed_event_ids) > MAX_STORED_EVENT_IDS:
|
||||||
|
processed_event_ids.pop()
|
||||||
|
|
||||||
|
# Get event info for quick response
|
||||||
|
webhook_type = data.get("type", "unknown")
|
||||||
|
event_id_val = data.get("event_id", "none")
|
||||||
|
merchant_id = data.get("merchant_id", "unknown")
|
||||||
|
|
||||||
|
# RESPOND IMMEDIATELY - Square requires fast response (< 2 seconds)
|
||||||
|
response_data = {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Webhook received",
|
||||||
|
"event_id": event_id_val,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process webhook in background (async task)
|
||||||
|
asyncio.create_task(
|
||||||
|
process_webhook(
|
||||||
|
webhook_type, event_id_val, merchant_id, data, request.client.host
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return immediate response
|
||||||
|
print(f"[INFO] 200 OK response sent for event {event_id_val}")
|
||||||
|
return JSONResponse(status_code=status.HTTP_200_OK, content=response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Catch-all for any unexpected errors - still respond quickly
|
||||||
|
print(f"[ERROR] Unexpected error in webhook handler: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={"error": "Internal server error"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return immediate response
|
||||||
|
return JSONResponse(status_code=status.HTTP_200_OK, content=response_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_webhook(
|
||||||
|
webhook_type: str,
|
||||||
|
event_id: str,
|
||||||
|
merchant_id: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
client_ip: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Process webhook data asynchronously after responding to Square
|
||||||
|
|
||||||
|
This runs in the background and doesn't block the HTTP response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"[WEBHOOK] Received at {asyncio.get_event_loop().time()}")
|
||||||
|
print(f"[WEBHOOK] From: {client_ip}")
|
||||||
|
print(f"[WEBHOOK] Type: {webhook_type}")
|
||||||
|
print(f"[WEBHOOK] Event ID: {event_id}")
|
||||||
|
print(f"[WEBHOOK] Merchant: {merchant_id}")
|
||||||
|
print(f"[WEBHOOK] Full payload:")
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
# Process based on webhook type
|
||||||
|
if webhook_type == "inventory.count.updated":
|
||||||
|
print(f"[PROCESS] Processing inventory count update...")
|
||||||
|
inventory_counts = (
|
||||||
|
data.get("data", {}).get("object", {}).get("inventory_counts", [])
|
||||||
|
)
|
||||||
|
for count in inventory_counts:
|
||||||
|
catalog_id = count.get("catalog_object_id")
|
||||||
|
quantity = count.get("quantity")
|
||||||
|
state = count.get("state")
|
||||||
|
print(
|
||||||
|
f"[PROCESS] Catalog: {catalog_id}, Qty: {quantity}, State: {state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif webhook_type == "catalog.version.updated":
|
||||||
|
print(f"[PROCESS] Processing catalog version update...")
|
||||||
|
# Add your catalog processing logic here
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[PROCESS] Webhook type '{webhook_type}' - no handler configured")
|
||||||
|
|
||||||
|
print(f"[PROCESS] ✓ Webhook processing complete for {event_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to process webhook {event_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# For development/testing - use uvicorn directly
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Studspider webhook boot dev")
|
||||||
|
print("=" * 60)
|
||||||
|
print(
|
||||||
|
f"Signature verification: {'ENABLED' if SQUARE_WEBHOOK_SIGNATURE_KEY else 'DISABLED'}"
|
||||||
|
)
|
||||||
|
print(f"Webhook URL: {SQUARE_WEBHOOK_URL or 'NOT CONFIGURED'}")
|
||||||
|
print("\nFor production, use Gunicorn:")
|
||||||
|
print(
|
||||||
|
" gunicorn webhook_server:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:7433"
|
||||||
|
)
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=7433, log_level="info")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Simple test script to verify Square webhook signature verification works correctly.
|
||||||
|
|
||||||
|
This uses the same test data from Square's documentation to ensure our implementation
|
||||||
|
matches their expected behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "src")
|
||||||
|
|
||||||
|
from square.utils.webhooks_helper import verify_signature
|
||||||
|
|
||||||
|
# Test data from Square's documentation
|
||||||
|
TEST_BODY = '{"hello":"world"}'
|
||||||
|
TEST_SIGNATURE = "2kRE5qRU2tR+tBGlDwMEw2avJ7QM4ikPYD/PJ3bd9Og="
|
||||||
|
TEST_SIGNATURE_KEY = "asdf1234"
|
||||||
|
TEST_NOTIFICATION_URL = "https://example.com/webhook"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_validation():
|
||||||
|
"""Test that signature validation works with Square SDK"""
|
||||||
|
|
||||||
|
print("Testing Square webhook signature validation...")
|
||||||
|
print(f"Body: {TEST_BODY}")
|
||||||
|
print(f"Signature: {TEST_SIGNATURE}")
|
||||||
|
print(f"Key: {TEST_SIGNATURE_KEY}")
|
||||||
|
print(f"URL: {TEST_NOTIFICATION_URL}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# This should return True with Square's test data
|
||||||
|
is_valid = verify_signature(
|
||||||
|
request_body=TEST_BODY,
|
||||||
|
signature_header=TEST_SIGNATURE,
|
||||||
|
signature_key=TEST_SIGNATURE_KEY,
|
||||||
|
notification_url=TEST_NOTIFICATION_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
print("✅ SUCCESS: Signature validation passed!")
|
||||||
|
print("The webhook implementation is using Square SDK correctly.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ FAILURE: Signature validation failed!")
|
||||||
|
print("Something is wrong with the signature verification.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = test_signature_validation()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -92,6 +92,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -239,6 +248,21 @@ 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" },
|
{ 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.128.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -330,9 +354,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "23.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -949,11 +987,14 @@ dependencies = [
|
|||||||
{ name = "asyncio-throttle" },
|
{ name = "asyncio-throttle" },
|
||||||
{ name = "bs4" },
|
{ name = "bs4" },
|
||||||
{ name = "dotenv" },
|
{ name = "dotenv" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "reflex" },
|
{ name = "reflex" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "squareup" },
|
{ name = "squareup" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -964,11 +1005,14 @@ requires-dist = [
|
|||||||
{ name = "asyncio-throttle", specifier = ">=1.0.2,<2.0.0" },
|
{ name = "asyncio-throttle", specifier = ">=1.0.2,<2.0.0" },
|
||||||
{ name = "bs4", specifier = ">=0.0.2" },
|
{ name = "bs4", specifier = ">=0.0.2" },
|
||||||
{ name = "dotenv", specifier = ">=0.9.9,<0.10.0" },
|
{ name = "dotenv", specifier = ">=0.9.9,<0.10.0" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||||
|
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||||
{ name = "pyinstaller", specifier = ">=6.14.1,<7.0.0" },
|
{ name = "pyinstaller", specifier = ">=6.14.1,<7.0.0" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2,<9.0.0" },
|
{ name = "pytest", specifier = ">=8.4.2,<9.0.0" },
|
||||||
{ name = "reflex", specifier = ">=0.8.14.post1" },
|
{ name = "reflex", specifier = ">=0.8.14.post1" },
|
||||||
{ name = "requests", specifier = ">=2.32.4,<3.0.0" },
|
{ name = "requests", specifier = ">=2.32.4,<3.0.0" },
|
||||||
{ name = "squareup", specifier = ">=43.0.0.20250618,<44.0.0.0" },
|
{ name = "squareup", specifier = ">=43.0.0.20250618,<44.0.0.0" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.40.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1022,6 +1066,19 @@ 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" },
|
{ 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.40.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|||||||
Reference in new issue
Block a user