Core trust scoring: - Five metadata signals (account age, feedback count/ratio, price vs market, category history), composited 0–100 - CV-based price signal suppression for heterogeneous search results (e.g. mixed laptop generations won't false-positive suspicious_price) - Expanded scratch/dent title detection: evasive redirects, functional problem phrases, DIY/repair indicators - Hard filters: new_account, established_bad_actor - Soft flags: low_feedback, suspicious_price, duplicate_photo, scratch_dent, long_on_market, significant_price_drop Search & filtering: - Browse API adapter (up to 200 items/page) + Playwright scraper fallback - OR-group query expansion for comprehensive variant coverage - Must-include (AND/ANY/groups), must-exclude, category, price range filters - Saved searches with full filter round-trip via URL params Seller enrichment: - Background BTF /itm/ scraping for account age (Kasada-safe headed Chromium) - On-demand enrichment: POST /api/enrich + ListingCard ↻ button - Category history derived from Browse API categories field (free, no extra calls) - Shopping API GetUserProfile inline enrichment for API adapter Market comps: - eBay Marketplace Insights API with Browse API fallback (catches 403 + 404) - Comps prioritised in ThreadPoolExecutor (submitted first) Infrastructure: - Staging DB fields: times_seen, first_seen_at, price_at_first_seen, category_name - Migrations 004 (staging tracking) + 005 (listing category) - eBay webhook handler stub - Cloud compose stack (compose.cloud.yml) - Vue frontend: search store, saved searches store, ListingCard, filter sidebar Docs: - README fully rewritten to reflect MVP status + full feature documentation - Roadmap table linked to all 13 Forgejo issues
149 lines
6.1 KiB
Python
149 lines
6.1 KiB
Python
"""eBay Marketplace Account Deletion webhook.
|
|
|
|
Required to activate eBay production API credentials.
|
|
|
|
Protocol (https://developer.ebay.com/develop/guides-v2/marketplace-user-account-deletion):
|
|
|
|
GET /api/ebay/account-deletion?challenge_code=<hex>
|
|
→ {"challengeResponse": SHA256(code + token + endpoint_url)}
|
|
|
|
POST /api/ebay/account-deletion
|
|
Header: X-EBAY-SIGNATURE: <base64-JSON {"kid": "...", "signature": "<b64>"}>
|
|
Body: JSON notification payload
|
|
→ 200 on valid + deleted, 412 on bad signature
|
|
|
|
Public keys are fetched from the eBay Notification API and cached for 1 hour.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import requests
|
|
from fastapi import APIRouter, Header, HTTPException, Request
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
|
from cryptography.hazmat.primitives.hashes import SHA1
|
|
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
|
|
from app.db.store import Store
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
|
|
|
# ── Public-key cache ──────────────────────────────────────────────────────────
|
|
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
|
_KEY_CACHE_TTL = 3600
|
|
_key_cache: dict[str, tuple[bytes, float]] = {} # kid → (pem_bytes, expiry)
|
|
|
|
# The eBay Notification service is a unified production-side system — signing keys
|
|
# always live at api.ebay.com regardless of whether the app uses sandbox or production
|
|
# Browse API credentials.
|
|
_EBAY_KEY_URL = "https://api.ebay.com/commerce/notification/v1/public_key/{kid}"
|
|
|
|
|
|
def _fetch_public_key(kid: str) -> bytes:
|
|
"""Return PEM public key bytes for the given kid, using a 1-hour cache."""
|
|
cached = _key_cache.get(kid)
|
|
if cached and time.time() < cached[1]:
|
|
return cached[0]
|
|
|
|
key_url = _EBAY_KEY_URL.format(kid=kid)
|
|
resp = requests.get(key_url, timeout=10)
|
|
if not resp.ok:
|
|
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
|
resp.raise_for_status()
|
|
pem_str: str = resp.json()["key"]
|
|
pem_bytes = pem_str.encode()
|
|
_key_cache[kid] = (pem_bytes, time.time() + _KEY_CACHE_TTL)
|
|
return pem_bytes
|
|
|
|
|
|
# ── GET — challenge verification ──────────────────────────────────────────────
|
|
|
|
@router.get("/api/ebay/account-deletion")
|
|
def ebay_challenge(challenge_code: str):
|
|
"""Respond to eBay's endpoint verification challenge.
|
|
|
|
eBay sends this GET once when you register the endpoint URL.
|
|
Response must be the SHA-256 hex digest of (code + token + endpoint).
|
|
"""
|
|
token = os.environ.get("EBAY_NOTIFICATION_TOKEN", "")
|
|
endpoint = os.environ.get("EBAY_NOTIFICATION_ENDPOINT", "")
|
|
if not token or not endpoint:
|
|
log.error("EBAY_NOTIFICATION_TOKEN or EBAY_NOTIFICATION_ENDPOINT not set")
|
|
raise HTTPException(status_code=500, detail="Webhook not configured")
|
|
|
|
digest = hashlib.sha256(
|
|
(challenge_code + token + endpoint).encode()
|
|
).hexdigest()
|
|
return {"challengeResponse": digest}
|
|
|
|
|
|
# ── POST — deletion notification ──────────────────────────────────────────────
|
|
|
|
@router.post("/api/ebay/account-deletion", status_code=200)
|
|
async def ebay_account_deletion(
|
|
request: Request,
|
|
x_ebay_signature: Optional[str] = Header(default=None),
|
|
):
|
|
"""Process an eBay Marketplace Account Deletion notification.
|
|
|
|
Verifies the ECDSA/SHA1 signature, then permanently deletes all stored
|
|
data (sellers + listings) for the named eBay user.
|
|
"""
|
|
body_bytes = await request.body()
|
|
|
|
# 1. Parse and verify signature header
|
|
if not x_ebay_signature:
|
|
log.warning("ebay_account_deletion: missing X-EBAY-SIGNATURE header")
|
|
raise HTTPException(status_code=412, detail="Missing signature")
|
|
|
|
try:
|
|
sig_json = json.loads(base64.b64decode(x_ebay_signature))
|
|
kid: str = sig_json["kid"]
|
|
sig_b64: str = sig_json["signature"]
|
|
sig_bytes = base64.b64decode(sig_b64)
|
|
except Exception as exc:
|
|
log.warning("ebay_account_deletion: malformed signature header — %s", exc)
|
|
raise HTTPException(status_code=412, detail="Malformed signature header")
|
|
|
|
# 2. Fetch and verify with eBay public key
|
|
# EBAY_WEBHOOK_VERIFY_SIGNATURES=false skips ECDSA during sandbox/registration phase.
|
|
# Set to true (default) once production credentials are active.
|
|
skip_verify = os.environ.get("EBAY_WEBHOOK_VERIFY_SIGNATURES", "true").lower() == "false"
|
|
if skip_verify:
|
|
log.warning("ebay_account_deletion: signature verification DISABLED — enable before production")
|
|
else:
|
|
try:
|
|
pem_bytes = _fetch_public_key(kid)
|
|
pub_key = load_pem_public_key(pem_bytes)
|
|
pub_key.verify(sig_bytes, body_bytes, ECDSA(SHA1()))
|
|
except InvalidSignature:
|
|
log.warning("ebay_account_deletion: ECDSA signature verification failed (kid=%s)", kid)
|
|
raise HTTPException(status_code=412, detail="Signature verification failed")
|
|
except Exception as exc:
|
|
log.error("ebay_account_deletion: unexpected error during verification — %s", exc)
|
|
raise HTTPException(status_code=412, detail="Verification error")
|
|
|
|
# 3. Extract username from notification payload and delete data
|
|
try:
|
|
payload = json.loads(body_bytes)
|
|
username: str = payload["notification"]["data"]["username"]
|
|
except (KeyError, json.JSONDecodeError) as exc:
|
|
log.error("ebay_account_deletion: could not parse payload — %s", exc)
|
|
raise HTTPException(status_code=400, detail="Unrecognisable payload")
|
|
|
|
store = Store(_DB_PATH)
|
|
store.delete_seller_data("ebay", username)
|
|
log.info("ebay_account_deletion: deleted data for eBay user %r", username)
|
|
return {}
|