From 6a49fedfbbec2e98f6cd9d39a484392ae8432743 Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Tue, 7 Jul 2026 09:22:11 +0800 Subject: [PATCH] feat(api): add public RESTful API with API key auth and documentation page --- app/i18n.py | 43 ++++ app/main.py | 5 +- app/models.py | 17 +- app/routers/api.py | 407 ++++++++++++++++++++++++++++++++++++ app/routers/web.py | 69 ++++++ app/templates/api_docs.html | 306 +++++++++++++++++++++++++++ app/templates/base.html | 1 + app/templates/settings.html | 60 +++++- memory/2026-07-06.md | 7 + 9 files changed, 910 insertions(+), 5 deletions(-) create mode 100644 app/routers/api.py create mode 100644 app/templates/api_docs.html diff --git a/app/i18n.py b/app/i18n.py index 478fd4d..6a4937e 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -6,6 +6,7 @@ _translations: dict[str, dict[str, str]] = { "nav.settings": {"zh": "设置", "en": "Settings"}, "nav.invites": {"zh": "邀请码管理", "en": "Invites"}, "nav.logout": {"zh": "退出", "en": "Logout"}, + "nav.api_docs": {"zh": "API 文档", "en": "API Docs"}, # ── Auth ── "login.title": {"zh": "登录", "en": "Login"}, @@ -59,6 +60,7 @@ _translations: dict[str, dict[str, str]] = { # ── Postcards list ── "pc_list.title": {"zh": " 的明信片", "en": "'s postcards"}, "pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"}, + "pc_list.duplicate": {"zh": "编号已存在", "en": "Card number already exists"}, "pc_list.all_countries": {"zh": "全部国家", "en": "All countries"}, "pc_list.filter": {"zh": "筛选", "en": "Filter"}, "pc_list.tab_sent": {"zh": "寄出", "en": "Sent"}, @@ -208,6 +210,47 @@ _translations: dict[str, dict[str, str]] = { "flash.password_short": {"zh": "新密码长度至少6位", "en": "Password must be at least 6 characters"}, "flash.password_ok": {"zh": "密码修改成功", "en": "Password changed"}, + # ── API Keys ── + "settings.tab_apikeys": {"zh": "API Keys", "en": "API Keys"}, + "apikey.title": {"zh": "API Key 管理", "en": "API Key Management"}, + "apikey.hint": {"zh": "使用 API Key 通过 HTTP 请求访问星笺的公共 API。请妥善保管你的 Key,它仅在创建时显示一次。", "en": "Use API Keys to access Mailova's public API via HTTP requests. Keep your key safe — it is only shown once upon creation."}, + "apikey.created": {"zh": "✅ 新 Key 已创建:", "en": "✅ New key created:"}, + "apikey.copy_now": {"zh": "⚠️ 请立即复制保存,关闭后将无法再次查看完整 Key。", "en": "⚠️ Copy it now. You won't be able to see the full key again after leaving this page."}, + "apikey.name_ph": {"zh": "Key 名称(可选)", "en": "Key name (optional)"}, + "apikey.create": {"zh": "创建 Key", "en": "Create Key"}, + "apikey.name": {"zh": "名称", "en": "Name"}, + "apikey.key": {"zh": "Key", "en": "Key"}, + "apikey.created_at": {"zh": "创建时间", "en": "Created"}, + "apikey.last_used": {"zh": "最后使用", "en": "Last used"}, + "apikey.delete": {"zh": "删除", "en": "Delete"}, + "apikey.confirm_delete": {"zh": "确定删除此 API Key?", "en": "Delete this API key?"}, + "apikey.empty": {"zh": "暂无 API Key", "en": "No API keys yet"}, + + # ── API Docs ── + "api_docs.title": {"zh": "API 文档", "en": "API Documentation"}, + "api_docs.intro": {"zh": "星笺提供 RESTful API,供第三方应用集成使用。所有需要鉴权的请求需在 Header 中携带 X-API-Key。", "en": "Mailova provides a RESTful API for third-party integrations. All authenticated requests must include the X-API-Key header."}, + "api_docs.base_url": {"zh": "基础 URL", "en": "Base URL"}, + "api_docs.auth": {"zh": "鉴权方式", "en": "Authentication"}, + "api_docs.auth_desc": {"zh": "在请求头中携带你的 API Key:", "en": "Include your API Key in the request header:"}, + "api_docs.profile_ops": {"zh": "Profile 操作", "en": "Profile Operations"}, + "api_docs.postcard_ops": {"zh": "明信片操作", "en": "Postcard Operations"}, + "api_docs.upload_op": {"zh": "图片上传", "en": "Image Upload"}, + "api_docs.key_ops": {"zh": "API Key 管理", "en": "API Key Management"}, + "api_docs.showcase_ops": {"zh": "公开展示(无需鉴权)", "en": "Public Showcase (no auth)"}, + "api_docs.method": {"zh": "方法", "en": "Method"}, + "api_docs.path": {"zh": "路径", "en": "Path"}, + "api_docs.desc": {"zh": "说明", "en": "Description"}, + "api_docs.params": {"zh": "参数", "en": "Parameters"}, + "api_docs.body": {"zh": "请求体 (JSON)", "en": "Request Body (JSON)"}, + "api_docs.response": {"zh": "响应", "en": "Response"}, + "api_docs.status": {"zh": "状态码", "en": "Status"}, + "api_docs.go_settings": {"zh": "去设置页创建 API Key →", "en": "Go to Settings to create an API Key →"}, + "api_docs.try_it": {"zh": "试试看", "en": "Try it"}, + "api_docs.api_key_ph": {"zh": "输入你的 API Key", "en": "Enter your API Key"}, + "api_docs.send": {"zh": "发送", "en": "Send"}, + "api_docs.response_label": {"zh": "响应结果", "en": "Response"}, + "api_docs.no_auth": {"zh": "此接口无需鉴权", "en": "No authentication required"}, + # ── Misc ── "misc.required": {"zh": " *", "en": " *"}, } diff --git a/app/main.py b/app/main.py index 55a14c8..0e7f5e0 100644 --- a/app/main.py +++ b/app/main.py @@ -5,15 +5,16 @@ from fastapi.staticfiles import StaticFiles from app.config import UPLOAD_DIR from app.database import init_db -from app.routers import web +from app.routers import web, api -app = FastAPI(title="Postcard Manager") +app = FastAPI(title="Mailova 星笺") UPLOAD_DIR.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.include_router(web.router) +app.include_router(api.router) init_db() diff --git a/app/models.py b/app/models.py index 2c4f066..92204bd 100644 --- a/app/models.py +++ b/app/models.py @@ -26,6 +26,7 @@ class User(Base): sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan") invite_codes = relationship("InviteCode", back_populates="creator", foreign_keys="InviteCode.created_by", cascade="all, delete-orphan") invited_by_code = relationship("InviteCode", foreign_keys=[invited_by_code_id]) + api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan") class InviteCode(Base): @@ -74,7 +75,7 @@ class Postcard(Base): id = Column(Integer, primary_key=True, index=True) profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) - card_number = Column(String(64), nullable=False) + card_number = Column(String(64), nullable=False, unique=True) status = Column(String(16), nullable=False) # sent & delivered recipient_name = Column(String(64)) @@ -98,3 +99,17 @@ class Postcard(Base): @validates("country") def _upper_country(self, key, value): return value.strip().upper() if value else None + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(64), unique=True, nullable=False, index=True) + name = Column(String(64), nullable=False, default="") + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, default=_utcnow) + last_used_at = Column(DateTime, nullable=True) + + user = relationship("User", back_populates="api_keys") diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..0d7a704 --- /dev/null +++ b/app/routers/api.py @@ -0,0 +1,407 @@ +from datetime import datetime, timedelta, timezone +from pathlib import Path +from secrets import token_hex + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File +from pydantic import BaseModel +from sqlalchemy.orm import Session as DbSession + +from app.database import get_db +from app.models import ApiKey, Profile, Postcard, User +from app.config import UPLOAD_DIR + +router = APIRouter(prefix="/api/v1", tags=["api"]) + + +# ─── Auth Dependency ────────────────────────────────────────────── + +def get_api_user( + x_api_key: str = Header(..., alias="X-API-Key"), + db: DbSession = Depends(get_db), +) -> User: + """Validate X-API-Key header and return the associated user.""" + api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first() + if not api_key or not api_key.is_active: + raise HTTPException(status_code=401, detail="Invalid or inactive API key") + # Update last_used_at + api_key.last_used_at = datetime.now(timezone.utc) + db.commit() + return api_key.user + + +def get_api_user_optional( + x_api_key: str | None = Header(None, alias="X-API-Key"), + db: DbSession = Depends(get_db), +) -> User | None: + """Like get_api_user but returns None if no key provided (for public endpoints).""" + if not x_api_key: + return None + return get_api_user(x_api_key, db) + + +# ─── Pydantic Schemas ──────────────────────────────────────────── + +class ProfileCreate(BaseModel): + nickname: str + +class ProfileUpdate(BaseModel): + nickname: str | None = None + +class ProfileOut(BaseModel): + id: int + nickname: str + showcase_enabled: bool + showcase_visible: str + notes: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class PostcardCreate(BaseModel): + card_number: str + status: str + recipient_name: str | None = None + country: str | None = None + send_time: str | None = None + arrival_time: str | None = None + sender_name: str | None = None + receive_time: str | None = None + notes: str | None = None + +class PostcardUpdate(BaseModel): + card_number: str | None = None + status: str | None = None + recipient_name: str | None = None + country: str | None = None + send_time: str | None = None + arrival_time: str | None = None + sender_name: str | None = None + receive_time: str | None = None + notes: str | None = None + +class PostcardOut(BaseModel): + id: int + profile_id: int + card_number: str + status: str + recipient_name: str | None + country: str | None + send_time: datetime | None + arrival_time: datetime | None + sender_name: str | None + receive_time: datetime | None + notes: str + image_front: str | None + image_back: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ApiKeyCreate(BaseModel): + name: str = "" + +class ApiKeyOut(BaseModel): + id: int + key: str + name: str + is_active: bool + created_at: datetime + last_used_at: datetime | None + + model_config = {"from_attributes": True} + + +# ─── Helpers ────────────────────────────────────────────────────── + +def _parse_date(s: str | None) -> datetime | None: + """Parse a date or datetime string to datetime.""" + if not s: + return None + s = s.strip() + for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + raise HTTPException(status_code=400, detail=f"Invalid date format: {s}") + + +def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile: + p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + + +def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard: + _profile_or_404(user, profile_id, db) + pc = db.query(Postcard).filter( + Postcard.id == postcard_id, Postcard.profile_id == profile_id + ).first() + if not pc: + raise HTTPException(status_code=404, detail="Postcard not found") + return pc + + +# ═══════════════════════════════════════════════════════════════════ +# Profiles +# ═══════════════════════════════════════════════════════════════════ + +@router.get("/profiles", response_model=list[ProfileOut]) +def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all() + + +@router.post("/profiles", response_model=ProfileOut, status_code=201) +def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + p = Profile(nickname=body.nickname.strip(), user_id=user.id) + db.add(p) + db.commit() + db.refresh(p) + return p + + +@router.get("/profiles/{profile_id}", response_model=ProfileOut) +def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + return _profile_or_404(user, profile_id, db) + + +@router.put("/profiles/{profile_id}", response_model=ProfileOut) +def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + p = _profile_or_404(user, profile_id, db) + if body.nickname is not None: + p.nickname = body.nickname.strip() + db.commit() + db.refresh(p) + return p + + +@router.delete("/profiles/{profile_id}") +def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + p = _profile_or_404(user, profile_id, db) + db.delete(p) + db.commit() + return {"ok": True} + + +# ═══════════════════════════════════════════════════════════════════ +# Postcards +# ═══════════════════════════════════════════════════════════════════ + +@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut]) +def list_postcards( + profile_id: int, + status: str | None = Query(None), + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + _profile_or_404(user, profile_id, db) + q = db.query(Postcard).filter(Postcard.profile_id == profile_id) + if status: + q = q.filter(Postcard.status == status) + return q.order_by(Postcard.id.desc()).all() + + +@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201) +def create_postcard( + profile_id: int, + body: PostcardCreate, + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + _profile_or_404(user, profile_id, db) + cn = body.card_number.strip() + if db.query(Postcard).filter(Postcard.card_number == cn).first(): + raise HTTPException(status_code=409, detail="Card number already exists") + pc = Postcard( + profile_id=profile_id, + card_number=cn, + status=body.status, + recipient_name=body.recipient_name, + country=body.country.strip().upper() if body.country else None, + send_time=_parse_date(body.send_time), + arrival_time=_parse_date(body.arrival_time), + sender_name=body.sender_name, + receive_time=_parse_date(body.receive_time), + notes=body.notes or "", + ) + db.add(pc) + db.commit() + db.refresh(pc) + return pc + + +@router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut) +def get_postcard( + profile_id: int, + postcard_id: int, + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + return _postcard_or_404(user, profile_id, postcard_id, db) + + +@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut) +def update_postcard( + profile_id: int, + postcard_id: int, + body: PostcardUpdate, + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + pc = _postcard_or_404(user, profile_id, postcard_id, db) + for field in ("status", "recipient_name", "sender_name", "notes"): + val = getattr(body, field) + if val is not None: + setattr(pc, field, val.strip() if isinstance(val, str) else val) + if body.card_number is not None: + cn = body.card_number.strip() + dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first() + if dup: + raise HTTPException(status_code=409, detail="Card number already exists") + pc.card_number = cn + if body.country is not None: + pc.country = body.country.strip().upper() if body.country else None + for field in ("send_time", "arrival_time", "receive_time"): + val = getattr(body, field) + if val is not None: + setattr(pc, field, _parse_date(val)) + db.commit() + db.refresh(pc) + return pc + + +@router.delete("/profiles/{profile_id}/postcards/{postcard_id}") +def delete_postcard( + profile_id: int, + postcard_id: int, + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + pc = _postcard_or_404(user, profile_id, postcard_id, db) + db.delete(pc) + db.commit() + return {"ok": True} + + +# ═══════════════════════════════════════════════════════════════════ +# Image Upload +# ═══════════════════════════════════════════════════════════════════ + +@router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut) +async def upload_image( + profile_id: int, + postcard_id: int, + side: str = Query("front", pattern="^(front|back)$"), + file: UploadFile = File(...), + user: User = Depends(get_api_user), + db: DbSession = Depends(get_db), +): + pc = _postcard_or_404(user, profile_id, postcard_id, db) + ext = Path(file.filename).suffix if file.filename else ".jpg" + save_name = f"pc_{pc.id}_{side}{ext}" + save_path = UPLOAD_DIR / save_name + content = await file.read() + save_path.write_bytes(content) + if side == "front": + pc.image_front = f"/uploads/{save_name}" + else: + pc.image_back = f"/uploads/{save_name}" + db.commit() + db.refresh(pc) + return pc + + +# ═══════════════════════════════════════════════════════════════════ +# API Key Management +# ═══════════════════════════════════════════════════════════════════ + +@router.get("/keys", response_model=list[ApiKeyOut]) +def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() + + +@router.post("/keys", response_model=ApiKeyOut, status_code=201) +def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + key = f"mv_{token_hex(24)}" + ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id) + db.add(ak) + db.commit() + db.refresh(ak) + return ak + + +@router.delete("/keys/{key_id}") +def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): + ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() + if not ak: + raise HTTPException(status_code=404, detail="API key not found") + db.delete(ak) + db.commit() + return {"ok": True} + + +# ═══════════════════════════════════════════════════════════════════ +# Showcase (public, no auth required) +# ═══════════════════════════════════════════════════════════════════ + +class ShowcaseProfileOut(BaseModel): + id: int + nickname: str + showcase_visible: str + sent: list[PostcardOut] + received: list[PostcardOut] + + model_config = {"from_attributes": True} + + +class ShowcaseUserOut(BaseModel): + username: str + showcase_mode: str + profiles: list[ShowcaseProfileOut] + + +@router.get("/showcase/{username}", response_model=ShowcaseUserOut) +def showcase_user(username: str, db: DbSession = Depends(get_db)): + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all() + result_profiles = [] + for p in profiles: + cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all() + if p.showcase_visible in ("sent", "both"): + sent = [c for c in cards if c.status in ("sent", "delivered")] + else: + sent = [] + if p.showcase_visible in ("received", "both"): + received = [c for c in cards if c.status == "received"] + else: + received = [] + if sent or received: + sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True) + received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) + result_profiles.append(ShowcaseProfileOut( + id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, + sent=sent, received=received, + )) + return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles) + + +@router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut) +def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)): + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first() + if not p: + raise HTTPException(status_code=404, detail="Profile not found or showcase disabled") + cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all() + sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else [] + received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else [] + sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True) + received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) + return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received) diff --git a/app/routers/web.py b/app/routers/web.py index ee2800a..ef21b2f 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -305,13 +305,16 @@ def settings_page( user: User = Depends(get_current_user_web), db: Session = Depends(get_db), ): + from app.models import ApiKey profiles = db.query(Profile).filter(Profile.user_id == user.id).all() profile_cards = {} for p in profiles: profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() + api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() return templates.TemplateResponse("settings.html", { "request": request, "user": user, "profiles": profiles, "profile_cards": profile_cards, "active_tab": active_tab, + "api_keys": api_keys, }) @@ -558,6 +561,13 @@ def postcard_create( if not profile: return _redirect("/profiles") + cn = card_number.strip() + if db.query(Postcard).filter(Postcard.card_number == cn).first(): + return templates.TemplateResponse("postcard_form.html", { + "request": request, "user": user, "profile": profile, "postcard": None, + "error": _t("pc_list.duplicate", user.language), + }) + def _parse_dt(s: str) -> datetime | None: s = s.strip() if not s: @@ -638,6 +648,14 @@ def postcard_update( if not pc: return _redirect("/profiles") + cn = card_number.strip() + dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first() + if dup: + return templates.TemplateResponse("postcard_form.html", { + "request": request, "user": user, "profile": pc.profile, "postcard": pc, + "error": _t("pc_list.duplicate", user.language), + }) + def _parse_dt(s: str) -> datetime | None: s = s.strip() if not s: @@ -821,3 +839,54 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "lang": user.language, }) + + +# ---------- API Keys ---------- + +@router.post("/settings/apikeys/create") +def apikey_create( + request: Request, + name: str = Form(""), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + from app.models import ApiKey + from secrets import token_hex + key = f"mv_{token_hex(24)}" + ak = ApiKey(key=key, name=name.strip(), user_id=user.id) + db.add(ak) + db.commit() + db.refresh(ak) + # Re-render settings with apikeys tab active and the new key shown + api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() + return templates.TemplateResponse("settings.html", { + "request": request, "user": user, "active_tab": "apikeys", + "api_keys": api_keys, "new_key": ak.key, "success": "API Key created", + }) + + +@router.post("/settings/apikeys/{key_id}/delete") +def apikey_delete( + key_id: int, + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + from app.models import ApiKey + ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() + if ak: + db.delete(ak) + db.commit() + return _redirect("/settings?tab=apikeys") + + +# ---------- API Docs ---------- + +@router.get("/api/docs", response_class=HTMLResponse) +def api_docs_page( + request: Request, + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + return templates.TemplateResponse("api_docs.html", { + "request": request, "user": user, + }) diff --git a/app/templates/api_docs.html b/app/templates/api_docs.html new file mode 100644 index 0000000..f361731 --- /dev/null +++ b/app/templates/api_docs.html @@ -0,0 +1,306 @@ +{% extends "base.html" %} +{% block title %}{{ 'api_docs.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %} +{% block content %} + + +
+

{{ 'api_docs.title'|t(user.language) }}

+
+ +
+

{{ 'api_docs.intro'|t(user.language) }}

+

{{ 'api_docs.go_settings'|t(user.language) }}

+
+ +
+

{{ 'api_docs.base_url'|t(user.language) }}

+
{{ request.base_url }}api/v1
+
+ +
+

{{ 'api_docs.auth'|t(user.language) }}

+

{{ 'api_docs.auth_desc'|t(user.language) }}

+
+ X-API-Key: mv_your_api_key_here +
+
+ + +
+

{{ 'api_docs.profile_ops'|t(user.language) }}

+ +
+
+ GET + /profiles +
+
获取当前用户的所有 Profile 列表。
+
[ + {"id": 1, "nickname": "Japan", "showcase_enabled": false, "showcase_visible": "both", "notes": "", "created_at": "2025-01-01T00:00:00"}, + ... +]
+
+ +
+
+ POST + /profiles +
+
创建新 Profile。
+ + + +
{{ 'api_docs.body'|t(user.language) }}
nicknamestring {{ 'misc.required'|t(user.language) }}
+
{"id": 2, "nickname": "Europe", ...}
+
+ +
+
+ GET + /profiles/{id} +
+
获取单个 Profile 详情。
+
+ +
+
+ PUT + /profiles/{id} +
+
更新 Profile。
+ + + +
{{ 'api_docs.body'|t(user.language) }}
nicknamestring(可选)
+
+ +
+
+ DELETE + /profiles/{id} +
+
删除 Profile 及其下所有明信片。
+
{"ok": true}
+
+
+ + +
+

{{ 'api_docs.postcard_ops'|t(user.language) }}

+ +
+
+ GET + /profiles/{profile_id}/postcards +
+
获取指定 Profile 下的明信片列表。
+ + + +
{{ 'api_docs.params'|t(user.language) }}
statusstring(可选)— 按状态筛选:sent / delivered / received
+
+ +
+
+ POST + /profiles/{profile_id}/postcards +
+
创建新明信片。
+ + + + + + + + + + + +
{{ 'api_docs.body'|t(user.language) }}
card_numberstring * {{ 'misc.required'|t(user.language) }}
statusstring * {{ 'misc.required'|t(user.language) }} — sent / delivered / received
recipient_namestring(可选)
countrystring(可选)— 两个大写字母,如 JP
send_timestring(可选)— 格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM
arrival_timestring(可选)
sender_namestring(可选)
receive_timestring(可选)
notesstring(可选)
+
+ +
+
+ GET + /profiles/{profile_id}/postcards/{id} +
+
获取单张明信片详情。
+
+ +
+
+ PUT + /profiles/{profile_id}/postcards/{id} +
+
更新明信片。仅传入需要修改的字段。
+ + + + + + + + + + + +
{{ 'api_docs.body'|t(user.language) }}
card_numberstring(可选)
statusstring(可选)
recipient_namestring(可选)
countrystring(可选)
send_timestring(可选)
arrival_timestring(可选)
sender_namestring(可选)
receive_timestring(可选)
notesstring(可选)
+
+ +
+
+ DELETE + /profiles/{profile_id}/postcards/{id} +
+
删除单张明信片。
+
{"ok": true}
+
+
+ + +
+

{{ 'api_docs.upload_op'|t(user.language) }}

+ +
+
+ POST + /profiles/{profile_id}/postcards/{id}/upload +
+
上传明信片图片(正面或背面),使用 multipart/form-data
+ + + + +
Form Field
fileFile * {{ 'misc.required'|t(user.language) }} — 图片文件
sidestring(可选)— front 或 back,默认 front
+
+
+ + +
+

{{ 'api_docs.key_ops'|t(user.language) }}

+ +
+
+ GET + /keys +
+
获取当前用户的所有 API Key。
+
+ +
+
+ POST + /keys +
+
创建新的 API Key。创建后完整 Key 仅在响应中显示一次。
+ + + +
{{ 'api_docs.body'|t(user.language) }}
namestring(可选)— Key 的备注名称
+
+ +
+
+ DELETE + /keys/{id} +
+
删除指定 API Key。
+
{"ok": true}
+
+
+ + +
+

{{ 'api_docs.showcase_ops'|t(user.language) }}

+ +
+
+ GET + /showcase/{username} +
+
{{ 'api_docs.no_auth'|t(user.language) }} — 获取用户的公开展示数据。
+
+ +
+
+ GET + /showcase/{username}/profile/{id} +
+
{{ 'api_docs.no_auth'|t(user.language) }} — 获取单个 Profile 的展示数据。
+
+
+ + +
+

{{ 'api_docs.try_it'|t(user.language) }}

+
+ + + + +
+ +
+
+ + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 1f76fa8..9fe6f48 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -17,6 +17,7 @@ {% if user.is_admin %} {{ 'nav.invites'|t(user.language) }} {% endif %} + {{ 'nav.api_docs'|t(user.language) }}
diff --git a/app/templates/settings.html b/app/templates/settings.html index ee07747..956a298 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -6,8 +6,9 @@
- + +
{% if success %} @@ -17,7 +18,7 @@
{{ error }}
{% endif %} -
+

{{ 'settings.change_username'|t(user.language) }}

@@ -155,6 +156,61 @@
+
+
+

{{ 'apikey.title'|t(user.language) }}

+

{{ 'apikey.hint'|t(user.language) }}

+ + {% if new_key %} +
+ {{ 'apikey.created'|t(user.language) }}
+ {{ new_key }}
+ {{ 'apikey.copy_now'|t(user.language) }} +
+ {% endif %} + + + + + + + {% if api_keys %} +
+ + + + + + + + + + + + {% for ak in api_keys %} + + + + + + + + {% endfor %} + +
{{ 'apikey.name'|t(user.language) }}{{ 'apikey.key'|t(user.language) }}{{ 'apikey.created_at'|t(user.language) }}{{ 'apikey.last_used'|t(user.language) }}
{{ ak.name or '—' }}{{ ak.key[:8] }}****{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %} +
+ +
+
+
+ {% else %} +
+

{{ 'apikey.empty'|t(user.language) }}

+
+ {% endif %} +
+
+