""" 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))