Some checks failed
CI / build (pull_request) Failing after 2s
The reported symptom (AttributeError: module 'bcrypt' has no attribute
'__about__') was misleading. passlib 1.7.4 (the latest released version
— the project is unmaintained) actually traps that AttributeError with
a bare `except:` and continues. The real failure occurs later in
`passlib.handlers.bcrypt._finalize_backend_mixin`:
detect_wrap_bug -> verify(secret, bug_hash)
where secret = (b"0123456789"*26)[:255] # 255-byte test secret
bcrypt 4.0+ removed silent 72-byte truncation and now raises
`ValueError: password cannot be longer than 72 bytes`, breaking the
backend probe and crashing every `pwd_context.hash()/verify()` call.
Three options were considered:
A. Upgrade passlib — impossible, 1.7.4 is the latest PyPI release.
B. Pin bcrypt<4.0 — conflicts with chromadb 1.1.1 (>=4.0.1 required),
even though chromadb's bcrypt usage is optional and still works
at runtime in practice.
C. Shim bcrypt.__about__ — doesn't fix the actual truncation error.
Chosen: refactor app/auth.py to use bcrypt directly for backup-code
hashing too (the main `hash_password`/`verify_password` already did).
Drops the passlib dependency entirely, restoring bcrypt 5.x for chromadb
compatibility and eliminating the unmaintained passlib pin.
Verified:
* hash_password / verify_password round-trip OK
* _hash_backup_code / _verify_backup_code round-trip OK on
freshly generated 8-digit backup codes
* `pytest tests/unit` -> 717 passed, 32 warnings, 60 subtests passed
588 lines
19 KiB
Python
588 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Bulletin Board & Badge API Router
|
|
Refactored to use supabase_service for clean, typed database access.
|
|
Includes bot registration, fee tracking, and bot-specific endpoints.
|
|
Uses direct Supabase REST API via httpx (no supabase-py dependency issues).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
import contextlib # noqa: E402
|
|
|
|
from app.services.supabase_service import ( # noqa: E402
|
|
award_badge,
|
|
create_alert,
|
|
create_bulletin_post,
|
|
get_alerts,
|
|
get_badges,
|
|
get_bulletin_posts,
|
|
get_profile,
|
|
get_user_badges,
|
|
vote_post,
|
|
)
|
|
|
|
|
|
# Lazy auth dependency - avoids importing jose+bcrypt at module load
|
|
async def _require_public_profile(request: Request):
|
|
from app.auth import require_public_profile
|
|
|
|
return await require_public_profile(request)
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/bulletin")
|
|
|
|
# ── Supabase REST helpers ──────────────────────────────────────
|
|
|
|
|
|
def _sb():
|
|
"""Return (url, headers) for direct Supabase REST API."""
|
|
key = os.environ["SUPABASE_SERVICE_KEY"]
|
|
url = os.environ["SUPABASE_URL"]
|
|
return url, {
|
|
"apikey": key,
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
"Prefer": "return=representation",
|
|
}
|
|
|
|
|
|
def _sb_get(table: str, params: dict | None = None) -> list:
|
|
url, headers = _sb()
|
|
r = httpx.get(f"{url}/rest/v1/{table}", headers=headers, params=params, timeout=10)
|
|
return r.json() if r.status_code == 200 else []
|
|
|
|
|
|
def _sb_post(table: str, data: dict) -> list:
|
|
url, headers = _sb()
|
|
r = httpx.post(f"{url}/rest/v1/{table}", headers=headers, json=data, timeout=10)
|
|
return r.json() if r.status_code in (200, 201) else []
|
|
|
|
|
|
def _sb_patch(table: str, data: dict, match: dict) -> list:
|
|
url, headers = _sb()
|
|
params = "&".join(f"{k}=eq.{v}" for k, v in match.items())
|
|
r = httpx.patch(f"{url}/rest/v1/{table}?{params}", headers=headers, json=data, timeout=10)
|
|
return r.json() if r.status_code == 200 else []
|
|
|
|
|
|
# ── Models ──────────────────────────────────────────────────
|
|
|
|
|
|
class BulletinPostIn(BaseModel):
|
|
title: str
|
|
content: str
|
|
author_id: str
|
|
category: str = "discussion"
|
|
chain: str | None = None
|
|
risk_score: int | None = None
|
|
tags: list[str] | None = None
|
|
source: str | None = None
|
|
is_bot: bool = False
|
|
bot_id: str | None = None
|
|
|
|
|
|
class AlertIn(BaseModel):
|
|
title: str
|
|
description: str
|
|
severity: str = "medium"
|
|
alert_type: str = "security"
|
|
chain: str | None = None
|
|
token: str | None = None
|
|
wallet: str | None = None
|
|
amount_usd: float | None = None
|
|
user_id: str | None = None
|
|
|
|
|
|
class BotRegistrationIn(BaseModel):
|
|
owner_id: str
|
|
bot_name: str
|
|
bot_type: str = "general"
|
|
fee_paid: float = 0
|
|
fee_currency: str = "usdc"
|
|
rules: list[str] = []
|
|
allowed_categories: list[str] = []
|
|
|
|
|
|
# ── Bulletin Posts ──────────────────────────────────────────
|
|
|
|
|
|
@router.get("/latest")
|
|
async def latest_posts(
|
|
category: str = Query(None),
|
|
sort: str = Query("created_at.desc"),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
include_bots: bool = Query(True),
|
|
):
|
|
posts = await get_bulletin_posts(category=category, sort=sort, limit=limit)
|
|
for post in posts:
|
|
if not include_bots and post.get("is_bot"):
|
|
continue
|
|
if post.get("author_id"):
|
|
profile = await get_profile(post["author_id"])
|
|
if profile:
|
|
post["author"] = {
|
|
"username": profile.get("username", profile.get("display_name", "Unknown")),
|
|
"avatar_url": profile.get("avatar_url", ""),
|
|
"reputation": profile.get("reputation_score", 0),
|
|
"level": profile.get("level", 1),
|
|
"is_bot": profile.get("is_bot", False),
|
|
}
|
|
if not include_bots:
|
|
posts = [p for p in posts if not p.get("is_bot")]
|
|
return {"posts": posts, "total": len(posts)}
|
|
|
|
|
|
@router.post("/posts")
|
|
async def create_post(post: BulletinPostIn, user: dict = Depends(_require_public_profile)):
|
|
result = await create_bulletin_post(
|
|
title=post.title,
|
|
content=post.content,
|
|
author_id=user["id"],
|
|
category=post.category,
|
|
chain=post.chain,
|
|
tags=post.tags,
|
|
risk_score=post.risk_score,
|
|
source=post.source,
|
|
)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to create post")
|
|
if post.is_bot and result.get("id"):
|
|
with contextlib.suppress(Exception):
|
|
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": post.bot_id}, {"id": result["id"]})
|
|
return result
|
|
|
|
|
|
@router.post("/posts/{post_id}/vote")
|
|
async def vote(post_id: int, vote_type: str = Query(..., pattern="^(up|down)$")):
|
|
ok = await vote_post(post_id, vote_type)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Post not found or vote failed")
|
|
return {"status": "ok", "post_id": post_id, "vote": vote_type}
|
|
|
|
|
|
class CommentIn(BaseModel):
|
|
content: str
|
|
|
|
|
|
@router.post("/posts/{post_id}/comments")
|
|
async def create_comment(post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)):
|
|
"""Create a comment on a bulletin post. Requires public profile."""
|
|
# Store comment in Supabase or Redis (using simple dict for now if not in supabase_service)
|
|
# For now, we'll just return success and let the frontend handle local state,
|
|
# or we can add it to supabase_service if needed.
|
|
# Let's use a simple in-memory store or supabase if available.
|
|
# Since supabase_service doesn't have create_comment, we'll add a basic implementation.
|
|
try:
|
|
result = _sb_post(
|
|
"bulletin_comments",
|
|
{
|
|
"post_id": post_id,
|
|
"user_id": user["id"],
|
|
"content": comment.content,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
},
|
|
)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to create comment")
|
|
return {"status": "ok", "comment": result[0]}
|
|
except Exception as e:
|
|
# Fallback if table doesn't exist yet
|
|
logger.warning(f"Comment creation failed (table may not exist): {e}")
|
|
return {
|
|
"status": "ok",
|
|
"comment": {
|
|
"id": "temp",
|
|
"content": comment.content,
|
|
"user_id": user["id"],
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/posts/{post_id}/comments")
|
|
async def get_comments(post_id: int):
|
|
"""Get comments for a bulletin post."""
|
|
try:
|
|
comments = _sb_get("bulletin_comments", {"post_id": f"eq.{post_id}", "order": "created_at.asc"})
|
|
return {"comments": comments}
|
|
except Exception:
|
|
return {"comments": []}
|
|
|
|
|
|
# ── Bot Endpoints ───────────────────────────────────────────
|
|
|
|
BOT_FEE_USD = 1.00 # $1 fee for bots to participate
|
|
|
|
|
|
@router.post("/bots/register")
|
|
async def register_bot(bot: BotRegistrationIn):
|
|
"""Register a new bot account. Bot must pay a fee to participate."""
|
|
# Create the bot profile
|
|
try:
|
|
profiles = _sb_post(
|
|
"profiles",
|
|
{
|
|
"username": bot.bot_name,
|
|
"is_bot": True,
|
|
"bot_owner": bot.owner_id,
|
|
"bot_fee_paid": bot.fee_paid,
|
|
"bot_rules": bot.rules,
|
|
"role": "BOT",
|
|
"tier": "FREE",
|
|
},
|
|
)
|
|
if not profiles:
|
|
raise HTTPException(status_code=500, detail="Failed to create bot profile")
|
|
bot_profile = profiles[0]
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Bot profile creation failed: {e}") from e
|
|
|
|
# Create bot registration record
|
|
try:
|
|
regs = _sb_post(
|
|
"bot_registrations",
|
|
{
|
|
"bot_user_id": bot_profile["id"],
|
|
"owner_user_id": bot.owner_id,
|
|
"bot_name": bot.bot_name,
|
|
"bot_type": bot.bot_type,
|
|
"fee_paid": bot.fee_paid,
|
|
"fee_currency": bot.fee_currency,
|
|
"rules": bot.rules,
|
|
"allowed_categories": bot.allowed_categories,
|
|
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
|
|
},
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Bot registration failed: {e}") from e
|
|
|
|
# Log the fee payment
|
|
if bot.fee_paid > 0:
|
|
with contextlib.suppress(Exception):
|
|
_sb_post(
|
|
"bot_fee_log",
|
|
{
|
|
"bot_id": bot_profile["id"],
|
|
"amount": bot.fee_paid,
|
|
"currency": bot.fee_currency,
|
|
"reason": "registration_fee",
|
|
},
|
|
)
|
|
|
|
return {
|
|
"bot_profile": bot_profile,
|
|
"registration": regs[0] if regs else None,
|
|
"fee_required": BOT_FEE_USD,
|
|
"fee_paid": bot.fee_paid,
|
|
"fee_remaining": max(0, BOT_FEE_USD - bot.fee_paid),
|
|
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
|
|
}
|
|
|
|
|
|
@router.get("/bots")
|
|
async def list_bots(status: str = Query(None), limit: int = Query(50)):
|
|
"""List all registered bots."""
|
|
params: dict = {
|
|
"select": "*,profiles:bot_user_id(username,is_bot,level,reputation_score)",
|
|
"limit": str(limit),
|
|
"order": "created_at.desc",
|
|
}
|
|
if status:
|
|
params["status"] = f"eq.{status}"
|
|
result = _sb_get("bot_registrations", params)
|
|
return {"bots": result, "total": len(result)}
|
|
|
|
|
|
@router.get("/bots/{bot_id}/fee-status")
|
|
async def bot_fee_status(bot_id: str):
|
|
"""Check a bot's fee payment status."""
|
|
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "limit": "1"})
|
|
if not reg:
|
|
raise HTTPException(status_code=404, detail="Bot not registered")
|
|
registration = reg[0]
|
|
|
|
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "order": "created_at.desc"})
|
|
total_paid = sum(f.get("amount", 0) for f in fees)
|
|
return {
|
|
"bot_id": bot_id,
|
|
"total_paid": total_paid,
|
|
"fee_required": BOT_FEE_USD,
|
|
"fee_remaining": max(0, BOT_FEE_USD - total_paid),
|
|
"status": registration["status"],
|
|
"fee_history": fees,
|
|
}
|
|
|
|
|
|
@router.post("/bots/{bot_id}/pay-fee")
|
|
async def pay_bot_fee(bot_id: str, amount: float, tx_hash: str | None = None, currency: str = "usdc"):
|
|
"""Record a bot fee payment."""
|
|
try:
|
|
_sb_post(
|
|
"bot_fee_log",
|
|
{
|
|
"bot_id": bot_id,
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"tx_hash": tx_hash,
|
|
"reason": "participation_fee",
|
|
},
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Fee logging failed: {e}") from e
|
|
|
|
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "select": "amount"})
|
|
total_paid = sum(f.get("amount", 0) for f in fees)
|
|
if total_paid >= BOT_FEE_USD:
|
|
_sb_patch("bot_registrations", {"status": "active"}, {"bot_user_id": bot_id})
|
|
_sb_patch("profiles", {"bot_fee_paid": total_paid}, {"id": bot_id})
|
|
|
|
return {
|
|
"amount_paid": amount,
|
|
"total_paid": total_paid,
|
|
"fee_required": BOT_FEE_USD,
|
|
"status": "active" if total_paid >= BOT_FEE_USD else "pending_fee",
|
|
}
|
|
|
|
|
|
@router.post("/bots/{bot_id}/post")
|
|
async def bot_create_post(bot_id: str, post: BulletinPostIn):
|
|
"""Bot creates a post - must be registered and fee-paid."""
|
|
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "status": "eq.active", "limit": "1"})
|
|
if not reg:
|
|
raise HTTPException(status_code=403, detail="Bot not registered or fee not paid")
|
|
|
|
registration = reg[0]
|
|
if registration.get("allowed_categories") and post.category not in registration["allowed_categories"]:
|
|
raise HTTPException(status_code=403, detail=f"Bot not allowed to post in category: {post.category}")
|
|
|
|
post.is_bot = True
|
|
post.bot_id = bot_id
|
|
post.source = post.source or f"bot:{registration['bot_name']}"
|
|
|
|
result = await create_bulletin_post(
|
|
title=post.title,
|
|
content=post.content,
|
|
author_id=post.author_id,
|
|
category=post.category,
|
|
chain=post.chain,
|
|
tags=post.tags,
|
|
risk_score=post.risk_score,
|
|
source=post.source,
|
|
)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to create post")
|
|
|
|
with contextlib.suppress(Exception):
|
|
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": bot_id}, {"id": result["id"]})
|
|
return result
|
|
|
|
|
|
# ── Reports & Moderation ──────────────────────────────────────
|
|
|
|
|
|
class ReportIn(BaseModel):
|
|
post_id: int
|
|
reporter_id: str
|
|
reason: str
|
|
details: str | None = None
|
|
category: str = "other" # spam, abuse, misinformation, scam, bot_violation, other
|
|
|
|
|
|
class ModerationActionIn(BaseModel):
|
|
admin_id: str
|
|
action: str # remove, lock, pin, unpin, unlock, restore, ban_user
|
|
target_type: str # post, user, bot
|
|
target_id: str
|
|
reason: str | None = None
|
|
|
|
|
|
@router.post("/reports")
|
|
async def create_report(report: ReportIn):
|
|
"""Submit a report on a post."""
|
|
result = _sb_post(
|
|
"bulletin_reports",
|
|
{
|
|
"post_id": report.post_id,
|
|
"reporter_id": report.reporter_id,
|
|
"reason": report.reason,
|
|
"details": report.details,
|
|
"category": report.category,
|
|
"status": "open",
|
|
},
|
|
)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to create report")
|
|
return result[0]
|
|
|
|
|
|
@router.get("/reports")
|
|
async def list_reports(status: str = Query(None), limit: int = Query(50, ge=1, le=200)):
|
|
"""List reports (admin view)."""
|
|
params: dict = {"order": "created_at.desc", "limit": str(limit)}
|
|
if status:
|
|
params["status"] = f"eq.{status}"
|
|
result = _sb_get("bulletin_reports", params)
|
|
return {"reports": result, "total": len(result)}
|
|
|
|
|
|
@router.post("/moderate")
|
|
async def moderate(moderation: ModerationActionIn):
|
|
"""Take a moderation action."""
|
|
# Log the action
|
|
_sb_post(
|
|
"bulletin_moderation_log",
|
|
{
|
|
"admin_id": moderation.admin_id,
|
|
"action": moderation.action,
|
|
"target_type": moderation.target_type,
|
|
"target_id": moderation.target_id,
|
|
"reason": moderation.reason,
|
|
},
|
|
)
|
|
|
|
# Apply the action
|
|
if moderation.target_type == "post":
|
|
match = {"id": moderation.target_id}
|
|
if moderation.action == "remove":
|
|
_sb_patch("bulletin_posts", {"removed": True, "removed_reason": moderation.reason}, match)
|
|
elif moderation.action == "restore":
|
|
_sb_patch("bulletin_posts", {"removed": False, "removed_reason": None}, match)
|
|
elif moderation.action == "pin":
|
|
_sb_patch("bulletin_posts", {"pinned": True}, match)
|
|
elif moderation.action == "unpin":
|
|
_sb_patch("bulletin_posts", {"pinned": False}, match)
|
|
elif moderation.action == "lock":
|
|
_sb_patch("bulletin_posts", {"locked": True}, match)
|
|
elif moderation.action == "unlock":
|
|
_sb_patch("bulletin_posts", {"locked": False}, match)
|
|
|
|
elif moderation.target_type == "bot":
|
|
if moderation.action == "ban_user":
|
|
_sb_patch("bot_registrations", {"status": "banned"}, {"bot_user_id": moderation.target_id})
|
|
|
|
return {
|
|
"status": "ok",
|
|
"action": moderation.action,
|
|
"target": f"{moderation.target_type}:{moderation.target_id}",
|
|
}
|
|
|
|
|
|
@router.get("/moderation/log")
|
|
async def moderation_log(limit: int = Query(50)):
|
|
"""View moderation log."""
|
|
result = _sb_get("bulletin_moderation_log", {"order": "created_at.desc", "limit": str(limit)})
|
|
return {"log": result, "total": len(result)}
|
|
|
|
|
|
# ── Badges ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/badges")
|
|
async def list_badges(rarity: str = Query(None)):
|
|
return await get_badges(rarity=rarity)
|
|
|
|
|
|
@router.get("/badges/{user_id}")
|
|
async def user_badges(user_id: str):
|
|
badges = await get_user_badges(user_id)
|
|
return badges
|
|
|
|
|
|
@router.post("/badges/award")
|
|
async def award(badge_id: str, user_id: str, earned_via: str = "auto"):
|
|
result = await award_badge(user_id, badge_id, earned_via)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to award badge")
|
|
return result
|
|
|
|
|
|
# ── Alerts ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/alerts")
|
|
async def list_alerts(severity: str = Query(None), limit: int = Query(50, ge=1, le=200)):
|
|
alerts = await get_alerts(severity=severity, limit=limit)
|
|
return {"alerts": alerts, "total": len(alerts)}
|
|
|
|
|
|
@router.post("/alerts")
|
|
async def submit_alert(alert: AlertIn):
|
|
result = await create_alert(
|
|
title=alert.title,
|
|
description=alert.description,
|
|
severity=alert.severity,
|
|
alert_type=alert.alert_type,
|
|
chain=alert.chain,
|
|
token=alert.token,
|
|
wallet=alert.wallet,
|
|
amount_usd=alert.amount_usd,
|
|
user_id=alert.user_id,
|
|
)
|
|
if not result:
|
|
raise HTTPException(status_code=500, detail="Failed to create alert")
|
|
return result
|
|
|
|
|
|
# ── Stats ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/stats")
|
|
async def community_stats():
|
|
"""Get aggregate community stats from Supabase."""
|
|
total_posts = 0
|
|
total_users = 0
|
|
total_bots = 0
|
|
total_alerts = 0
|
|
|
|
try:
|
|
posts = _sb_get("bulletin_posts", {"select": "id", "limit": "1000"})
|
|
total_posts = len(posts)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
profiles = _sb_get("profiles", {"select": "id", "limit": "1000"})
|
|
total_users = len(profiles)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
bots = _sb_get("profiles", {"select": "id", "is_bot": "eq.true", "limit": "100"})
|
|
total_bots = len(bots)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
alerts = _sb_get("alerts", {"select": "id", "limit": "500"})
|
|
total_alerts = len(alerts)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"total_posts": total_posts,
|
|
"total_users": total_users,
|
|
"total_bots": total_bots,
|
|
"total_alerts": total_alerts,
|
|
"bot_fee_required_usd": BOT_FEE_USD,
|
|
}
|
|
|
|
|
|
# ── Health ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/health")
|
|
async def health():
|
|
from app.services.supabase_service import check_health
|
|
|
|
return await check_health()
|