merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
432
app/routers/cases_investigators_api.py
Normal file
432
app/routers/cases_investigators_api.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""
|
||||
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.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.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.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.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.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.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),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue