'memos'
This commit is contained in:
@@ -13,6 +13,7 @@ from .nomadlms import router as nomadlms_router
|
||||
from .legacy import router as legacy_router
|
||||
from .common import router as common_router
|
||||
from .meetup import router as meetup_router
|
||||
from .community import router as community_router
|
||||
|
||||
__all__ = [
|
||||
"nomadvip_router",
|
||||
@@ -22,4 +23,5 @@ __all__ = [
|
||||
"legacy_router",
|
||||
"common_router",
|
||||
"meetup_router",
|
||||
"community_router",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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