360 lines
12 KiB
Python
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))
|