From 18d098f5f834bee811d07a33c3491dab5a448e4d Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 15 Mar 2026 04:26:05 -0500 Subject: [PATCH] 'memos' --- app/db/__init__.py | 1 + app/db/community_sqlite.py | 753 ++++++++++++++++++ app/main.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 605 -> 603 bytes app/payment/__pycache__/base.cpython-312.pyc | Bin 2584 -> 2582 bytes .../__pycache__/factory.cpython-312.pyc | Bin 2900 -> 2898 bytes .../__pycache__/xorpay.cpython-312.pyc | Bin 5733 -> 5731 bytes app/payment/__pycache__/zpay.cpython-312.pyc | Bin 11518 -> 11516 bytes app/routers/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 720 -> 747 bytes .../__pycache__/_digital_base.cpython-312.pyc | Bin 16160 -> 16158 bytes .../__pycache__/cnomadcna.cpython-312.pyc | Bin 378 -> 376 bytes .../__pycache__/common.cpython-312.pyc | Bin 3914 -> 3912 bytes .../__pycache__/digital.cpython-312.pyc | Bin 360 -> 358 bytes .../__pycache__/legacy.cpython-312.pyc | Bin 382 -> 380 bytes .../__pycache__/nomadlms.cpython-312.pyc | Bin 351 -> 349 bytes .../__pycache__/nomadvip.cpython-312.pyc | Bin 35723 -> 35721 bytes app/routers/community.py | 359 +++++++++ app/sdk/__pycache__/__init__.cpython-312.pyc | Bin 626 -> 624 bytes app/sdk/__pycache__/memos.cpython-312.pyc | Bin 3848 -> 3846 bytes app/sdk/__pycache__/minio.cpython-312.pyc | Bin 4215 -> 4213 bytes .../__pycache__/pocketbase.cpython-312.pyc | Bin 2905 -> 2903 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 513 -> 511 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 531 -> 529 bytes .../__pycache__/memos.cpython-312.pyc | Bin 1034 -> 1032 bytes .../__pycache__/payment.cpython-312.pyc | Bin 8795 -> 8793 bytes app/services/__pycache__/pb.cpython-312.pyc | Bin 1161 -> 1159 bytes app/services/community.py | 116 +++ data/community.db | Bin 0 -> 86016 bytes .../1773562558477_89a7aa1d.png | Bin 0 -> 21030 bytes .../1773562715937_0afd6608.png | Bin 0 -> 21030 bytes .../1773565492766_a3fe972b.png | Bin 0 -> 21030 bytes docs/COMMUNITY_MEMOS_SCHEMA.md | 21 + docs/COMMUNITY_SQLITE.md | 45 ++ 34 files changed, 1300 insertions(+) create mode 100644 app/db/__init__.py create mode 100644 app/db/community_sqlite.py create mode 100644 app/routers/community.py create mode 100644 app/services/community.py create mode 100644 data/community.db create mode 100644 data/community_uploads/1773562558477_89a7aa1d.png create mode 100644 data/community_uploads/1773562715937_0afd6608.png create mode 100644 data/community_uploads/1773565492766_a3fe972b.png create mode 100644 docs/COMMUNITY_MEMOS_SCHEMA.md create mode 100644 docs/COMMUNITY_SQLITE.md diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..980755b --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# Database modules diff --git a/app/db/community_sqlite.py b/app/db/community_sqlite.py new file mode 100644 index 0000000..32d1f22 --- /dev/null +++ b/app/db/community_sqlite.py @@ -0,0 +1,753 @@ +""" +SQLite storage for the private community module. +""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path +from typing import Optional +import os +import re +import sqlite3 +import time +import uuid + +_ROOT = Path(__file__).resolve().parent.parent.parent +DATA_DIR = Path(os.getenv("COMMUNITY_DATA_DIR", str(_ROOT / "data"))) +DB_PATH = DATA_DIR / "community.db" +UPLOADS_DIR = DATA_DIR / "community_uploads" + +UPVOTE_REACTION = "UPVOTE" + + +def _ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + +def _get_conn() -> sqlite3.Connection: + _ensure_dirs() + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) + + +def _normalize_memo_id(memo_id: str) -> str: + return memo_id.replace("memos/", "") if memo_id.startswith("memos/") else memo_id + + +def _extract_tags(content: str) -> list[str]: + matches = re.findall(r"#[\w\u4e00-\u9fa5]+", content or "") + return list(dict.fromkeys(match.strip("#") for match in matches)) + + +def _extract_attachments(content: str) -> list[dict]: + items = [] + for match in re.finditer(r"!\[([^\]]*)\]\(([^)]+)\)", content or ""): + alt, url = match.group(1).strip(), match.group(2).strip() + if url: + items.append({"url": url, "alt": alt}) + return items + + +def init_schema() -> None: + _ensure_dirs() + conn = _get_conn() + try: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS community_memos ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + visibility TEXT NOT NULL DEFAULT 'PRIVATE', + state TEXT NOT NULL DEFAULT 'NORMAL', + created TEXT NOT NULL, + updated TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memos_user ON community_memos(user_id); + CREATE INDEX IF NOT EXISTS idx_memos_state ON community_memos(state); + CREATE INDEX IF NOT EXISTS idx_memos_created ON community_memos(created); + CREATE INDEX IF NOT EXISTS idx_memos_pinned ON community_memos(pinned); + + CREATE TABLE IF NOT EXISTS community_uploads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + original_name TEXT NOT NULL, + filename TEXT NOT NULL UNIQUE, + content_type TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL, + created TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_uploads_user ON community_uploads(user_id); + CREATE INDEX IF NOT EXISTS idx_uploads_created ON community_uploads(created); + + CREATE TABLE IF NOT EXISTS community_comments ( + id TEXT PRIMARY KEY, + memo_id TEXT NOT NULL, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created TEXT NOT NULL, + updated TEXT NOT NULL, + FOREIGN KEY (memo_id) REFERENCES community_memos(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_comments_memo ON community_comments(memo_id, created); + CREATE INDEX IF NOT EXISTS idx_comments_user ON community_comments(user_id); + + CREATE TABLE IF NOT EXISTS community_reactions ( + id TEXT PRIMARY KEY, + memo_id TEXT NOT NULL, + user_id TEXT NOT NULL, + reaction_type TEXT NOT NULL, + created TEXT NOT NULL, + UNIQUE(memo_id, user_id, reaction_type), + FOREIGN KEY (memo_id) REFERENCES community_memos(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_reactions_memo ON community_reactions(memo_id); + CREATE INDEX IF NOT EXISTS idx_reactions_user ON community_reactions(user_id); + """ + ) + conn.commit() + finally: + conn.close() + + +def _base_memo_from_row(row: sqlite3.Row) -> dict: + content = row["content"] + return { + "id": row["id"], + "user_id": row["user_id"], + "content": content, + "pinned": bool(row["pinned"]), + "visibility": row["visibility"] or "PRIVATE", + "state": row["state"] or "NORMAL", + "created": row["created"], + "updated": row["updated"], + "tags": _extract_tags(content), + "attachments": _extract_attachments(content), + } + + +def _comment_from_row(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "memo_id": row["memo_id"], + "user_id": row["user_id"], + "content": row["content"], + "created": row["created"], + "updated": row["updated"], + } + + +def _comment_count(conn: sqlite3.Connection, memo_id: str) -> int: + cursor = conn.execute("SELECT COUNT(*) FROM community_comments WHERE memo_id = ?", (memo_id,)) + return int(cursor.fetchone()[0] or 0) + + +def _reaction_count(conn: sqlite3.Connection, memo_id: str) -> int: + cursor = conn.execute( + "SELECT COUNT(*) FROM community_reactions WHERE memo_id = ? AND reaction_type = ?", + (memo_id, UPVOTE_REACTION), + ) + return int(cursor.fetchone()[0] or 0) + + +def _liked_by_user(conn: sqlite3.Connection, memo_id: str, viewer_id: str = "") -> bool: + if not viewer_id: + return False + cursor = conn.execute( + """ + SELECT 1 + FROM community_reactions + WHERE memo_id = ? AND user_id = ? AND reaction_type = ? + LIMIT 1 + """, + (memo_id, viewer_id, UPVOTE_REACTION), + ) + return cursor.fetchone() is not None + + +def list_comments(memo_id: str) -> list[dict]: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + rows = conn.execute( + """ + SELECT id, memo_id, user_id, content, created, updated + FROM community_comments + WHERE memo_id = ? + ORDER BY created ASC + """, + (uid,), + ).fetchall() + return [_comment_from_row(row) for row in rows] + finally: + conn.close() + + +def _enrich_memo(conn: sqlite3.Connection, memo: dict, viewer_id: str = "", include_comments: bool = False) -> dict: + enriched = dict(memo) + enriched["comment_count"] = _comment_count(conn, memo["id"]) + enriched["reaction_count"] = _reaction_count(conn, memo["id"]) + enriched["liked_by_me"] = _liked_by_user(conn, memo["id"], viewer_id) + if include_comments: + enriched["comments"] = list_comments(memo["id"]) + return enriched + + +def _to_memo_format(memo: dict) -> dict: + payload = { + "name": f"memos/{memo['id']}", + "id": memo["id"], + "creator": f"users/{memo['user_id']}", + "user_id": memo["user_id"], + "content": memo["content"], + "pinned": memo["pinned"], + "visibility": memo["visibility"], + "state": memo["state"], + "create_time": memo["created"], + "update_time": memo["updated"], + "display_time": memo["created"], + "tags": memo["tags"], + "attachments": memo["attachments"], + "comment_count": memo["comment_count"], + "reaction_count": memo["reaction_count"], + "liked_by_me": memo["liked_by_me"], + } + if "comments" in memo: + payload["comments"] = memo["comments"] + return payload + + +def _normalize_memo(memo: dict) -> dict: + payload = { + "id": memo["id"], + "user_id": memo["user_id"], + "content": memo["content"], + "pinned": memo["pinned"], + "visibility": memo["visibility"], + "state": memo["state"], + "created": memo["created"], + "updated": memo["updated"], + "tags": memo["tags"], + "attachments": memo["attachments"], + "comment_count": memo["comment_count"], + "reaction_count": memo["reaction_count"], + "liked_by_me": memo["liked_by_me"], + } + if "comments" in memo: + payload["comments"] = memo["comments"] + return payload + + +def _build_where_clause(state: str = "NORMAL", query: str = "") -> tuple[str, list[str]]: + clauses: list[str] = [] + params: list[str] = [] + + normalized_state = (state or "NORMAL").upper() + if normalized_state != "ALL": + clauses.append("state = ?") + params.append(normalized_state) + + if query: + lowered = query.lower() + clauses.append("LOWER(content) LIKE ?") + params.append(f"%{lowered}%") + + if not clauses: + return "", params + return f"WHERE {' AND '.join(clauses)}", params + + +def list_memos( + page: int = 1, + per_page: int = 50, + state: str = "NORMAL", + order_by: str = "-pinned,-created", + memo_format: bool = False, + query: str = "", + viewer_id: str = "", +) -> dict: + conn = _get_conn() + try: + order_clause = "pinned DESC, created DESC" + if "created" in order_by and "pinned" not in order_by: + order_clause = "created DESC" + + offset = (page - 1) * per_page + where_clause, params = _build_where_clause(state=state, query=query) + + total = int( + conn.execute(f"SELECT COUNT(*) FROM community_memos {where_clause}", params).fetchone()[0] or 0 + ) + + rows = conn.execute( + f""" + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + {where_clause} + ORDER BY {order_clause} + LIMIT ? OFFSET ? + """, + [*params, per_page, offset], + ).fetchall() + + items = [] + for row in rows: + enriched = _enrich_memo(conn, _base_memo_from_row(row), viewer_id=viewer_id) + items.append(_to_memo_format(enriched) if memo_format else _normalize_memo(enriched)) + + next_token = "" if len(items) < per_page else str(page + 1) + return { + "memos": items, + "items": items, + "totalItems": total, + "page": page, + "pageSize": per_page, + "next_page_token": next_token, + } + finally: + conn.close() + + +def get_memo(memo_id: str, viewer_id: str = "", include_comments: bool = True) -> dict | None: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute( + """ + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + WHERE id = ? + """, + (uid,), + ).fetchone() + if not row: + return None + + enriched = _enrich_memo( + conn, + _base_memo_from_row(row), + viewer_id=viewer_id, + include_comments=include_comments, + ) + return _to_memo_format(enriched) + finally: + conn.close() + + +def create_memo( + user_id: str, + content: str, + pinned: bool = False, + visibility: str = "PRIVATE", + state: str = "NORMAL", +) -> dict: + content = (content or "").strip() + if not content: + raise ValueError("content cannot be empty") + if len(content) > 10000: + raise ValueError("content is too long") + + memo_id = uuid.uuid4().hex[:16] + now = _now_iso() + + conn = _get_conn() + try: + conn.execute( + """ + INSERT INTO community_memos (id, user_id, content, pinned, visibility, state, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (memo_id, user_id, content, 1 if pinned else 0, visibility, state, now, now), + ) + conn.commit() + memo = _enrich_memo( + conn, + { + "id": memo_id, + "user_id": user_id, + "content": content, + "pinned": pinned, + "visibility": visibility, + "state": state, + "created": now, + "updated": now, + "tags": _extract_tags(content), + "attachments": _extract_attachments(content), + }, + viewer_id=user_id, + ) + return _to_memo_format(memo) + finally: + conn.close() + + +def update_memo( + memo_id: str, + user_id: str, + pinned: Optional[bool] = None, + content: Optional[str] = None, + visibility: Optional[str] = None, + state: Optional[str] = None, +) -> dict | None: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute("SELECT * FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not row: + return None + + memo = _base_memo_from_row(row) + if memo["user_id"] != user_id: + return None + + updates = [] + params = [] + + if pinned is not None: + updates.append("pinned = ?") + params.append(1 if pinned else 0) + if content is not None: + content = content.strip() + if not content: + raise ValueError("content cannot be empty") + if len(content) > 10000: + raise ValueError("content is too long") + updates.append("content = ?") + params.append(content) + if visibility is not None: + updates.append("visibility = ?") + params.append(visibility) + if state is not None: + updates.append("state = ?") + params.append(state) + + if not updates: + return _to_memo_format(_enrich_memo(conn, memo, viewer_id=user_id)) + + now = _now_iso() + updates.append("updated = ?") + params.append(now) + params.append(uid) + conn.execute(f"UPDATE community_memos SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + latest_content = content if content is not None else memo["content"] + memo.update( + { + "pinned": pinned if pinned is not None else memo["pinned"], + "content": latest_content, + "visibility": visibility if visibility is not None else memo["visibility"], + "state": state if state is not None else memo["state"], + "updated": now, + "tags": _extract_tags(latest_content), + "attachments": _extract_attachments(latest_content), + } + ) + return _to_memo_format(_enrich_memo(conn, memo, viewer_id=user_id)) + finally: + conn.close() + + +def delete_memo(memo_id: str, user_id: str) -> bool: + uid = _normalize_memo_id(memo_id) + conn = _get_conn() + try: + row = conn.execute("SELECT user_id FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not row or row["user_id"] != user_id: + return False + conn.execute("DELETE FROM community_memos WHERE id = ?", (uid,)) + conn.commit() + return True + finally: + conn.close() + + +def create_comment(memo_id: str, user_id: str, content: str) -> dict: + uid = _normalize_memo_id(memo_id) + content = (content or "").strip() + if not content: + raise ValueError("comment content cannot be empty") + if len(content) > 3000: + raise ValueError("comment content is too long") + + conn = _get_conn() + try: + memo_exists = conn.execute("SELECT 1 FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not memo_exists: + raise ValueError("memo does not exist") + + comment_id = uuid.uuid4().hex[:16] + now = _now_iso() + conn.execute( + """ + INSERT INTO community_comments (id, memo_id, user_id, content, created, updated) + VALUES (?, ?, ?, ?, ?, ?) + """, + (comment_id, uid, user_id, content, now, now), + ) + conn.commit() + return { + "id": comment_id, + "memo_id": uid, + "user_id": user_id, + "content": content, + "created": now, + "updated": now, + } + finally: + conn.close() + + +def delete_comment(comment_id: str, user_id: str) -> bool: + conn = _get_conn() + try: + row = conn.execute( + """ + SELECT c.user_id AS comment_user_id, m.user_id AS memo_user_id + FROM community_comments c + JOIN community_memos m ON m.id = c.memo_id + WHERE c.id = ? + """, + (comment_id,), + ).fetchone() + if not row: + return False + if row["comment_user_id"] != user_id and row["memo_user_id"] != user_id: + return False + conn.execute("DELETE FROM community_comments WHERE id = ?", (comment_id,)) + conn.commit() + return True + finally: + conn.close() + + +def toggle_reaction(memo_id: str, user_id: str, reaction_type: str = UPVOTE_REACTION) -> dict: + uid = _normalize_memo_id(memo_id) + reaction_type = (reaction_type or UPVOTE_REACTION).upper() + conn = _get_conn() + try: + memo_exists = conn.execute("SELECT 1 FROM community_memos WHERE id = ?", (uid,)).fetchone() + if not memo_exists: + raise ValueError("memo does not exist") + + existing = conn.execute( + """ + SELECT id + FROM community_reactions + WHERE memo_id = ? AND user_id = ? AND reaction_type = ? + """, + (uid, user_id, reaction_type), + ).fetchone() + + liked = False + if existing: + conn.execute("DELETE FROM community_reactions WHERE id = ?", (existing["id"],)) + else: + conn.execute( + """ + INSERT INTO community_reactions (id, memo_id, user_id, reaction_type, created) + VALUES (?, ?, ?, ?, ?) + """, + (uuid.uuid4().hex[:16], uid, user_id, reaction_type, _now_iso()), + ) + liked = True + conn.commit() + + return { + "memo_id": uid, + "reaction_type": reaction_type, + "liked": liked, + "reaction_count": _reaction_count(conn, uid), + } + finally: + conn.close() + + +def save_upload(data: bytes, filename: str, content_type: str, user_id: str, base_url: str) -> dict: + _ensure_dirs() + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "jpg" + if ext not in ("jpg", "jpeg", "png", "gif", "webp"): + ext = "jpg" + + upload_id = uuid.uuid4().hex[:16] + stored_name = f"{int(time.time() * 1000)}_{upload_id[:8]}.{ext}" + path = UPLOADS_DIR / stored_name + path.write_bytes(data) + + now = _now_iso() + url = f"{base_url.rstrip('/')}/community/files/{stored_name}" + + conn = _get_conn() + try: + conn.execute( + """ + INSERT INTO community_uploads (id, user_id, original_name, filename, content_type, size, url, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (upload_id, user_id, filename, stored_name, content_type, len(data), url, now), + ) + conn.commit() + finally: + conn.close() + + return { + "id": upload_id, + "url": url, + "markdown": f"![{filename}]({url})", + "filename": stored_name, + "original_name": filename, + "content_type": content_type, + "size": len(data), + "created": now, + } + + +def list_uploads(page: int = 1, per_page: int = 24, user_id: str = "") -> dict: + offset = (page - 1) * per_page + conn = _get_conn() + try: + where_clause = "" + params: list[str] = [] + if user_id: + where_clause = "WHERE user_id = ?" + params.append(user_id) + + total = int( + conn.execute(f"SELECT COUNT(*) FROM community_uploads {where_clause}", params).fetchone()[0] or 0 + ) + rows = conn.execute( + f""" + SELECT id, user_id, original_name, filename, content_type, size, url, created + FROM community_uploads + {where_clause} + ORDER BY created DESC + LIMIT ? OFFSET ? + """, + [*params, per_page, offset], + ).fetchall() + + items = [ + { + "id": row["id"], + "user_id": row["user_id"], + "filename": row["filename"], + "original_name": row["original_name"], + "content_type": row["content_type"], + "size": row["size"], + "url": row["url"], + "created": row["created"], + } + for row in rows + ] + + next_token = "" if len(items) < per_page else str(page + 1) + return { + "items": items, + "totalItems": total, + "page": page, + "pageSize": per_page, + "next_page_token": next_token, + } + finally: + conn.close() + + +def get_stats(member_count: int | None = None) -> dict: + conn = _get_conn() + try: + totals = conn.execute( + """ + SELECT + COUNT(*) AS total_memos, + SUM(CASE WHEN state = 'NORMAL' THEN 1 ELSE 0 END) AS normal_memos, + SUM(CASE WHEN state = 'ARCHIVED' THEN 1 ELSE 0 END) AS archived_memos, + SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) AS pinned_memos, + COUNT(DISTINCT user_id) AS contributors + FROM community_memos + """ + ).fetchone() + + total_uploads = int(conn.execute("SELECT COUNT(*) FROM community_uploads").fetchone()[0] or 0) + total_comments = int(conn.execute("SELECT COUNT(*) FROM community_comments").fetchone()[0] or 0) + total_reactions = int(conn.execute("SELECT COUNT(*) FROM community_reactions").fetchone()[0] or 0) + + today_prefix = _now_iso()[:10] + today_memos = int( + conn.execute("SELECT COUNT(*) FROM community_memos WHERE created LIKE ?", (f"{today_prefix}%",)).fetchone()[0] + or 0 + ) + + top_authors_rows = conn.execute( + """ + SELECT user_id, COUNT(*) AS memo_count, MAX(created) AS latest_created + FROM community_memos + WHERE state = 'NORMAL' + GROUP BY user_id + ORDER BY memo_count DESC, latest_created DESC + LIMIT 6 + """ + ).fetchall() + top_authors = [ + { + "user_id": row["user_id"], + "memo_count": row["memo_count"], + "latest_created": row["latest_created"], + } + for row in top_authors_rows + ] + + tag_counter: Counter[str] = Counter() + for row in conn.execute("SELECT content FROM community_memos WHERE state = 'NORMAL'").fetchall(): + tag_counter.update(_extract_tags(row["content"])) + + top_tags = [{"tag": tag, "count": count} for tag, count in tag_counter.most_common(10)] + + latest_rows = conn.execute( + """ + SELECT id, user_id, content, pinned, visibility, state, created, updated + FROM community_memos + WHERE state = 'NORMAL' + ORDER BY pinned DESC, created DESC + LIMIT 3 + """ + ).fetchall() + latest_memos = [_normalize_memo(_enrich_memo(conn, _base_memo_from_row(row))) for row in latest_rows] + + return { + "stats": { + "members": member_count, + "total_memos": int(totals["total_memos"] or 0), + "normal_memos": int(totals["normal_memos"] or 0), + "archived_memos": int(totals["archived_memos"] or 0), + "pinned_memos": int(totals["pinned_memos"] or 0), + "contributors": int(totals["contributors"] or 0), + "attachments": total_uploads, + "comments": total_comments, + "reactions": total_reactions, + "today_memos": today_memos, + }, + "top_tags": top_tags, + "top_authors": top_authors, + "latest_memos": latest_memos, + } + finally: + conn.close() + + +def get_upload_path(filename: str) -> Path | None: + if not filename or ".." in filename or "/" in filename or "\\" in filename: + return None + path = UPLOADS_DIR / filename + if not path.exists() or not path.is_file(): + return None + return path diff --git a/app/main.py b/app/main.py index 8b3dc59..4ac5a3a 100755 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from .routers import ( legacy_router, common_router, meetup_router, + community_router, ) app = FastAPI( @@ -165,6 +166,7 @@ app.include_router(nomadlms_router) app.include_router(legacy_router) app.include_router(common_router) app.include_router(meetup_router) +app.include_router(community_router) @app.get("/") @@ -180,5 +182,6 @@ async def root(): "nomadlms": "/nomadlms/payh5, ...", "legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status", "common": "/submit_meetup_application, /create_user", + "community": "/community/memos, /community/stats, /community/attachments, /community/upload", }, } diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 9f5317ef497bff72f29805e20ea05595691c297f..751e5ff57a298f7934cba94a89dbd7cac3946b97 100644 GIT binary patch delta 60 zcmcc1a+`(sG%qg~0}y1N-<+wrk@r5MqPDYDOhHk8R%&udOhIC0R&ioMW=vv10f>{E MnpYAtnV-oI0AZsP_y7O^ delta 62 zcmcc3a+ihoG%qg~0}vG2Z^;zc$a|krSy#Wjyc~p!^7Bjd3lb}{iW3Vm^%DyUK%Csv Lyb}G%yi9%oZ*>&F diff --git a/app/payment/__pycache__/base.cpython-312.pyc b/app/payment/__pycache__/base.cpython-312.pyc index 8bd3013ac4ab7bf375f98fd2978fe56d8a36e711..fd323d0b27f1cc3b99aa8872bf4680acfc9ef53c 100644 GIT binary patch delta 61 zcmbOsGEIc{G%qg~0}$jZug$F9$h&|^QQg@prl2T4D>b9RNh@6w&|y delta 63 zcmbOxGDC#-G%qg~0}vG2Z^?An$h&|^SyR8fyc~p!^7Bjd3lb}{iW3Vm^%DyUK%Csv Myb}G*=a@X$0c)KVCIA2c diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc index 103f59f050c6de20c0c00d6c86fd18da419faa1d..24308778c28f66366a546dd0e80d1c5cd5c0c23a 100644 GIT binary patch delta 61 zcmca2c1eu)G%qg~0}zy*-<%n+kvEoEQOnsXrl2T4D>blb$Q#S7tfOCEUJk-V`S~UK1&Nhe#fb%(`iTVvAWm*- MUWxwZZsz0c0CS}kVE_OC diff --git a/app/payment/__pycache__/xorpay.cpython-312.pyc b/app/payment/__pycache__/xorpay.cpython-312.pyc index 955ed8c08ab672d046aa2debfbe8bb304a6680b2..bb4b95a29219cf513d2c0a17e75b8e0b488035b7 100644 GIT binary patch delta 61 zcmaE=^H_)XG%qg~0}y!b+?*M{k(ZZQQPbHfrl2T4D>bdA)S8%o{0|0it83q6V diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 99afa37..52065d5 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -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", ] diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc index 1848da243771a58eb071101a4603e09982cf66b9..95fd57c197ca8504f6333f4b54ddc51c4ef6a527 100644 GIT binary patch delta 164 zcmcb>`kIyRG%qg~0}!O;ZOas5p2#P`STIpNRwRWng(*iMmnlk+ks+NSg?SNUl+eVU zTZ|l&(-{jzZwVyl=jN8?WtLRN7v-0hq!#&U3QxYxSftAdmnh-}ntF>pK0YxgCq8~9 y!)K5!zr+&@3SuByien}>Fo|(V0)-iYxLAAg0wxvyy9|b(SU9bl%JKFToO}|SeaFvSdbZ$SWpmClwVqs MT2ve}*__cC01LenLjV8( delta 60 zcmeyt^oxo6G%qg~0}vG2Z`sJr$*8QSUtV4g!bSP{CHe)4m087!1)2JZ1qJ#=`K2YP LMaBA)O&Og5JBkz5 diff --git a/app/routers/__pycache__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc index 8592782e8446d1e40abf95742da938c0e69e2729..f6ebe17b2ca2cfed62e43a4f13cb0c17c32ed85e 100644 GIT binary patch delta 59 zcmX>lcS4T)G%qg~0}#AExp^b^Ja$D*XRDZkqWrAXhcS?@?G%qg~0}vG2Z`sH_k6l??zr4I0gp2a?OY{p8E3=9d3o`W+3kvj$@=Hrn Ni;DF(pJK0L2LNK$6+-|3 diff --git a/app/routers/__pycache__/digital.cpython-312.pyc b/app/routers/__pycache__/digital.cpython-312.pyc index eb292c012086b3c52ac76b621c4b114d23ffd11f..f9cd87415ded1f2413f98cd92ac4e23f673c5c10 100644 GIT binary patch delta 57 zcmaFC^o)u7G%qg~0}u$7ZkovbQ&G#=DyE<)KPxr4B&Hy-GOIYTATuVhpdh9wzqBN^ Ls5oY_I-?^19W@iz delta 59 zcmaFH^n!`|G%qg~0}vG2Z<)ybQ&~s9yu2KQi}LeJ^a~Ozvx*Z7GW8P+3iONeOG{FV LiuEU}GCBePPOlVg diff --git a/app/routers/__pycache__/legacy.cpython-312.pyc b/app/routers/__pycache__/legacy.cpython-312.pyc index e36aa3a6fcb69e13f716215b75694774f782e19e..34c4906edd5ce76e183c1025ce999831ee38ad17 100644 GIT binary patch delta 58 zcmeyz^oNQ2G%qg~0}y0AT)&Z9m{C#F*(#=>C_gJTxg@3_u`;VTu^=-hv7jKPD8IBM MwWv5|vLmA{059tl6#xJL delta 60 zcmeyv^pA=AG%qg~0}vG2Z`sH#%&4rbUtV4g!bSP{CHe)4m087!1)2JZ1qJ#=`K2YP LMaBA)?HO$WKB*Jw diff --git a/app/routers/__pycache__/nomadlms.cpython-312.pyc b/app/routers/__pycache__/nomadlms.cpython-312.pyc index 4b25e44813ca817e20f673832c0ced1affb7599b..07b217057c44127f1a2e8d5faa96599a2cea80cb 100644 GIT binary patch delta 57 zcmcc5beDRZKxqepYI7NlZavWma)wL1s*1K|xGWerZW+ LQE|*<1x9-SINTI= delta 59 zcmcc1bf1a)G%qg~0}vG2Z<)ybNm*CFyu2KQi}LeJ^a~Ozvx*Z7GW8P+3iONeOG{FV LiuEVUG1>zFNsJUs diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc index 1fe4d2b639a323a0756aef901f5510bccabe9ff8..d5bd854d2b7fa437f2950325c094a9e728ba92ad 100644 GIT binary patch delta 61 zcmeC4&eS=biTgA!FBbz4Ts*&dBX=E}qPDYDOhHk8R%&udOhIC0R&ioMW=vv1K}=D8 PX-R5Pam?m5Z1-CLeC!t9 delta 63 zcmeC2&eT1fiTgA!FBbz46xwgu$X&;#tgByMUJk-V`S~UK1&Nhe#fb%(`iTVv`bGJr PC8 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)) diff --git a/app/sdk/__pycache__/__init__.cpython-312.pyc b/app/sdk/__pycache__/__init__.cpython-312.pyc index ecf6064160e748ec04c0c00f15b129943356891e..64d5f8c6104ed2d3bf326d587f4d64bc0c334870 100644 GIT binary patch delta 54 zcmeyw@_~i>G%qg~0}vQKTECI|FQcrwvsFw%QGQlxa!E`Ym?(X&CAQh00imwn>TW+Gs&trTg4O<yYC<&CAQh00f2hTQ+j5Gs&yzmzS4=a8Z7KiGD$1Wma)wL8g9UL4kg8O1A!H If2Qp`0Nbe&CIA2c diff --git a/app/sdk/__pycache__/minio.cpython-312.pyc b/app/sdk/__pycache__/minio.cpython-312.pyc index 9c2d77606416070dff9001174260db39191769ad..26baa0994b05177e81af317f08267ee3d692ec73 100644 GIT binary patch delta 55 zcmeya@Ku5PG%qg~0}xz#w0+T9smz36BGad delta 57 zcmeyW@LhrXG%qg~0}vG2Z`sJL%_6U+UtV4g!bSP{CHe)4m087!1)2JZ1qJ%WDcSm) IgISb#01WsNe*gdg diff --git a/app/sdk/__pycache__/pocketbase.cpython-312.pyc b/app/sdk/__pycache__/pocketbase.cpython-312.pyc index 73352627b5f0b9495bad36885cfa96f97b1436f0..a316c6278cffcf7b857688b1e6ae913caaaa66e7 100644 GIT binary patch delta 55 zcmca9c3q77G%qg~0}#YNTECH7iAh$|*(#=>C_gJTxg@3_u`;VTu^=-hv7jKPI3+t~ Jvkwz5Cjja&5-0!w delta 57 zcmcaEc2kV|G%qg~0}vG2Z`sJL#3Zk+UtV4g!bSP{CHe)4m087!1)2JZ1qJ%WDcSm) Iy_k480s1cz1^@s6 diff --git a/app/sdk/payment/__pycache__/__init__.cpython-312.pyc b/app/sdk/payment/__pycache__/__init__.cpython-312.pyc index 218827e0936012e4b3f218bcac11fe2f8d57905c..c74dc619d2ffe2137de1e29173bf4c5cb34ae820 100644 GIT binary patch delta 62 zcmZo<`OnOKnwOW00SK%gt>4Jq!KkY5Y!y>bl%JKFToO}|SeaFvSdbZ$SWpmCoRSR^ O$W6^FiJ82M(H#JGZ58?e delta 64 zcmey*+{nUxnwOW00SF51w`}C@U{p8MFE1|#;iCNf68(b2%BTVFVpP&}wu&hz%FjwoE{Q2ftjsD-EXa&WEGUR6PAw|S OOinG1+5C}Fk_iAPU=&0E delta 62 zcmeC+=;Gi$&CAQh00f2hTQ+hZVpP%AFE1|#;iCNf68(b2%Bi{nE+Gp6uSTb diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc index d6c03064d7825454a1545fced85ce3220893f139..791667f84b373c792499f3262f9eda472dce7a37 100644 GIT binary patch delta 67 zcmccZa?^$TG%qg~0}!P4Y~IMN%B-aAY!y>bl%JKFToO}|SeaFvSdbZ$SWpmCoLW?t VnVec2vpIy>ft~Tz=BZrA!~yuL7p(vQ delta 69 zcmccVa@&RbG%qg~0}vG2Z`sJL%B-TRUtV4g!bSP{CHe)4m087!1)2JZ1qJ%WsYPX( V$*INqn}e7g*cpFqp3HSj8~_TWwWmHmgwu&hz%FjwoE{Q2ftjsD-EXa&WEGUR6PAw|S OOinG1+5CsGmk9tjDHRX^ delta 62 zcmZqY?BwJ=&CAQh00f2hTQ+i^WmM77FE1|#;iCNf68(b2%B dict: + return sqlite_db.list_memos( + page=page, + per_page=per_page, + state=state, + order_by=order_by, + memo_format=memo_format, + query=query, + viewer_id=viewer_id, + ) + + +def get_memo(memo_id: str, viewer_id: str = "", include_comments: bool = True) -> dict | None: + return sqlite_db.get_memo(memo_id, viewer_id=viewer_id, include_comments=include_comments) + + +def create_memo( + user_id: str, + content: str, + pinned: bool = False, + visibility: str = "PRIVATE", + state: str = "NORMAL", +) -> dict: + return sqlite_db.create_memo( + user_id=user_id, + content=content, + pinned=pinned, + visibility=visibility, + state=state, + ) + + +def update_memo( + memo_id: str, + user_id: str, + pinned: bool | None = None, + content: str | None = None, + visibility: str | None = None, + state: str | None = None, +) -> dict | None: + return sqlite_db.update_memo( + memo_id, + user_id, + pinned=pinned, + content=content, + visibility=visibility, + state=state, + ) + + +def delete_memo(memo_id: str, user_id: str) -> bool: + return sqlite_db.delete_memo(memo_id, user_id) + + +def create_comment(memo_id: str, user_id: str, content: str) -> dict: + return sqlite_db.create_comment(memo_id, user_id, content) + + +def list_comments(memo_id: str) -> list[dict]: + return sqlite_db.list_comments(memo_id) + + +def delete_comment(comment_id: str, user_id: str) -> bool: + return sqlite_db.delete_comment(comment_id, user_id) + + +def toggle_reaction(memo_id: str, user_id: str, reaction_type: str = sqlite_db.UPVOTE_REACTION) -> dict: + return sqlite_db.toggle_reaction(memo_id, user_id, reaction_type) + + +def upload_image( + data: bytes, + filename: str, + content_type: str, + user_id: str, + base_url: str = "", +) -> dict: + base = base_url or (os.getenv("BASE_URL") or BASE_URL or "http://localhost:8000") + return sqlite_db.save_upload(data, filename, content_type, user_id, base) + + +def list_uploads(page: int = 1, per_page: int = 24, user_id: str = "") -> dict: + return sqlite_db.list_uploads(page=page, per_page=per_page, user_id=user_id) + + +def get_stats(member_count: int | None = None) -> dict: + return sqlite_db.get_stats(member_count=member_count) diff --git a/data/community.db b/data/community.db new file mode 100644 index 0000000000000000000000000000000000000000..9c8f62fb4dfafaf06365ee5e9fca1fb281425e1b GIT binary patch literal 86016 zcmeI*&2QW09l&wXlx54Y&9t^57}*f+u*HHSi*J%FVaBRDOcBhr8%OSvZi6c8CDCe2 zjwKh(h8>JxISeRKeq!cGZUS{_z z*fRa_;q&A3;q#DsD2cpwb*dR!+|Kje@+swYC<_=6W5M&D&nZ_MN@zahc0j?snB($>E{5)~{UMG8fzxy(qh@ za#8LJ77woT<&91A${Xu;y=8ClxV&jzHaE@nRr6X8qg{Aw!P+?X#=B&$nO2ip&0ky1 zUo!i|;^wQ7*bQ9>by{fa_KtNRD3%V|&CdVD0UNHU4M?0ZUwPVM)B(l3x+{&vlB#ex z*6lEMu4<$0?=N2R*+AR%=5#t(f7A75bvw)Z(@wbgU^pAl&hKBN4Hp=ip0LB z9^WlI{$fva_R^-CXRQ5VQaFruo7chKPP0^L72Sq#w3*!*lY#c-TcW>(xic5Mc2%Dk zizTdpVjy7u(Z*fw%+P3d_Q9dtomKqq;+WNUlES@FUlVrA4peP?NCS5B|F7-WU7zDx zTywvEv)(A}6dR@8WB22=`c5^NPj>d(!QHak=%45i(W?KrY8~4P<{Rdw>>VPk9rWe= z)>=VUf`uRK?;KaRfrp*`y?j0ziESm1Z_e(<=-tP=NfY_;rz@hTL%2mm%aKSdu_*ks z+d4Z(sp4L}(Wq9A8cZj3EYLQ4GxxX6&YT6OnE1_6t6eS%hk`Fkpv!fZiVO+knfATf za^iTM?cH!Bwzw$V@x^Jit$miSGNVJHbn*_WOJ_#>T`>}iFA6{LMdv*2(*v`|lGTSe!QSR2}U7daJ%&w_f+$>F;OV zPI2}Mt+se+Qt zX+};{mJ~&KXIT24XvI3;iDekNR@Ssy*+?5kO;rQ2{`!kwIzNwzsOY&=YCy!J^uyOj zCKAcRd?%)wsi|qbs%|f5Ge)VV1Y&;U`}UeEx|&inBc-ZKdyShvT)5S4@3nHtWNELy zbgNW;Z@;H+{lt)nVzQko1Z4k@P$1 z^Hb5tsRRNDAb3(Sd0A@KHpcwnkHFm+^BOhyAG zIOG2ZA?Y918Guiv2PfW%3Lt<00tg_000IagfB*srAn;WYm=Na$=PMiGqe<~-awICw zN1Y7L`2WL@^qKT~={M4cUlk(ii2wo!AbVb z2cmyw@AU*m&WS70-eP;N3*e0ZzX(ZRO8=DpE`9OR>P}z;5I_I{1Q0*~0R#|0009IL zcs2rIaa;)7Pon)aB8uZt`}+Ta^jgUJ;eh}G2q1s}0tg_000IagfB*tR1P)gO>y86z z{`|<{%t}eGRkIn*y5WGP8_Rk%@NN6x{RW(y2&4?_h70L*(JR2di9mg~bhDbYCN{tI zl(!@pR9f%*|Jj^kc=rXc-~Sh^%l>oa{~;TgWCRdE009ILKmY**5I_I{1WuTMef@v$ z41;%y!WsWR3`q}9SY=pU1Q0*~0R#|0009ILKmY**5a<#3x|oj+|CE6<{(ofM|F35e zCJ{gY0R#|0009ILKmY**5J2Fh3JksfpCEl^UH|{2R*UsU009ILKmY**5I_I{1Q0*~ z0b9U-{r}$Y|J&F93!Sx}cp!iP0tg_000IagfB*srAb`MW6|g`5&-nkeZe8ky00Iag zfB*srAbl6qB#}tYucT6Xwp!MVnpRVc z+O}#Kx>nY-TG>b&Mom=@TGf5~c7J+0t7NrIR^58*?Tvz|DOx(A=m|AdP_jAA$Z6>% zMN!`I-v5_Af9W-31rR_00R#|0009ILKmY**5J2D=2+W9ysQ>gqcl`gCXApoQ5I_I{ z1Q0*~0R#|0009ILK;T6QTpYDNClGzY&jobH|GfYIqP7ol5I_I{1Q0*~0R#|0009IL xc&-BW`~Uva0^RZdqvsl$!Vy3K0R#|0009ILKmY**5J2F?2+WGfXyEDq{{^gzU{?SD literal 0 HcmV?d00001 diff --git a/data/community_uploads/1773562558477_89a7aa1d.png b/data/community_uploads/1773562558477_89a7aa1d.png new file mode 100644 index 0000000000000000000000000000000000000000..da77684e50f4247ffb11ce4164cf8946d8274081 GIT binary patch literal 21030 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 literal 0 HcmV?d00001 diff --git a/data/community_uploads/1773562715937_0afd6608.png b/data/community_uploads/1773562715937_0afd6608.png new file mode 100644 index 0000000000000000000000000000000000000000..da77684e50f4247ffb11ce4164cf8946d8274081 GIT binary patch literal 21030 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 literal 0 HcmV?d00001 diff --git a/data/community_uploads/1773565492766_a3fe972b.png b/data/community_uploads/1773565492766_a3fe972b.png new file mode 100644 index 0000000000000000000000000000000000000000..da77684e50f4247ffb11ce4164cf8946d8274081 GIT binary patch literal 21030 zcmbTdbzGD0_djk2VE_Xa0cqK2=~P0xV>Hq|WW=a}qM(#WjE)!Wh#}1w45hTR^Zoq&dpr(f+zjsPoO4~zQ_nkGTT_XW>jB%t+KJ_l`B+Uf4{Cy z+cWu+E>d|Z8hPot+k3&RpmtZ3pmuf;Pe*qz3xhkPKheohQFx{AYq8mQJHf0iYNSTId*w=j`1iy$>MK{&J>T#Qb6&ZUy!6AQNbB#%-&gBrUAgim z^|}jE=IWInLbpeB;n%LbbGlcOfAat99~8!oP<4qn3phO2H?smB+c#8vV)^p6o3YE~ zvTZ!O>z}XGPyuFoAc`Lm_`(8E@sK%-ip+1b>CUff=wgdqE@ry^MVC2r2;P9tsqA-P@yhcx z{N2;#fvw1boB^3s3`dvFitr5E;+OoG-n&MG*8NS)v(cSMxd9aXp1lpW+SjZU4eMpq zRI_HA<Bp3W7V{%8>B`X}6?8Xxt*s$m` z?VUfDnCd}zoi5N--5YK~kfM6#lbb1!PrnpT#mOStYW{>aZ1O@%%|A(1r;|>ifCGjm z$l6>;GJnOau?y&q$!572w&HbS9xmV5C3}R?kpOrT9Gh6GxpFYdUk09I$TMh#e)TZc zlE79>51$X_gU` z)di-h3R!Ty?2Zb@e_weU&mjVivrZ_8=WuQ)siVuIUn*Rq%UfUOJjBkpNah3^zEDT7 z$Rf~coABVD!3w7dKg3=UijuNB^l=db5FjohPSU%HJ%)U6}FVp&6ZzM!12Pw!36t4M4GrqRI8L-fx> zY^OpNEN6iNG@-}6AFS!joGym7u6 zW^+~D>r0&LOC9%SHtr}A``p^U>HeN|a`sFLI<#jtMPD|jkVC=MG`E=apoP+R+iTW` zab)H<^b}s?Zy=walXYeIJd9e<=`v6KMX^-)gm!V0zDmobS^tO0&>jb}u1(RR%if8a z6LmkLH2u*(VaL!Hy)|hI^w?G>v$4Ez8vlwfzcLrZ^^bStHx&0qI;^&^5hs! z)Ni4SO+HsYx5!B*lXl29`C%&w>P~+t)vUDfu@}Im7kki7qoiX>uEMejUcl!+;p@Ep z#VKpN^;>fF_mf0Pk0;M?tFJx$lF|g6o+dvjYf$+1B|*%FYFWcNT&+6uoB7IC&;(;f zis=bLjxn=90%qJZ81l|-w(=?o2*Vghk0UBdn>7KR@z(`lt|mY7H%dH}OldtdUqYtQ z6L>m|eyhn?LHtm7Usr#(p}qPL^f)B3h7z&zRfT$UzBN=1%~TlAVc&2TrGj!d-U-`l z%ncy+y}k9XbtM*f``-plSAtLXU*08wUj9E7v^(C!R-INU;NUe}X2b+|EQLHyYUOwWrDD|Pi2Rl1h_!a`gK z`mTy-u14c-^whn9d-|<$$*kaUnB&%Bd00C@~ynK*WRuCZ+)X=sw8Q93@rxMHV5Vcu?+QfF{6{B^2EQR_5_ zDmtlyo=R|y|X^TjnQZ00ENpdBoAd2(1p z1^k(cjcF$34Jw~^Lk`mECp{rvhiIMS`el44VJ`>M7yTfEZd|b}t{KxyzLhs>z8va< zm95%4Ur_UBb-s zwEXI1)=3}!t^THp0F2FeSJ&SNcG)8^bg)$X~t{%Lh30n>Ej?lUFc<>)p%;CPk#J z1{$thwA&qMt1@ZH8$?J!!I~uXddScTBFXU-B-BBJC{SOim6CXu7gIaK0!?Vu^ybfK z{7ygkaiy~Es6uG-p3t(bFRSJFk@WnQmQ`YzOp<4bT8im5Rt-K^1t|PAhIk@|xub@4 zhq=`mPxMijke?TEe*!h$HUPyem@AspvGV&Y!{i~Gem`qWzDskCWddCL9vElQzRse0 zot0<89)+TLoy9+6vO+)F>2}oMI*1;JGj(pBzBDwX)_L83oYJ_E3X#Y%I|QlX%GhB+&fNQtYJO!qK~0>R9jn)qiz%z;>EHMw4%xeEsj?>M=FUYrSP>QQ|{V5Wh`8 zXPbL|z%1SJ=_TJtUv`)f=&O})pmIw>DeS=~m;Gw{4k4`InJ+i74CIZIOFg>}c(x&y zE90t=&oxwjFy1d&XON!+%nDapHA3+{e3z5iryjpd%|c+U`827*hs-?mOU|J!%wcn9jcdyMNkC`bpdMi zXQ1JOCrbsIy5X`9qz`QR!8KW1lYZecnPEjVeha8y_+yww4&@S?UOzq@%VJ8t2N*X= z2x{vD+q&qev-2E+RC`!Xy19Ru`@B(XfIvuw@;$`==tWO&dQO=x*VX*;fTXOCsoTy7 zjh6dmdiF@2azNc@gtUg*{SGwt+~}7lM0)4#ruh46Jh0v^QtM&I2hrpu^sND;ce(4! zF_2{0daL)I%ZszQGrg43Tw*h9SZmW#5kMFCb8aUt!WJn1xJ`)L{kH=&v-#a9CuU#uLvX1v=!`M-(=`-cPUL{1s$v{|eU*wJd8}V{~V& z%@ZS1-e*WSlPvJ{EpjCYVdc~n7fJPn?cF%-cgnom#vcLEf z(QvIjFq9`)z%skX@)tPIKJ-|1u_W|qIwyPYXl-4uT7hQUx+%#@aJ3e_XAe46ZGG*0 z?FJ$$R=r;9bHuAs=MSdn12q(y)bj_CUJ_5k#qJ)Mxrz;ARI7(3Nf^@@0)lYp;!CjO zLnI{=zJo(wAw7vN- z5QFr&JDm{%yB+HwIV|$C(qP*)dxE=8RL6}5PycO%oZuZ2Q2NINR5wj)=Av9^9h|b! z=#mCl0>!+Q1pU%qzyWZ*WbVL5PD{QDR&Zf<%YW6xavD)%mqLkF z+ed8=n`gk>9$t%X6=|5H`Lyk-o0oipc(Md0}_I@tfrt3m9FEr~6q|z+!X(G`esmWhQ)Wx~gVH?ntKP0?i^S2jY1} z5%$yI?oWy62K(yi_!zQTG8f1Z z>Gnz$FA@gN{-|PG^XGyzxLxvFo#6N5=L&!_-c+9opCbHybz`)PW>qBRqNSy7et4DU zCPZJI=bx3}pf%9Y^}V&4h;w7?K0mq+Rpaf=oKUvWRJWVFi7ln`t<{2$hTsuNk~0Tf zna74Aum}=7GiQ*+lk`YNZe!&X)kBeWWh(f~FyBB}!qmBR2c1D&A{XX`O@k55tWU}EbTrKU6p5`Le=uoZm zKr1fIkrcjoRP<(8-8F5O)IJsJzObyFWVrW9K3n_QgYDDvd!K}>cQG_zJJV}TtG<@z&8Gs6g8EBhdqozbOHR!;RS&}1&Mak5bHuQsv&t4&;)@)zr& zvXww~p3)C0SZi0Fu=5BBmlsm7W98eMU{x4!2&|t$c9^5sSoXQhVu5sio-3vzJW%fh zlpczLZS`W|ib8;#_p&MOJSA9_a8pB7k|~*=4AmOV=^Cr)acaQnDYe1|r>)nCXWO=Q z7F$sCSe595J4b;sF&#G5Uu;>LAKn5y0zX*b$bn6T1ZNI_5@@wrrhDQK?tg+wAMKE2>$I3RintG zomb*L)I}$>JtkN`cF~fm1)HJ6XGnM4c3zZ*B3QroB;g@_+F4N3=sM9?iZ`jpnW12B z2C?B7QQzuKoKgXrrVI7co8asH;`2IpFrSgG#v2*DBP5H*V~|Xlz*Nb8@XGKHaBCtv zjtDC9=GPb{-F*+>#}HM`t);EU%*cAu9o1)^=SQLU5FRQ@0xJo<^4$U!VUUUCtmRFl zi(%F6A4iJ(y^L~>7?#;Hen>o}T5#3`cu zTwdp|H$l$kyoq862a~_CO_R{@*XAs+k!tb7>2!=vy~q-taxS{~Js$G}iaJRzW>!NH zjlu8{<0S9l*y3*{t0rmQ%3vjp5uJGE3D)jj6eipiGgxE(7z4HI*3Ng69IVOcA|PG> zVX=d+B|#0yw_D@hH8`>zgaG)*kzU&^_wgLRpWVp?EEH$29v#UmmW0B`8-fM?W6 zxsRRGbY8K0IaA?itSChoCvhy#Lg#USuWB5VTKw^i_fUog1&A1;vM5immx-q zPdUg~o)?@Xz1k;<9A)oT(*T5-`%=^?y+1-z<8MzuP?AY*uQtQmQOn-5qGuLZPN7$WZ)Ny?`Ij^%EBbDY9?ryv>E<@a6 z4*LsdTL)Z0Oh8Z1r2E3Rug`jMB;oF+aO(hB**-`x3TfN`CHmTX#|1 zaIVNNz5wgwK|d_|7%_%m$_$&?KD}PgtP<<*Dwugdg6}qVU7fPIPIz}HSGfdwMH|p(+XwQ@JQLN|_=JD0R=- z_}vpHw8!E1W-g4g=TVCuvRp-YQ`o3PD}SKhvC2HBG+HIwpjq%YD^KC)K?@Nno&Nl3 z5j$j3Vl{U@6J2q`B9`Cnm$W{?O|R+MzkchhL0aew2*Z%clJU6ZZ`FB~3cpO|xDeIP z&B+~jewim%cM_VPwjpb1+nl~-I1&J`S%11E-s!J@DDwodgD==%vGER#*X+NWUn+C~ zzFQaQx;l`6hSgFh6<~KW%7k11P(Ws+mT{EgHU1u;sA{&>VL;SS3)JR@Kt9H`bM&mu zwZz-M$*~z6R}bjY0A?t&p%0}KrqB6b(;yah##XlXzg6j8Dn~u_p()f{$TX~}$Ew)H z2Q_TIH(K9y?1A?uE(#QF_e-{&aEu62)|0$Zg7>S@ApRO1!sn+{FR1jen5Y>l4-E5L z^9-5$x^VT^Z8t``^4N4+w3;L<&r>ObMszpP`1NtqQY!$ ziGZb?YT#-9iSLsl-R~)UP}l)_57MMhDv{ess#p`eII_C#Dh!!)IvbHH{te#jeKuGXVO2=!a}=eg!PW*EDT@^)mhU+Rtged~4^B zkm|Cbew-;K;-o*i6+!`V#$ry$BJiEIc-!L>TW*n|O_hh|k z11>&j2|~4w{hKK>WXJ$yi??c5lv)qE{4g-Z-zg>OK z&IW(+7PwMj?uK(Zkw+t->qkOONiT&|M;PanuL4bEN6b@6|3AN(SsC)?wz`tGln>8v zT}y-EwnanR3)Lz%@j_A_M2*ovrq4Bku{;XUe9@}btwIFP2yFisJUbT_J2&0%QUACG zxn)wo24jcYMI|n7l;m5Z_gvG~Qp%8M>QZ*C1Cv(iv#J%|Rud{YHcfn}Ft6a9m~@VRh_7iklr?{U)Uz8cAp6jyZm{bdMI44 zP=6%HLMJwi)9+2rdV?DjF00I`ao>4OQdObmK`u`Zt3zjqdxBZG+}89e2eGlW(XY#^ zGEt=`_a)T)~AbNeJ zR8i`4327v|%o~XJz4_}8{6|F7o6wYqx;utYQ$_C@qsv4dJh>r7-#Y=z$6w)!##eK{ zH19VdYH2;D*$_vMh&+h8x+X}ZhMyea+C1b)@7M#3CbC@zabI6)r zmz5k;oX`%Ryo{oi(CLBhbgQNVp-a=9DFMde)NUqo6B4)*y9#LGAwt`?s+#!oeCx~s zejaUZ-d8je6IUh3sdZkHJv(^&USMPK`ncn-vOPpsZ$`kn{(Y^2B~83b?7?;$wY3v- zrhbWCRJ$US4hz%qi;A5|_&R6c$|?KJi^m`fv+Yred=7f!Y$+fF=m+nE-n@2*B$kB? zq22-94V4RXOs}pF9+3$EvbU1tX81tXJmC{J!zV&hE=T}b>fUA0c5dCCQ{*lL8u&q?ch~P1L@Hy?? z#^mMV#Kd}vgk)BNDK*+oJZLU>(NYY$@Wm*7<7<~^NLSy~&|fQjVUS5Y-0l*QnRJXT zQG}SuowSFV(#{*Ull=nw#^@MpzB>yEoF}8%Z%0l-Mv{3(VTIRQOBxH=A4n+xuqK? zZ}Nygm3ywo((2B{CZ4kL9DeHKj!8$$Lhb#Mk;mA#d%JJ<8uyn-ZyK|g0-AA$w_@y)Gf56mY*N@lkmejIK3!z-I> z416uEG9ng0jhC$Z7ji4-ihtn@mgB=brD@wHjXg;ul{{m4nFn#RPTSSGE!5^VRt3#V z-xcl(wzeAZ`G4-9UU-tv=l{p@@LA-A5N&k(SW>K`dg7q?Q-~iwe)meF%p+&NcPqcn zvtRVA5-+s(&a;;mN%AV^R3QT^`u1e{4>BNR|H(gOne~&f70u;#@{F`ZF;QMpJMP|y zAQA1dWn`e|3D>|8Gcx*;ewlA<&t?&(8@&z*`PUN}^wK0p6OoLW>Ns=YlBV76YE)G} z%jWD0Sa=tKr8GR$TJD+dwh|fk?N9n{7{&7a>gAHmbkJANlS<9tky%#giRVUG7q3dg zp*;gOeF`+)c^C%uox3}_^EeLkTL7ikFA6#XU)>~OTk>V?B#SWA4>TfVdi zu>cLW&u$7fwg~PgtdTZzaY;M6$tDZQg3`zm1a4$Y79RX(|NLUG=e#iKwkF?UR=w6x z*J&W<7BQn?G&o`kKQHYObJ#j118wYp%|W(yFpV9Tz#iCKS>^}X@={A{)23GJtX#*^ zsx^;Z8m6~*IN-pnDd2D*GqXD16E@e{C<(Ku^;{mfcrQ(|-tyAF)?-*V5ehhJqfe6K zH@TxghSlz+?;eMvw`3BHPh?ZdxV6e=vKYVTZ)7Im|o~Zhn=wiUmnyT@X z%C5@-haF6jwFc&(OpsHetT7;+!Z{{0_py@C4v~?i?+|!HuHz6uHOXAD8jAaX?B!)r ziqPPr(_pXD;KR)2fx@bI+;2<}n3fHE#SQUbZgX^93r_qMSZ(^#_MK|}V7YHjVOd#j zadBgDWod41X>Mb6V`F~lrn-xZr&`bI3kS_CanCjNJ>(0VVH$4v{t+_p_3lZaOxDcu zUVyk?gN)A$LHxY9N7nKYJd^scKIX;Lyo^uG=~$!wVcnPMc?qA|^)X9xCBO4FBJXgl zEM_`iV%OoYze((HMxfp&+?xRjoh(4sCUSLU;O*jWvD0p?`FoF|@+D!s(o+pA4=8Si zFb6;D)SbWoyEp+O|0$^2ZPI(PmT!}3OtKnX>7KgwH>e?Nb|Qs%LA?8CzXDwzI%rjU z9pq(-+TBtKFIHIA(1p9GNlleSX?QK27WFO9Ym#x5LGLK#+`i8=jS04##}auNCw2npTJM@2kkk*KJGBF6aUGQ zPnX?7{wsVV^1+X#_mlcR>@WSv5#VTv`kl(o>TW4kZ?#rxrwUaX^dY(&gZ$?s84q# zH>8xS3JY7#RIN^Eq{)m-2E1|hbeQ5F{)?M~M&zD!*Ik&garYIV*ta9}zv|Ap3PlAR zBjLw{m0?**%j5Vz2C|f9KA$ctm`=;kJ(q>xz^$6&(=Y(>{^@C0AiU*bn%R=Cah2)d zFy_Vr6Y)OjcV`<#1OK?c>0I1!Pi01vH-b{gE(_ZH|Dr_x%xzb0O95BI2-Le(^?c<@ zIpT}#Wt6H>PmV98+phqo-1ved6}v${y>__-4*&O#fBXuDeyq_n7Th*0JjsgRcTKg> zgZJf~WU3rU$5%AW5s!sB)W?6&kcG~Odwosro^GEAJCPpJsoBDp+nv_nOlw{76~;qL zxI#@)Q|=Gf6MtS@Rl9tbUl#llTli#Ut56p!AM^`Cj<#r~oHNWNm zBxrPSJD`AYGIKC=S(An&h)xlkR#B&J;Hffny~a9y(p@^0K+Mp z2$RHf0+??F4i8@}Wp7#M9qbs?Mubpk>3x-~m|={JZR^q=+q&-s+nOM`gt#ti0){RB z`TgDQ?bq(FBY1KGtXlXTKW=kZUbiQtPoZ=&HkuBqDM zXsVyQNz}jVeyOa5RjIhp#Dws~fcU_Ko!ctgVZ%gyNvZir4FsCs^jd*19_=+VVO3s5 zNMR)1cD#8n;#cR?ED0Tx2-EKLl=IKbE$M)EXTu(_0Ib^Pr;lgB*|1ekWN~Gi}WiE zpHiSXM;+o8PP`hHS6hF8ATgY;`Vt4?&G%^@jBAJAy7qJA;6M+Dvb! z;QZ9pJn0Es{wSVFF@QRSaKtn^ZHlqDd~;Z=b~2)VPT#1mLP$p5xb~{7 zZwnfrnd$GgUO<1Mc_AWv67nZ77YH3~vM-w9ZXLq6wS2K9F|3yLRs#hQY7Z9YyUe6g zVmP3&zPwL+!g7p*#Wu+cq~7QC(AQ#ESr7jF_+E$^9T*#0VKOp(NoZGD`Rq|?eN=8< zbZLuw{Cz+Y6j<1NN;ZRxLRVUUH}6Y{m;=cjYT;@V$1)XTrxdyfO7YkEI8H2;_nRJU z1;|Vv2IgEI-@vSzXRMeqY{7X)PF7QlaGhRef_O4y+B3A&YwaWx=)3qcjEUp5 zAs9?LCs`z1$K!8qmMZ{bf3;Jm__9z(QY+4{Rst|@ujmd8lLXn7ymL|~P306z*ee2b zd`ENs^oI6-`DD{roGVR|zEI3(7W2N>^oP=qIJZK1x`<}wY|VCaor zx7f3CAB1ht}q|#$nsP}A*V~5?I9VbT&(T0^gAD^F5Wb+$M^*bByn1M-umQ* zyP?hxNg7 zM4P=*_9_>hHyv^_2cg*`x8*bXZb@cRdP&W@2dA!aS+46{)NsPxv`$RGu`ZK9ck)41 z4knEF-i=|hzLbte2g9#vOC61FeUy}xhX-91{Ea{{Z}LHhM+8Vlh4E~ zIHl{n5&i!8Ltx&jrPiScRR@_$@d)2x3qpbqe_x{8(h$&}t^5hE9YA(iIY4 zLqHU!87cdK>;~(_o7Uy~r`H~q$!-vpq_E#7>3#gSjux_ zg@QSsax_PhE?w4&Gg&8EQYQ(qe3Iy4TTDzoTDWV^sEHOO*Uo|f;;54 zYCO%;==`<#U|D_xe*XFM`>Lm1fP;9DD<@-ZhA&i#cl!jhkqDa~DZSZ|mWSQs8_;Uw zk_OM!Hti$e2O9Y@THR(UP3QUUz~D^RbEHVJqCPX(zk~ODli;H0r3Gs!AxxqxMWwKV z@OE+&k~Zb$>02UFJG{hLjsB#Rk!Tgr)HX3X>dqj>NIw;Z*ZBObI#KtPUfIQCA)g`r z1;oo~u<}ZT@L$~h(B8}M7zTP{T`4!@{pq(iW_*N7)vuNp3NaC@fMgR_8Z&E_8yWcg zBrLpY8($I|Fy7UXE8E|yDTQp(DkxmBnM0MWtvf5VZd4YX=RX@3N$aFfHNs~m>?>%xiAx^q@Plxd>M)6rjnOR2hRhCg|)a-ok z>^$l9OSVn#&zASI?RDND9K#F10_56_`^6fHmrHT%VDjTlc6P`axnW&hIJUV>!}tO? z3)01}kQ)wx-lWeqv0wNAF@yep<2~Ip3IJ{8^jkob+dk z%NM5YRye`oTT7P7qn{RzPrGsWnq~CX&uWXerMZ8H%p6eC%)P24eB~9*X{0Tu_6$6p z%?XI9+e|8QMb%X!%%SsY!*Ea;r*3V$?I>vG+|!Le@|zqD^e+_&_4F1@ADPIR8Ku(g#p6HQnO6ewLNR_#5 z`Rh22*E?r(=zc>H(+oBQ&oajhJn281PW&(115m#;@Y)kfB1nnChD?O64sDh^7vOg4 znsXSv)cGD&?dn)ps_S)Ay|FS!5U>Vn)ihI2g3?Q zgX=c%-O9CS+2cd0ulLg*VPBb+caJ!P@<9>-K`KcGB3a1;5ZTPuw+M|2>y3kBlw|ea zJ@Cq&M8h#A*=Gt#+WsVxxjW(GpI7tzaVuSkB^FmfR=_}tYLuDlO;;&_JV6ma#TonY z-yY50ZMo(9ziJZSa!l98sfYIFR4gVbMH6`?in>So5Jz*oBk*-y7J}E#I=YDyJbbFW zXV{k{jEh(cMRJ3}I(8#?RRxRtVCM?-zgUVMgp#Os_5?C>+5Z2Uqt!$|HX*6W%WB9c zpe?B>8TklX6%+;F4{{)l^AMu+N^vKoWfX0tx$=Bb=G`*NR1N^H zxdA*}y7@Cny`x?*7UUIJdu^oI=Qs*v>Tmf^Wd?}yaj9a&R4oL>R`9;(<8I>pRH9jQ zBi+oO0dS4gVQHyrE;?>xTII>dFIx+e!T{5e5cy|E`CG;otP~|9P2jv3Y|&hh;Ff%}o67EExH zk#3c{CLr53{J?fvf`t@iV~2}c|{r@=aK)|m^p|FaFNf=&OshMNbTv%_BR&uY4p zab%PgR93XMG7ap8KNdc;`S%&R7k_s6A+#QmdMExFxY;C+K@M&gBPB=Ld)*xC=o5lz zQ*zIiBLd|LKg4Mz7_FeYY0u3>!E=(Wy7m^x`W0bevD4HIRE~mu$?}b#levtIgy=jB@1w(uZ(wg50M_LovehW{ILx zBXR0bUiHxJj6?iHFY06ve?l~)^}6_zQGYseyKyWvckP$TIKu5W<3Ko7@rJwdN&W8w z;i57!H%nYCT>k%T1-F^s|GoSB^Y#MkH`7D<6mo7}C7O4I9i~+ao3~1$hD*Kz_;Epq z2D`So5&?IrWmO~cVpqeF*PiSjx<}maufgs_`uTQw?)cM#aR>^!r^2)Ly{_cy2-~MU8PqOqcW8Ke-{v`n3{5K?+G!^|9hOV7@ zX=Fe3IVVfz_!CyZf)RTGh>qM-Vs7uJivYdR{n6oU_)(<44P?b*V^H&x1Z!|l9%e~8 zX6)>fb!_yK>csQPm+K>st3PeF>y1A-YRdeT5(^^&y>*)slDpNAWS=xEO=4D~*bN=Dv6$2`+mV z{v}5MU>-B(Z^~YxNWdD~%}6dcdd9e&A|a?&4%N2%ax1Ko7u_RS*4zf;XZ|SE=?J)n z${2zYdVP3qM>GpZ5a~i+SU%`IlN?^&~aH)P?4X+IC(~1>CS2AGpsj>3m8<5OVPtZ{X^WRp4~jr(u2gHax7GA(#K3VNQ^yPL{?)Wy2br>eL*LJ5usJF4Q$(E#FSKJ3^>%6R zzsI0==9^}RdpPo2&APFKQDt*su5YFF*I`=N(&psRde@pvQ(HP02=itDx#k7cubMsHd@q znkRdC$znHFWoWhUPN8guJ67>86p{%_IeChr{0Jp|=Gn)z|MkwY`y(Y?AwHx2$%X=t zq2vgEhqgb%#QLa59E#QEd;twsx{%V3)yfDB%~sL?zEsgGiAPquqkPOP{AO&M4xPfV znTqp|YESm_dseHPTsSkNeuu*lyt3jV#A?0t7ed)M5}{G8F6M26J+}WJJj^3eN&R@C zh25le*#CDON+qF|(cA+i`(30Ugd-b8My1ew$N;bwf_IxmLGy3@A(6$Tv}8$@7JYTp zgxotx?Tgzn<6X-pRK@Xjc{gpLTG%=|Ral|r`u!7hoIZZD=Q{fs{BmBTU zt4MzVuP;1n ziGt7Ps^K?PaJNV4QzJMexrI-_qdj~8-P6WyeW4)tZC&OONyBE7Bk*Qi#fT}nN76d4 zc+Ih-ss{*BdT1-5Gf=h+MBw~(A4N~sVcgw}i~XhZD}+lAQ%u=Bs7d}xaU>ZEeto#` zqh1z`CY&YMKjnY0bYMwSn(c8Z>5;%f86B}k_5TL*0!C1FUxgw6yp}EYd@~RDvw8x^&U_2bZp5DNETAy}TI0oJ|_GRM2+QA63wwYggJp z|IzBt!WjG$cCwO2vXQ>>u)d#B((3)Z_2{l$_V8P2Fv_(iv**d%mhOU5Q7#wKu{Sv2 z42{qF=fsyVMEPI)IW%k(PidxaqIgg32Nrsj+LN zKcWmds!?U@tdxcvPG4yB9;N|~Kh~1USxtvY$SC*H6O+hice1s;#=YqnCXX!HY)k68 zK3#5mvLh=DbY7H@5)gCS{@=b++af!^RMMuSiHK6!wr>P1f|M>2M+^?QbjO>!Vsy-z zWRC>?-E+IQ(1X36!h_F6x-y7pwWnvv|NDqQ6t)y%=tbggOy#)(&uUb|)d8~C3j~82 z%Q~F~T#LU8J!b6EYnZWhDgQKB-csw_U7uWiC*%**j}0c^N{BL~Xsa;XQV7pdZZYub zhn47cs7bXMSqmhUE*HV>7#~grRt~+ZB%1l5^0**|<5^Ui6>}GLc`l%58rfrx9v-bx;VTTi|uzUQ?|Ao&GS>EfgH7U*lzh`bb(czo50b&s6Ay z-WVyD+q>#u{r!k&rs2>5oP2JyYo_u-hlR3^!>HmeeTKL=4=J-ZRzsAgf>Z|Cbw{i{ z$T?BbB0;{kGkkxmJkmq6ieDT(9r%Ia@n#ShY-G6?zMb?Rm>XW=rE?7zGBaJdIDS;n zyleOUT0!QG&!nlBBhZFWvchro){A%6rxt5T4}@o|NejK{FynLCtESs=skd_MPKy-3 zJdinlWnIpOF(xgyp^o1Jf&0Q=rG;z99^0S$~yoMAM?+bL|0#Xt=ldNdSb~{ zqfL4^Md%Y3iS460lBe>cwS-;wt-|hs@|Psm1ZYNGhpA_7(@tV%|CWLl{sMbQVyH#3 zUmmz$_>B@8L!RM%ai_IXH9{Uop5d%nYEQB39!H5{jU4x8e$mM8j~`{HA!X=geX0AI zfzZpxMOyLe@3lJaaYr&4(NcTcY-G^e+=e`GpGLnLD9kyP1>{B$E^S>eIdc`VJ`-$C z>b@1kC>_G1zJ;%zC=U{28`W|(MwQYMUz6m(LTO03ET9Z9?CX}dldC|<6fG3=@!Krx zY?up4+mGQgCEqev*WR8RWY-r5N|rtHJFUpB|@=0E6 zc$_D!F}>ohks8|;MPB;*k1rlLK>3g7?^(7A3~a37=T`6w8)HY)!KTlTy90d6;QKjk zbd}!UBm3YnupaBa#I{V_!WxMugnz)}q38E)tTBLkFchVsmQK$Hg*5zDB=E6`(sv=c z%4b?+FW$_|KhlcScdrm*lp8&g<$G`_RGwT3_i8Z^o2uV$)3VQdrd zohQ%wCl@4kMH>Cm@KGhle>zj|M>l8)HM}mZ?et#Z$@wYD#}K$TKr$q~S*=&qqFX@K zKa}ns8tJaQOwsvru?o+r9LNMi=YMLq+PL|6hoe2cT)nE?zpGVLm(^zZ;f{Ozq)hn~ z31Oprl&~hAJ2%||L3g2|te$k+;`r;)s9p`tR7r{yG#(s^s#>&D7o{mQUuVHXhye~r#v1zR9ruxid-O;2!Y!b)3d|{@MT6&Y1=^m1S{UhY>dv zMF9aZ2m*qD3koV=8Z<-%rCGv~C?O!SghinpcG4mu2y_HO1Y~ImTO<%j2p|fmAiL1) z$i9U&1c`(|LXvs3(>?lOs`~3x^`}?2-hJoYbN=Vts{87lpFBcOlAjPY8 znut7}wYDWbb;aRB zVX9dc!s&`d$D^u6%o{3=!=CNKV;Aui*)EzUSqXZlLzR6SDD^XKS2bL&F)L2?yz0uq z+OGFbupT)_<5r)=+LbY4FdTosU*y3btM&i2u>z4sD(x_`wQW~^rqv^!{-gW#!<-7g zJk@6{;)5YC+-~wjPN{9Su#=i^0cKVrB${GZpZ)VFt_^fATZi4C(>$*<@SEK5fHW0C zSr7lbBoxq!ijElW)2d-vabFxzFO1fXAKDpy>h_my?@NqOmeL2dhyX3yK>I~=xzuaD zgXX!knqxcS2+`!;fTF?K3cUM6K4D?8?~T`MuI2)CgmNiJW8d|e#l17@F=XyMhMz6C zTd<0HQF8X4N`ju@KG8x4OuompkjMuB0s<&xIwIzyWUQ*#`%=GCg5{6DY$M0EVO)M1 zF(vpp910wrALr=nt&q*d1uZY%4VGQph)z@3AwtMF>fd_gLd+sM%6-C2-(TR^gIg2( z?>Y;#_i67+)Qd+`RIcjwNh7-C`CP5Ote#pScWMkwF5T#Q^5CR*@w!_w7drHid7P9Z zc0Db6rcFB5#x%P96mU*D`F@VdvqVAOg; z!*+pSytIe!Dv^o$Q-?F297s$=bee19ZbUFaw}@KBF=-K& zJB@WpKNiwEs)hc6?V3JuLGH;x+iywPncAnGKhU0D9`tRPTAqHCj+1$;_Ds5rUGIGp z%`1oDF+m|?m3>eodgrYb937Byfl7`mK6M|FCepU!52q%v*55f*Vpp|)ZqK^_N+1e~ zWJH!1F4?;SzKCAp&NpTV`W+Sg=Bz}7?I1nym*!$!yAV157}vP>CGNV8$p@wn2~aUL=VKXE^eifq_wvh}6-Mr5&~K=HKd4 z0+>V9cWK`Wh8Ljx&%fMdRitoMtllSNkb4p3T?5XGw8y}5+%}hU!zUm9t!$>{A7rvn&C(HF zzjWvI*$J29t7}4kyxQ{=Tf&)0c_Q~ncnb-+=R{`GP5?=l(RwiSBZXe5%_m$}Z6 zT>$HM^K>~JhmDo71y>_A8b{VQ7o81IZSX2>b$&DX8Q&&dXNQk?2GO{!;d$)qW z9}D-!)GZ#MvmQYLAs2|E#`+C#QH_jN__698lTW4J&UBRr0IF| zhklfw(YVz^=Et$dL2_m~QDHq68ZN~^<+Hb%Q|-ukJy*(TSNlC zu-E&YV2!DGyhKT=_R11fiHuqCsR_$a2-;%9pu}mNj4RpLON_XW(ULCrDBWLkCS;d# zhWRq6L}=TTJ`YNlG%ILLKq4xw1qGJvC@Jl9%Ut*mvzb9?i^{E?uEj@RPb22{M)LN{ zS0^o3P9sS7P)P5p%U!?DR+B?-3p%>7tpbY=tJyQe&$Xi@40B+r4!{DVs)M}i&QxEc?ruyXxziYr2cKLdt8`dP) zcJn7#0cRMzO2Y_YWLYQO2N!TjIjl|)jBjgGTP|%Tab1-&Yc=k`bIkad{Tw~#UqO>9 zq-Oiwb9b!KRtyJD;~jVm6IzU?lJ2nfHJeW@Egbn&oT{AdGb`}83AQ9b`1#auKsmdX zfMgrBL`)o9;twl|z3&UJ+yh%m)zz0t-4`*8450HMeWcO_+cem5VLo#|4r9tuS`uAteJH`AT2tg_`%*(ZfhJEOG+cDbLTm%-7S-RNQ%7vdHO`H3l~I zfQ@Z+q4tk{9_@(DpgvW!P?srEG_m}8ha>JpH??~25Ab0X7OS1+jT9hu9svHAh{b?U=JblwtPvU^pu0lV3X1pY|dc) zOpT?`sbj`!3fhd358Xxe_?&73$dr=sRU{RIx1Bkrw$~qegShzX15_JrkZtarlrFGnYi zB&XA+S|h+F#N}|O_PjN$weUUzbC~XP=X{qsbzYUghk5C^p6F6{=~Vg+X%HjyT5&#X z4X^wt=l37_*oW-F9$nL~sIp|WGK3Q6Nz!LYyWLwHx&Mnay8SdH%vU~YW7?j z*pa-`9?_0b?mkn)Ya3Gla9h{r6yWNp{}v8hlEp{>uMTW0cA literal 0 HcmV?d00001 diff --git a/docs/COMMUNITY_MEMOS_SCHEMA.md b/docs/COMMUNITY_MEMOS_SCHEMA.md new file mode 100644 index 0000000..91e81ae --- /dev/null +++ b/docs/COMMUNITY_MEMOS_SCHEMA.md @@ -0,0 +1,21 @@ +# 私密社群 PocketBase 集合 schema + +在 PocketBase 管理后台创建集合 `community_memos`,字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | Text | ✓ | 发布者 user_id(PocketBase 用户 ID 或匿名 user123456) | +| content | Text | ✓ | 动态内容,支持 Markdown、#标签、图片 `![](url)` | +| pinned | Bool | | 是否置顶,默认 false | +| visibility | Text | | 可见性:PRIVATE/PROTECTED/PUBLIC,默认 PRIVATE | +| state | Text | | 状态:NORMAL/ARCHIVED,默认 NORMAL | + +- 集合名:`community_memos` +- 列表/查看权限:仅服务端 admin 操作,前端通过 payjsapi `/community/memos` 接口访问 +- 创建/更新/删除:仅 VIP 用户,由 payjsapi 校验 + +创建步骤: +1. 登录 PocketBase 管理后台 +2. Collections → New collection → 名称 `community_memos` +3. 添加字段:user_id (Text), content (Editor 或 Text), pinned (Bool) +4. 保存 diff --git a/docs/COMMUNITY_SQLITE.md b/docs/COMMUNITY_SQLITE.md new file mode 100644 index 0000000..7e0dbb0 --- /dev/null +++ b/docs/COMMUNITY_SQLITE.md @@ -0,0 +1,45 @@ +# 社群模块 SQLite 存储 + +默认使用 SQLite,无需 PocketBase、MinIO 等外部依赖。图片上传保存到本地目录。 + +## 配置 + +| 环境变量 | 说明 | 默认 | +|----------|------|------| +| COMMUNITY_STORAGE | `sqlite` \| `pocketbase` | `sqlite` | +| COMMUNITY_DATA_DIR | 数据目录(含 db、uploads) | `./data` | +| BASE_URL | 用于拼接图片 URL | 见主配置 | + +## 文件结构 + +``` +data/ +├── community.db # SQLite 数据库 +└── community_uploads/ # 上传图片 + └── 1731234567_abc123.jpg +``` + +## 表结构(community_memos) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | TEXT | 主键 | +| user_id | TEXT | 发布者 | +| content | TEXT | 内容 | +| pinned | INTEGER | 置顶 0/1 | +| visibility | TEXT | PRIVATE/PROTECTED/PUBLIC | +| state | TEXT | NORMAL/ARCHIVED | +| created | TEXT | ISO 时间 | +| updated | TEXT | ISO 时间 | + +## 启动时自动建表 + +`app/services/community.py` 加载时自动执行 `init_schema()`,无需手动迁移。 + +## 切换 PocketBase + +```bash +COMMUNITY_STORAGE=pocketbase +``` + +需配置 PocketBase 和 MinIO(图片上传)。