Files
gitlab-instance-0a899031_pa…/app/routers/community.py
2026-03-15 04:26:05 -05:00

360 lines
12 KiB
Python

"""
Private community API.
"""
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
import requests
from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
from ..db import community_sqlite as sqlite_db
from ..services import community as community_svc
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_IMAGE_SIZE = 10 * 1024 * 1024
SITE_ID = "vip"
router = APIRouter(prefix="/community", tags=["community"])
logger = logging.getLogger(__name__)
def _get_pb_admin_token() -> str | None:
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
return None
base = (PB_URL or "").rstrip("/")
for path in (
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
):
try:
response = requests.post(
f"{base}{path}",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
timeout=10,
)
if response.status_code == 200:
return response.json().get("token")
except Exception:
continue
return None
def _pb_escape(value: str) -> str:
return str(value).replace("\\", "\\\\").replace('"', '\\"')
def _uid_has_vip(uid: str) -> bool:
uid = (uid or "").strip()
if not uid:
return False
token = _get_pb_admin_token()
if not token:
return False
base = (PB_URL or "").rstrip("/")
headers = {"Authorization": f"Bearer {token}"}
safe_uid = _pb_escape(uid)
try:
response = requests.get(
f"{base}/api/collections/site_vip/records",
params={"filter": f'user_id = "{safe_uid}" && site_id = "{SITE_ID}"', "perPage": 1},
headers=headers,
timeout=10,
)
if response.status_code == 200:
items = response.json().get("items") or []
if items:
record = items[0]
expires = record.get("expires_at") or ""
if not expires:
return True
try:
expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00"))
now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
return expires_at.timestamp() >= now.timestamp()
except Exception:
return True
except Exception:
pass
try:
response = requests.get(
f"{base}/api/collections/payments/records",
params={"filter": f'user_id = "{safe_uid}" && type = "vip"', "sort": "-created", "perPage": 1},
headers=headers,
timeout=10,
)
if response.status_code == 200 and (response.json().get("items") or []):
return True
except Exception:
pass
return False
def _count_vip_members() -> int | None:
token = _get_pb_admin_token()
if not token:
return None
base = (PB_URL or "").rstrip("/")
headers = {"Authorization": f"Bearer {token}"}
try:
response = requests.get(
f"{base}/api/collections/site_vip/records",
params={"filter": f'site_id = "{SITE_ID}"', "perPage": 1},
headers=headers,
timeout=10,
)
if response.status_code == 200:
payload = response.json()
return int(payload.get("totalItems") or len(payload.get("items") or []))
except Exception:
return None
return None
def _require_vip_user(user_id: str, detail: str = "Only VIP members can access the private community.") -> str:
normalized = (user_id or "").strip()
if not normalized:
raise HTTPException(status_code=401, detail="Missing user_id")
if not _uid_has_vip(normalized):
raise HTTPException(status_code=403, detail=detail)
return normalized
def _extract_payload(item: dict[str, Any]) -> dict[str, Any]:
nested = item.get("memo") or item.get("comment") or item.get("reaction")
if isinstance(nested, dict):
return nested
return item
@router.get("/health")
async def community_health():
return {"ok": True, "service": "community"}
@router.get("/files/{filename:path}")
async def serve_upload_file(filename: str):
path = sqlite_db.get_upload_path(filename)
if path is None:
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(path, filename=filename)
@router.get("/stats")
async def get_stats(user_id: str = ""):
normalized = _require_vip_user(user_id)
member_count = _count_vip_members()
return {"ok": True, "user_id": normalized, **community_svc.get_stats(member_count=member_count)}
@router.get("/attachments")
async def list_attachments(user_id: str = "", page: int = 1, per_page: int = 24):
_require_vip_user(user_id)
per_page = min(max(1, per_page), 60)
page = max(1, page)
return {"ok": True, **community_svc.list_uploads(page=page, per_page=per_page)}
@router.get("/memos")
async def list_memos(
user_id: str = "",
page: int = 1,
per_page: int = 50,
page_size: int = 0,
page_token: str = "",
state: str = "NORMAL",
order_by: str = "-pinned,-created",
memo_format: str = "1",
query: str = "",
):
normalized = _require_vip_user(user_id)
per_page = per_page or page_size or 50
per_page = min(max(1, per_page), 100)
page = max(1, int(page_token) if page_token and page_token.isdigit() else page)
try:
result = community_svc.list_memos(
page=page,
per_page=per_page,
state=(state or "NORMAL").upper(),
order_by=order_by or "-pinned,-created",
memo_format=memo_format != "0",
query=(query or "").strip(),
viewer_id=normalized,
)
return {"ok": True, **result}
except RuntimeError as exc:
logger.error("community list_memos config error: %s", exc)
raise HTTPException(status_code=503, detail=str(exc))
except Exception as exc:
logger.error("community list_memos error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/memos/{memo_id}")
async def get_memo(memo_id: str, user_id: str = ""):
normalized = _require_vip_user(user_id)
result = community_svc.get_memo(memo_id, viewer_id=normalized, include_comments=True)
if result is None:
raise HTTPException(status_code=404, detail="Memo not found.")
return {"ok": True, "memo": result}
@router.post("/memos")
async def create_memo(item: dict[str, Any]):
payload = _extract_payload(item)
user_id = _require_vip_user(payload.get("user_id") or item.get("user_id"), "Only VIP members can publish posts.")
content = str(payload.get("content") or item.get("content") or "").strip()
pinned = bool(payload.get("pinned") if payload.get("pinned") is not None else item.get("pinned"))
visibility = str(payload.get("visibility") or item.get("visibility") or "PRIVATE").upper()
state = str(payload.get("state") or item.get("state") or "NORMAL").upper()
try:
memo_record = community_svc.create_memo(
user_id=user_id,
content=content,
pinned=pinned,
visibility=visibility,
state=state,
)
return {"ok": True, "memo": memo_record}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except RuntimeError as exc:
logger.error("community create_memo config error: %s", exc)
raise HTTPException(status_code=503, detail=str(exc))
except Exception as exc:
logger.error("community create_memo error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.patch("/memos/{memo_id}")
async def update_memo(memo_id: str, item: dict[str, Any]):
payload = _extract_payload(item)
user_id = _require_vip_user(payload.get("user_id") or item.get("user_id"), "Only VIP members can update posts.")
pinned = payload.get("pinned") if payload.get("pinned") is not None else item.get("pinned")
content = payload.get("content") if payload.get("content") is not None else item.get("content")
visibility = payload.get("visibility") or item.get("visibility")
state = payload.get("state") or item.get("state")
try:
result = community_svc.update_memo(
memo_id.replace("memos/", ""),
user_id,
pinned=bool(pinned) if pinned is not None else None,
content=str(content) if content is not None else None,
visibility=str(visibility).upper() if visibility else None,
state=str(state).upper() if state else None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if result is None:
raise HTTPException(status_code=404, detail="Memo not found or permission denied.")
return {"ok": True, "memo": result}
@router.delete("/memos/{memo_id}")
async def delete_memo(memo_id: str, user_id: str = ""):
normalized = _require_vip_user(user_id, "Only VIP members can delete posts.")
ok = community_svc.delete_memo(memo_id, normalized)
if not ok:
raise HTTPException(status_code=404, detail="Memo not found or permission denied.")
return {"ok": True}
@router.get("/memos/{memo_id}/comments")
async def get_comments(memo_id: str, user_id: str = ""):
_require_vip_user(user_id)
return {"ok": True, "comments": community_svc.list_comments(memo_id)}
@router.post("/memos/{memo_id}/comments")
async def create_comment(memo_id: str, item: dict[str, Any]):
payload = _extract_payload(item)
user_id = _require_vip_user(
payload.get("user_id") or item.get("user_id"),
"Only VIP members can reply in the private community.",
)
content = str(payload.get("content") or item.get("content") or "").strip()
try:
comment = community_svc.create_comment(memo_id, user_id, content)
return {"ok": True, "comment": comment}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("community create_comment error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.delete("/comments/{comment_id}")
async def delete_comment(comment_id: str, user_id: str = ""):
normalized = _require_vip_user(user_id, "Only VIP members can manage comments.")
ok = community_svc.delete_comment(comment_id, normalized)
if not ok:
raise HTTPException(status_code=404, detail="Comment not found or permission denied.")
return {"ok": True}
@router.post("/memos/{memo_id}/reactions")
async def toggle_reaction(memo_id: str, item: dict[str, Any]):
payload = _extract_payload(item)
user_id = _require_vip_user(
payload.get("user_id") or item.get("user_id"),
"Only VIP members can react in the private community.",
)
reaction_type = str(payload.get("reaction_type") or item.get("reaction_type") or sqlite_db.UPVOTE_REACTION)
try:
reaction = community_svc.toggle_reaction(memo_id, user_id, reaction_type)
return {"ok": True, "reaction": reaction}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("community toggle_reaction error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/upload")
async def upload_image(user_id: str = Form(""), file: UploadFile = File(...)):
normalized = _require_vip_user(user_id, "Only VIP members can upload images.")
if not file.content_type or file.content_type.lower() not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail="Only jpeg, png, gif, and webp images are supported.")
data = await file.read()
if len(data) > MAX_IMAGE_SIZE:
raise HTTPException(status_code=400, detail="Image size must be 10MB or smaller.")
try:
base = (BASE_URL or "http://localhost:8000").rstrip("/")
result = community_svc.upload_image(
data=data,
filename=file.filename or "image",
content_type=file.content_type or "image/jpeg",
user_id=normalized,
base_url=base,
)
return {"ok": True, **result}
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
except Exception as exc:
logger.error("community upload error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))