Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48835cd857 | ||
|
|
c4bca1e4d0 | ||
|
|
42b4575cc4 | ||
|
|
a51c19ea53 |
No files matched your search
+17
-78
@@ -1,83 +1,22 @@
|
|||||||
# studspider Environment Configuration
|
# Brickset.com credentials - LEGO database for pricing, images, UPC codes
|
||||||
|
BRICKSET_USERNAME = "your_brickset_username"
|
||||||
|
BRICKSET_PASS = "your_brickset_password"
|
||||||
|
BRICKSET_API_KEY = "your_brickset_api_key"
|
||||||
|
|
||||||
# ============================================================================
|
# Square environment - "sandbox" for testing, "production" for live store
|
||||||
# Square API Configuration
|
SQUARE_ENV = "sandbox" # or "production"
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Environment: "production" or "sandbox"
|
SQUARE_WEBHOOK_URL = "https://yourdomain.com/hook"
|
||||||
SQUARE_ENV=sandbox
|
|
||||||
|
|
||||||
# Sandbox credentials
|
# Sandbox credentials - test environment (fake data, safe for development)
|
||||||
SANDBOX_SQUARE_APP_ID=your_sandbox_app_id
|
SANDBOX_SQUARE_APP_ID = "your_sandbox_app_id"
|
||||||
SANDBOX_SQUARE_TOKEN=your_sandbox_access_token
|
SANDBOX_SQUARE_TOKEN = "your_sandbox_access_token"
|
||||||
|
SANDBOX_SQUARE_WEBHOOK_INVENTORY_ID = "your_sandbox_webhook_signature_key"
|
||||||
|
|
||||||
# Production credentials
|
# Production credentials - live store (real inventory and sales)
|
||||||
PROD_SQUARE_APP_ID=your_production_app_id
|
PROD_SQUARE_APP_ID = "your_production_app_id"
|
||||||
PROD_SQUARE_TOKEN=your_production_access_token
|
PROD_SQUARE_TOKEN = "your_production_access_token"
|
||||||
|
PROD_SQUARE_WEBHOOK_INVENTORY_ID = "your_production_webhook_signature_key"
|
||||||
|
|
||||||
# Square LEGO category ID (find this in Square Dashboard)
|
# Category ID for LEGO items in Square catalog (find in Dashboard → Items → Categories)
|
||||||
SQUARE_LEGO_CAT_ID=your_lego_category_id
|
SQUARE_LEGO_CAT_ID = "your_lego_category_id"
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Brickset API Configuration
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
BRICKSET_USERNAME=your_brickset_username
|
|
||||||
BRICKSET_PASS=your_brickset_password
|
|
||||||
BRICKSET_API_KEY=your_brickset_api_key
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SSL Certificate Configuration (Optional)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# NOTE: Only needed if running HTTPS directly in Python (not recommended)
|
|
||||||
# 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 (if running HTTPS directly)
|
|
||||||
# SSL_CERT_PATH=/path/to/cert.pem
|
|
||||||
|
|
||||||
# Path to SSL private key in PEM format (if running HTTPS directly)
|
|
||||||
# SSL_KEY_PATH=/path/to/key.pem
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Square Webhook Signature Verification
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# CRITICAL: Get these from Square Developer Dashboard → Webhooks
|
|
||||||
# 1. Go to https://developer.squareup.com/apps
|
|
||||||
# 2. Select your application
|
|
||||||
# 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)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Default host and port (can override with command line args)
|
|
||||||
# SERVER_HOST=0.0.0.0
|
|
||||||
# SERVER_PORT=7432
|
|
||||||
# SERVER_HOOK_PORT=8080
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Usage Examples
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Basic HTTP server (recommended with nginx):
|
|
||||||
# python src/test_server.py
|
|
||||||
#
|
|
||||||
# With SSL from .env:
|
|
||||||
# Set SSL_CERT_PATH and SSL_KEY_PATH above, then:
|
|
||||||
# python src/test_server.py --hook-port 8443 --hook-ssl-only
|
|
||||||
#
|
|
||||||
# Override SSL from command line:
|
|
||||||
# python src/test_server.py --ssl-cert /custom/cert.pem --ssl-key /custom/key.pem
|
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
is a tool used to talk to brickset.com and a square inventory to pull lego set information for entering in the square db
|
is a tool used to talk to brickset.com, pull lego set information and automatically enter it into the square inventory!
|
||||||
## Run me
|
## Run me
|
||||||
|
|
||||||
Clone the project
|
Clone the project
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone http://git.traps.moe/ph0sgene/studspider.git
|
git clone http://git.traps.moe/ph0sgene/studspider.git
|
||||||
git checkout uv
|
git checkout branch
|
||||||
```
|
```
|
||||||
|
|
||||||
Go to the project directory
|
Go to the project directory
|
||||||
@@ -30,14 +30,15 @@ Install dependencies
|
|||||||
Start the server
|
Start the server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx run studspider.py
|
uv run studspider.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Tech
|
## Tech
|
||||||
|
|
||||||
|
- gunicorn
|
||||||
|
- uvicorn
|
||||||
- aiohttp
|
- aiohttp
|
||||||
- squareup
|
- squareup
|
||||||
- apscheduler
|
- apscheduler
|
||||||
- aiosqlite
|
- aiosqlite
|
||||||
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
"""
|
|
||||||
Gunicorn configuration for StudSpider webhook server
|
|
||||||
Production-ready settings for running with nginx reverse proxy
|
|
||||||
"""
|
|
||||||
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"path": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../kopling"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {}
|
|
||||||
}
|
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,278 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
+276
-11
@@ -1,14 +1,279 @@
|
|||||||
import src.test_server
|
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_SIGNATURE_KEY") or os.getenv(
|
||||||
|
"PROD_SQUARE_SIGNATURE_KEY"
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
class Studspider:
|
@app.get("/")
|
||||||
def __init__(self):
|
async def root():
|
||||||
self.name = "Studspider"
|
"""Landing page"""
|
||||||
self.version = "0.0.1"
|
return {
|
||||||
self.description = """
|
"service": "Studspider",
|
||||||
studspider is an application designed to interact with the Brickset API and directly with Square.
|
"status": "running",
|
||||||
It allows users to search for items, manage collections, and perform various operations related to bricksets.
|
"endpoints": {"webhook": "/hook (POST)", "health": "/health (GET)"},
|
||||||
"""
|
}
|
||||||
|
|
||||||
def run(self):
|
|
||||||
print(f"{self.name} v{self.version} is running.")
|
@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 conf/studspider: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")
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user