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.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) }}
+
+
+
+
获取当前用户的所有 Profile 列表。
+
[
+ {"id": 1, "nickname": "Japan", "showcase_enabled": false, "showcase_visible": "both", "notes": "", "created_at": "2025-01-01T00:00:00"},
+ ...
+]
+
+
+
+
+
创建新 Profile。
+
+ | {{ 'api_docs.body'|t(user.language) }} | |
+ | nickname | string {{ 'misc.required'|t(user.language) }} |
+
+
{"id": 2, "nickname": "Europe", ...}
+
+
+
+
+
+
+
更新 Profile。
+
+ | {{ 'api_docs.body'|t(user.language) }} | |
+ | nickname | string(可选) |
+
+
+
+
+
+
删除 Profile 及其下所有明信片。
+
{"ok": true}
+
+
+
+
+
+
{{ 'api_docs.postcard_ops'|t(user.language) }}
+
+
+
+
获取指定 Profile 下的明信片列表。
+
+ | {{ 'api_docs.params'|t(user.language) }} | |
+ | status | string(可选)— 按状态筛选:sent / delivered / received |
+
+
+
+
+
+
创建新明信片。
+
+ | {{ 'api_docs.body'|t(user.language) }} | |
+ | card_number | string * {{ 'misc.required'|t(user.language) }} |
+ | status | string * {{ 'misc.required'|t(user.language) }} — sent / delivered / received |
+ | recipient_name | string(可选) |
+ | country | string(可选)— 两个大写字母,如 JP |
+ | send_time | string(可选)— 格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM |
+ | arrival_time | string(可选) |
+ | sender_name | string(可选) |
+ | receive_time | string(可选) |
+ | notes | string(可选) |
+
+
+
+
+
+
+
+
更新明信片。仅传入需要修改的字段。
+
+ | {{ 'api_docs.body'|t(user.language) }} | |
+ | card_number | string(可选) |
+ | status | string(可选) |
+ | recipient_name | string(可选) |
+ | country | string(可选) |
+ | send_time | string(可选) |
+ | arrival_time | string(可选) |
+ | sender_name | string(可选) |
+ | receive_time | string(可选) |
+ | notes | string(可选) |
+
+
+
+
+
+
删除单张明信片。
+
{"ok": true}
+
+
+
+
+
+
{{ 'api_docs.upload_op'|t(user.language) }}
+
+
+
+
上传明信片图片(正面或背面),使用 multipart/form-data。
+
+ | Form Field | |
+ | file | File * {{ 'misc.required'|t(user.language) }} — 图片文件 |
+ | side | string(可选)— front 或 back,默认 front |
+
+
+
+
+
+
+
{{ 'api_docs.key_ops'|t(user.language) }}
+
+
+
+
+
+
创建新的 API Key。创建后完整 Key 仅在响应中显示一次。
+
+ | {{ 'api_docs.body'|t(user.language) }} | |
+ | name | string(可选)— Key 的备注名称 |
+
+
+
+
+
+
删除指定 API Key。
+
{"ok": true}
+
+
+
+
+
+
{{ 'api_docs.showcase_ops'|t(user.language) }}
+
+
+
+
{{ 'api_docs.no_auth'|t(user.language) }} — 获取用户的公开展示数据。
+
+
+
+
+
{{ '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) }}