'memos'
This commit is contained in:
359
app/routers/community.py
Normal file
359
app/routers/community.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
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))
|
||||
Reference in New Issue
Block a user