rmi-backend/app/routers/cases_investigators_api.py

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),
}