432 lines
14 KiB
Python
432 lines
14 KiB
Python
"""
|
|
cases_investigators_api.py - Public case files + investigator profiles
|
|
|
|
Endpoints:
|
|
POST /api/v1/cases - create/save a case snapshot
|
|
GET /api/v1/cases/:id - fetch a case by id (public)
|
|
GET /api/v1/cases - list cases by creator (auth)
|
|
DELETE /api/v1/cases/:id - delete a case (creator only)
|
|
GET /api/v1/investigators/:username - public investigator profile
|
|
GET /api/v1/portfolio/features - returns tier-gated portfolio limits for current user
|
|
POST /api/v1/subscription/addon - add an add-on to an existing subscription
|
|
|
|
Designed to work with localStorage fallback on the frontend (cases are
|
|
still public-shareable URLs even before this backend is wired).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["cases-investigators"])
|
|
|
|
|
|
# ── Storage ───────────────────────────────────────────────────
|
|
# Cases are stored in Redis if available, else in-memory dict.
|
|
# In production this should be Postgres/ClickHouse, but for the
|
|
# public-share URL contract, this gives full functionality end-to-end.
|
|
|
|
_cases_db: dict[str, dict] = {}
|
|
_investigators_db: dict[str, dict] = {}
|
|
|
|
try:
|
|
import redis as _redis # type: ignore
|
|
|
|
_r = _redis.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
db=0,
|
|
decode_responses=True,
|
|
socket_timeout=2,
|
|
)
|
|
_r.ping()
|
|
REDIS_OK = True
|
|
except Exception as e:
|
|
REDIS_OK = False
|
|
logger.warning(f"cases: redis unavailable, using in-memory store ({e})")
|
|
|
|
|
|
def _store_case(case_id: str, data: dict) -> None:
|
|
if REDIS_OK:
|
|
_r.setex(f"rmi:case:{case_id}", 60 * 60 * 24 * 365, json.dumps(data))
|
|
_cases_db[case_id] = data
|
|
|
|
|
|
def _load_case(case_id: str) -> dict | None:
|
|
if REDIS_OK:
|
|
raw = _r.get(f"rmi:case:{case_id}")
|
|
if raw:
|
|
return json.loads(raw)
|
|
return _cases_db.get(case_id)
|
|
|
|
|
|
def _delete_case(case_id: str) -> bool:
|
|
deleted = False
|
|
if REDIS_OK:
|
|
deleted = bool(_r.delete(f"rmi:case:{case_id}"))
|
|
if case_id in _cases_db:
|
|
del _cases_db[case_id]
|
|
deleted = True
|
|
return deleted
|
|
|
|
|
|
def _store_investigator(handle: str, data: dict) -> None:
|
|
if REDIS_OK:
|
|
_r.setex(f"rmi:investigator:{handle}", 60 * 60 * 24, json.dumps(data))
|
|
_investigators_db[handle] = data
|
|
|
|
|
|
def _load_investigator(handle: str) -> dict | None:
|
|
if REDIS_OK:
|
|
raw = _r.get(f"rmi:investigator:{handle}")
|
|
if raw:
|
|
return json.loads(raw)
|
|
return _investigators_db.get(handle)
|
|
|
|
|
|
# ── Models ────────────────────────────────────────────────────
|
|
|
|
|
|
class CaseWallet(BaseModel):
|
|
address: str
|
|
label: str | None = None
|
|
pct: float | None = None
|
|
|
|
|
|
class CaseCreateRequest(BaseModel):
|
|
chain: str
|
|
token_address: str
|
|
title: str
|
|
risk_score: float = Field(ge=0, le=100)
|
|
risk_level: str = "medium"
|
|
description: str | None = None
|
|
key_finding: str | None = None
|
|
findings: list[str] = []
|
|
evidence_wallets: list[CaseWallet] = []
|
|
tx_hashes: list[str] = []
|
|
bubble_svg: str | None = None
|
|
|
|
|
|
class AddonRequest(BaseModel):
|
|
addon: str = Field(..., pattern="^(PORTFOLIO_PRO)$")
|
|
period: str = Field(..., pattern="^(monthly|six_month|yearly)$")
|
|
payment_method: str = Field("x402", pattern="^(stripe|crypto|x402)$")
|
|
crypto_chain: str | None = None
|
|
|
|
|
|
# ── Cases ─────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/cases")
|
|
async def create_case(req: CaseCreateRequest, request: Request):
|
|
"""Create a public case file. Returns the case id + public URL."""
|
|
# Resolve creator if authed
|
|
creator = "anonymous"
|
|
try:
|
|
from app.domains.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
if user:
|
|
creator = (
|
|
user.get("user_metadata", {}).get("handle")
|
|
or user.get("email", "").split("@")[0]
|
|
or user.get("id", "anonymous")
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Derive deterministic id from payload
|
|
payload = {
|
|
"chain": req.chain,
|
|
"address": req.token_address,
|
|
"risk_score": req.risk_score,
|
|
"findings": req.findings,
|
|
"wallets": [w.model_dump() for w in req.evidence_wallets],
|
|
"txs": req.tx_hashes,
|
|
"title": req.title,
|
|
}
|
|
payload_sorted = json.dumps(payload, sort_keys=True)
|
|
full_hash = hashlib.sha256(payload_sorted.encode()).hexdigest()
|
|
case_id = f"rmi-{full_hash[:12]}"
|
|
|
|
full = {
|
|
"id": case_id,
|
|
"created_at": datetime.utcnow().isoformat() + "Z",
|
|
"creator": creator,
|
|
"chain": req.chain,
|
|
"token_address": req.token_address,
|
|
"title": req.title,
|
|
"description": req.description,
|
|
"risk_score": req.risk_score,
|
|
"risk_level": req.risk_level,
|
|
"key_finding": req.key_finding,
|
|
"findings": req.findings,
|
|
"evidence_wallets": [w.model_dump() for w in req.evidence_wallets],
|
|
"tx_hashes": req.tx_hashes,
|
|
"bubble_svg": req.bubble_svg,
|
|
"snapshot_hash": full_hash,
|
|
"url": f"https://rugmunch.io/case/{case_id}",
|
|
}
|
|
_store_case(case_id, full)
|
|
|
|
return {
|
|
"success": True,
|
|
"case": full,
|
|
"public_url": full["url"],
|
|
}
|
|
|
|
|
|
@router.get("/cases/{case_id}")
|
|
async def get_case(case_id: str):
|
|
"""Public, no auth required. Returns 404 if not found."""
|
|
data = _load_case(case_id)
|
|
if not data:
|
|
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
|
return data
|
|
|
|
|
|
@router.get("/cases")
|
|
async def list_my_cases(request: Request, limit: int = 50):
|
|
"""List cases created by the current user. Auth required."""
|
|
try:
|
|
from app.domains.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
except Exception:
|
|
user = None
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id")
|
|
matches = [c for c in _cases_db.values() if c.get("creator") == creator]
|
|
matches.sort(key=lambda c: c.get("created_at", ""), reverse=True)
|
|
return {"cases": matches[:limit], "total": len(matches)}
|
|
|
|
|
|
@router.delete("/cases/{case_id}")
|
|
async def delete_case(case_id: str, request: Request):
|
|
"""Delete a case. Only the creator (or anon if created anonymously) can delete."""
|
|
data = _load_case(case_id)
|
|
if not data:
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
try:
|
|
from app.domains.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
except Exception:
|
|
user = None
|
|
creator = None
|
|
if user:
|
|
creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id")
|
|
if data.get("creator") != "anonymous" and data.get("creator") != creator:
|
|
raise HTTPException(status_code=403, detail="Not your case")
|
|
_delete_case(case_id)
|
|
return {"success": True}
|
|
|
|
|
|
# ── Investigator profiles ─────────────────────────────────────
|
|
|
|
|
|
@router.get("/investigators/{username}")
|
|
async def get_investigator(username: str):
|
|
"""Public investigator profile. Returns synthesized profile if no record."""
|
|
handle = username.lower()
|
|
data = _load_investigator(handle)
|
|
if data:
|
|
return data
|
|
# Synthesize a default profile so the /u/:username page always renders
|
|
return {
|
|
"username": handle,
|
|
"display_name": handle.replace("_", " ").replace("-", " ").title() if handle != "anonymous" else "Anonymous",
|
|
"bio": f"RMI investigator @{handle}",
|
|
"joined_at": (datetime.utcnow() - timedelta(days=90)).isoformat() + "Z",
|
|
"reputation": 0,
|
|
"cases_filed": 0,
|
|
"scans_run": 0,
|
|
"accuracy": 0,
|
|
"badges": [],
|
|
}
|
|
|
|
|
|
@router.post("/investigators/{username}")
|
|
async def upsert_investigator(username: str, request: Request):
|
|
"""Update or create investigator profile. Auth required."""
|
|
try:
|
|
from app.domains.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
except Exception:
|
|
user = None
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
body = await request.json()
|
|
handle = username.lower()
|
|
data = {
|
|
"username": handle,
|
|
"display_name": body.get("display_name", handle),
|
|
"bio": body.get("bio", ""),
|
|
"avatar_url": body.get("avatar_url"),
|
|
"twitter": body.get("twitter"),
|
|
"github": body.get("github"),
|
|
"website": body.get("website"),
|
|
"telegram": body.get("telegram"),
|
|
"reputation": body.get("reputation", 0),
|
|
"cases_filed": body.get("cases_filed", 0),
|
|
"scans_run": body.get("scans_run", 0),
|
|
"accuracy": body.get("accuracy", 0),
|
|
"badges": body.get("badges", []),
|
|
"updated_at": datetime.utcnow().isoformat() + "Z",
|
|
}
|
|
_store_investigator(handle, data)
|
|
return {"success": True, "investigator": data}
|
|
|
|
|
|
# ── Portfolio features (tier-gated limits) ───────────────────
|
|
|
|
_FREE_LIMITS = {"wallets": 3, "history_days": 7, "csv_export": False, "alerts": False}
|
|
_PORTFOLIO_PRO_LIMITS = {"wallets": 50, "history_days": 30, "csv_export": True, "alerts": True}
|
|
|
|
|
|
@router.get("/portfolio/features")
|
|
async def portfolio_features(request: Request):
|
|
"""Return tier-gated Portfolio features for the current user.
|
|
|
|
Resolves the user's effective tier by checking:
|
|
1. Supabase auth → user metadata.has_portfolio_pro
|
|
2. Their active subscription (if any)
|
|
3. Falls back to FREE limits
|
|
|
|
Frontend uses this to gate wallets, history window, and exports.
|
|
"""
|
|
is_pro = False
|
|
has_subscription = False
|
|
base_tier = "FREE"
|
|
|
|
try:
|
|
from app.domains.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
except Exception:
|
|
user = None
|
|
|
|
if user:
|
|
# Check user metadata flag
|
|
if user.get("user_metadata", {}).get("has_portfolio_pro"):
|
|
is_pro = True
|
|
# Check subscription entitlements (Redis or DB)
|
|
# Convention: rmi:sub:{user_id} → JSON with {tier, addons: [PORTFOLIO_PRO], ...}
|
|
try:
|
|
if REDIS_OK:
|
|
sub_raw = _r.get(f"rmi:sub:{user.get('id')}")
|
|
if sub_raw:
|
|
sub = json.loads(sub_raw)
|
|
base_tier = sub.get("tier", "FREE")
|
|
has_subscription = True
|
|
if "PORTFOLIO_PRO" in sub.get("addons", []):
|
|
is_pro = True
|
|
except Exception:
|
|
pass
|
|
|
|
limits = _PORTFOLIO_PRO_LIMITS if is_pro else _FREE_LIMITS
|
|
return {
|
|
"tier": "PORTFOLIO_PRO" if is_pro else base_tier,
|
|
"is_portfolio_pro": is_pro,
|
|
"has_subscription": has_subscription,
|
|
"limits": limits,
|
|
"price_usd": 3.00,
|
|
"price_atoms": "3000000",
|
|
"x402_chain_id": 8453,
|
|
"x402_pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
|
"upgrade_url": "/pricing?tier=PORTFOLIO_PRO",
|
|
}
|
|
|
|
|
|
# ── Add-on subscription ──────────────────────────────────────
|
|
|
|
|
|
@router.post("/subscription/addon")
|
|
async def add_addon(req: AddonRequest, request: Request):
|
|
"""Add an add-on (e.g. PORTFOLIO_PRO) to an existing subscription.
|
|
|
|
Returns an x402 challenge for micropayment, or a stripe checkout URL
|
|
for fiat, or a crypto payment address.
|
|
"""
|
|
from app.domains.auth import get_current_user
|
|
|
|
try:
|
|
user = await get_current_user(request)
|
|
except Exception:
|
|
user = None
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
addon = req.addon.upper()
|
|
if addon == "PORTFOLIO_PRO":
|
|
price_usd = 3.00
|
|
x402_price_atoms = "3000000"
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Unknown addon {addon}")
|
|
|
|
addon_id = secrets.token_hex(8)
|
|
now = datetime.utcnow()
|
|
|
|
if req.payment_method == "x402":
|
|
# Return EIP-3009 challenge
|
|
return {
|
|
"success": True,
|
|
"addon": addon,
|
|
"addon_id": addon_id,
|
|
"user_id": user["id"],
|
|
"x402": {
|
|
"version": 2,
|
|
"scheme": "exact",
|
|
"network": "eip155:8453",
|
|
"asset": "USDC",
|
|
"amount_atoms": x402_price_atoms,
|
|
"amount_usd": price_usd,
|
|
"pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
|
"expires_at": (now + timedelta(minutes=10)).isoformat() + "Z",
|
|
},
|
|
"instructions": f"Sign EIP-3009 transfer for ${price_usd} USDC. Addon activates on settlement.",
|
|
}
|
|
|
|
if req.payment_method == "crypto":
|
|
return {
|
|
"success": True,
|
|
"addon": addon,
|
|
"addon_id": addon_id,
|
|
"user_id": user["id"],
|
|
"payment_details": {
|
|
"chain": req.crypto_chain,
|
|
"amount_usd": price_usd,
|
|
"memo": f"RMI-ADDON-{addon_id}",
|
|
},
|
|
}
|
|
|
|
# Stripe
|
|
return {
|
|
"success": True,
|
|
"addon": addon,
|
|
"addon_id": addon_id,
|
|
"user_id": user["id"],
|
|
"checkout_url": f"/pricing?addon={addon}&period={req.period}",
|
|
}
|
|
|
|
|
|
@router.get("/health")
|
|
async def health():
|
|
return {
|
|
"status": "ok",
|
|
"redis": REDIS_OK,
|
|
"cases_stored": len(_cases_db),
|
|
"investigators_stored": len(_investigators_db),
|
|
}
|