"""Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions).""" from __future__ import annotations import hashlib import json import secrets import time from pathlib import Path from typing import Any from app.services.pb_client import pb, pb_quote from app.services.pb_repo import safe_create, safe_first, safe_update, use_pb STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json" # In-memory fallback when PocketBase unavailable (dev only) _users: dict[str, dict] = {} _sessions: dict[str, str] = {} _favorites: dict[str, list[str]] = {} _plans: dict[str, dict] = {} def _hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() def _empty_plan() -> dict[str, Any]: return { "items": [], "meta": { "title": "我的旅居计划", "startMonth": "", "monthlyBudget": 0, "checklist": {}, }, "updated_at": 0, } def _user_profile(user: dict) -> dict: return { "id": user["id"], "email": user["email"], "name": user["name"], "avatar": user.get("avatar", "🧑‍💻"), } def _account_from_pb(row: dict) -> dict: return { "pb_id": row["id"], "id": row.get("legacyUserId") or row["id"], "email": row.get("email", ""), "password_hash": row.get("passwordHash", ""), "name": row.get("name", ""), "avatar": row.get("avatar", "🧑‍💻"), "favorites": row.get("favorites") or [], "plan": row.get("plan") or _empty_plan(), } def _migrate_json_to_pb() -> None: if not STORE_PATH.exists() or not use_pb(): return try: data = json.loads(STORE_PATH.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return if pb.list_records("nomad_accounts", per_page=1).get("totalItems", 0) > 0: return for email, user in (data.get("users") or {}).items(): uid = user.get("id", secrets.token_hex(8)) pb.create_record( "nomad_accounts", { "email": email, "passwordHash": user.get("password_hash", ""), "name": user.get("name", ""), "avatar": user.get("avatar", "🧑‍💻"), "legacyUserId": uid, "favorites": data.get("favorites", {}).get(uid, []), "plan": data.get("plans", {}).get(uid, _empty_plan()), }, ) for token, uid in (data.get("sessions") or {}).items(): pb.create_record( "nomad_sessions", {"token": token, "userId": uid, "expiresAt": int(time.time()) + 86400 * 30}, ) def _restore_json() -> bool: if not STORE_PATH.exists(): return False try: data = json.loads(STORE_PATH.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return False global _users, _sessions, _favorites, _plans _users = data.get("users") or {} _sessions = data.get("sessions") or {} _favorites = data.get("favorites") or {} _plans = data.get("plans") or {} return True def _persist_json() -> None: STORE_PATH.parent.mkdir(parents=True, exist_ok=True) STORE_PATH.write_text( json.dumps({ "users": _users, "sessions": _sessions, "favorites": _favorites, "plans": _plans, }, ensure_ascii=False), encoding="utf-8", ) def _get_account_by_email(email: str) -> dict | None: row = safe_first("nomad_accounts", filter=f"email={pb_quote(email)}") return _account_from_pb(row) if row else None def _get_account_by_id(user_id: str) -> dict | None: row = safe_first("nomad_accounts", filter=f"legacyUserId={pb_quote(user_id)}") if not row: row = safe_first("nomad_accounts", filter=f"id={pb_quote(user_id)}") return _account_from_pb(row) if row else None def _create_session(user_id: str) -> str: token = secrets.token_urlsafe(32) if use_pb(): safe_create( "nomad_sessions", {"token": token, "userId": user_id, "expiresAt": int(time.time()) + 86400 * 30}, ) else: _sessions[token] = user_id _persist_json() return token def _issue_login(user: dict) -> dict[str, Any]: token = _create_session(user["id"]) return {"token": token, "user": _user_profile(user)} def register_user(email: str, password: str, name: str) -> dict[str, Any] | None: if use_pb(): _migrate_json_to_pb() if _get_account_by_email(email): return None uid = secrets.token_hex(8) row = safe_create( "nomad_accounts", { "email": email, "passwordHash": _hash_password(password), "name": name, "avatar": "🧑‍💻", "legacyUserId": uid, "favorites": [], "plan": _empty_plan(), }, ) if not row: return None return _issue_login(_account_from_pb(row)) if email in _users: return None uid = secrets.token_hex(8) _users[email] = { "id": uid, "email": email, "password_hash": _hash_password(password), "name": name, "avatar": "🧑‍💻", } _favorites[uid] = [] _plans[uid] = _empty_plan() _persist_json() return _issue_login(_users[email]) def login_user(email: str, password: str) -> dict[str, Any] | None: if use_pb(): user = _get_account_by_email(email) if not user or user["password_hash"] != _hash_password(password): return None return _issue_login(user) user = _users.get(email) if not user or user["password_hash"] != _hash_password(password): return None return _issue_login(user) def login_google(email: str, name: str) -> dict[str, Any] | None: if use_pb(): user = _get_account_by_email(email) if not user: uid = secrets.token_hex(8) row = safe_create( "nomad_accounts", { "email": email, "passwordHash": _hash_password(secrets.token_hex(16)), "name": name or email.split("@")[0], "avatar": "🌐", "legacyUserId": uid, "favorites": [], "plan": _empty_plan(), }, ) if not row: return None user = _account_from_pb(row) return _issue_login(user) if email not in _users: register_user(email, secrets.token_hex(16), name or email.split("@")[0]) return _issue_login(_users[email]) def login_demo() -> dict[str, Any] | None: email = "demo@nomadro.com" if use_pb(): user = _get_account_by_email(email) if not user: return register_user(email, "demo123", "演示用户") return _issue_login(user) if email not in _users: return register_user(email, "demo123", "演示用户") return _issue_login(_users[email]) def get_user_by_token(token: str) -> dict | None: if use_pb(): row = safe_first("nomad_sessions", filter=f"token={pb_quote(token)}") if not row: return None exp = int(row.get("expiresAt") or 0) if exp and exp < int(time.time()): return None user = _get_account_by_id(row.get("userId", "")) return _user_profile(user) if user else None uid = _sessions.get(token) if not uid and _restore_json(): uid = _sessions.get(token) if not uid: return None for user in _users.values(): if user["id"] == uid: return _user_profile(user) return None def _token_user_id(token: str) -> str | None: user = get_user_by_token(token) return user["id"] if user else None def get_favorites(token: str) -> list[str]: uid = _token_user_id(token) if not uid: return [] if use_pb(): user = _get_account_by_id(uid) return list(user.get("favorites") or []) if user else [] return list(_favorites.get(uid, [])) def toggle_favorite(token: str, slug: str) -> list[str]: uid = _token_user_id(token) if not uid: return [] if use_pb(): user = _get_account_by_id(uid) if not user: return [] favs = list(user.get("favorites") or []) if slug in favs: favs.remove(slug) else: favs.append(slug) safe_update("nomad_accounts", user["pb_id"], {"favorites": favs}) return favs favs = _favorites.setdefault(uid, []) if slug in favs: favs.remove(slug) else: favs.append(slug) _persist_json() return list(favs) def get_plan(token: str) -> dict[str, Any] | None: uid = _token_user_id(token) if not uid: return None if use_pb(): user = _get_account_by_id(uid) if not user: return None plan = user.get("plan") or _empty_plan() return { "items": plan.get("items") or [], "meta": plan.get("meta") or _empty_plan()["meta"], "updated_at": int(plan.get("updated_at") or 0), } plan = _plans.get(uid) or _empty_plan() return { "items": plan.get("items") or [], "meta": plan.get("meta") or _empty_plan()["meta"], "updated_at": int(plan.get("updated_at") or 0), } def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None: uid = _token_user_id(token) if not uid: return None ts = int(updated_at or time.time() * 1000) payload = {"items": items, "meta": meta, "updated_at": ts} if use_pb(): user = _get_account_by_id(uid) if not user: return None safe_update("nomad_accounts", user["pb_id"], {"plan": payload}) return get_plan(token) _plans[uid] = payload _persist_json() return get_plan(token) def update_profile(token: str, *, name: str | None = None, avatar: str | None = None) -> dict | None: uid = _token_user_id(token) if not uid: return None if use_pb(): user = _get_account_by_id(uid) if not user: return None patch: dict[str, Any] = {} if name is not None and name.strip(): patch["name"] = name.strip()[:80] if avatar is not None and avatar.strip(): patch["avatar"] = avatar.strip()[:500] if patch: safe_update("nomad_accounts", user["pb_id"], patch) user = {**user, **patch} return _user_profile(user) for _email, user in list(_users.items()): if user.get("id") == uid: if name is not None and name.strip(): user["name"] = name.strip()[:80] if avatar is not None and avatar.strip(): user["avatar"] = avatar.strip()[:500] _persist_json() return _user_profile(user) return None def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]: """Create or resume a session for meetup checkout flows (NomadCNA ensure-user).""" display = (name or email.split("@")[0]).strip() or "游民" if use_pb(): _migrate_json_to_pb() user = _get_account_by_email(email) is_new = False if not user: created = register_user(email, secrets.token_urlsafe(12), display) if not created: raise RuntimeError("unable to create user") return {**created, "is_new": True} return {**_issue_login(user), "is_new": is_new} if email not in _users: created = register_user(email, secrets.token_urlsafe(12), display) if not created: raise RuntimeError("unable to create user") return {**created, "is_new": True} return {**_issue_login(_users[email]), "is_new": False} def find_user_public_by_email(email: str) -> dict | None: if use_pb(): user = _get_account_by_email(email) return _user_profile(user) if user else None user = _users.get(email) return _user_profile(user) if user else None # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase if not use_pb(): if not _restore_json(): login_demo() elif "demo@nomadro.com" not in _users: login_demo() else: _migrate_json_to_pb() if not _get_account_by_email("demo@nomadro.com"): register_user("demo@nomadro.com", "demo123", "演示用户")