feat: 新增 API Token 和离线 API 文档
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth import resolve_login_context
|
||||
from app.services.api_tokens import create_token, delete_token, list_tokens, regenerate_token, update_token
|
||||
|
||||
|
||||
router = APIRouter(tags=["api-tokens"])
|
||||
|
||||
|
||||
def _require_login(request: Request) -> None:
|
||||
if resolve_login_context(request) is None:
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
|
||||
|
||||
def _bad_expiration_error(exc: ValueError) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
|
||||
|
||||
|
||||
@router.get("/api/tokens")
|
||||
async def get_tokens(request: Request):
|
||||
_require_login(request)
|
||||
return {"success": True, "tokens": list_tokens()}
|
||||
|
||||
|
||||
@router.post("/api/tokens/create")
|
||||
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
||||
_require_login(request)
|
||||
name = str(payload.get("name", "")).strip()
|
||||
expires_at = payload.get("expires_at")
|
||||
enabled = bool(payload.get("enabled", True))
|
||||
permanent = bool(payload.get("permanent", False))
|
||||
expires_in_days = payload.get("expires_in_days")
|
||||
raw_expires_at = None if permanent else (str(expires_at).strip() if expires_at else None)
|
||||
|
||||
try:
|
||||
raw_token, record = create_token(
|
||||
name=name,
|
||||
expires_at=raw_expires_at,
|
||||
enabled=enabled,
|
||||
expires_in_days=int(expires_in_days) if expires_in_days is not None else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise _bad_expiration_error(exc) from exc
|
||||
return {
|
||||
"success": True,
|
||||
"message": "API Token 创建成功",
|
||||
"token": raw_token,
|
||||
"record": record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/tokens/update")
|
||||
async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
||||
_require_login(request)
|
||||
token_id = str(payload.get("id", "")).strip()
|
||||
if not token_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 Token ID")
|
||||
|
||||
try:
|
||||
record = update_token(
|
||||
token_id,
|
||||
name=payload.get("name"),
|
||||
enabled=payload.get("enabled"),
|
||||
expires_at=None if payload.get("permanent") else payload.get("expires_at"),
|
||||
)
|
||||
return {"success": True, "message": "API Token 已更新", "record": record}
|
||||
except ValueError as exc:
|
||||
raise _bad_expiration_error(exc) from exc
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/tokens/regenerate")
|
||||
async def regenerate_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
||||
_require_login(request)
|
||||
token_id = str(payload.get("id", "")).strip()
|
||||
if not token_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 Token ID")
|
||||
|
||||
try:
|
||||
raw_token, record = regenerate_token(token_id)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "API Token 已重新生成",
|
||||
"token": raw_token,
|
||||
"record": record,
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/tokens/delete")
|
||||
async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
||||
_require_login(request)
|
||||
token_id = str(payload.get("id", "")).strip()
|
||||
if not token_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 Token ID")
|
||||
|
||||
delete_token(token_id)
|
||||
return {"success": True, "message": "API Token 已删除"}
|
||||
|
||||
|
||||
@router.get("/api/docs-info")
|
||||
async def docs_info(request: Request):
|
||||
_require_login(request)
|
||||
return {
|
||||
"success": True,
|
||||
"docs_url": "/api/docs-ui",
|
||||
"openapi_url": "/api/openapi.json",
|
||||
"token_header": "Authorization: Bearer <token>",
|
||||
"alt_header": "X-API-Token: <token>",
|
||||
"note": "API 文档仅登录后可访问。",
|
||||
}
|
||||
+64
@@ -4,8 +4,11 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.config import BASE_DIR
|
||||
|
||||
|
||||
@@ -15,6 +18,12 @@ DEFAULT_USERNAME = "root"
|
||||
DEFAULT_PASSWORD = "Capacity"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
kind: str
|
||||
payload: dict
|
||||
|
||||
|
||||
def _ensure_auth_ini() -> None:
|
||||
if AUTH_INI_PATH.exists():
|
||||
return
|
||||
@@ -68,6 +77,61 @@ def verify_jwt_token(token: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def extract_login_token(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
cookie_token = request.cookies.get("token", "").strip()
|
||||
return cookie_token or None
|
||||
|
||||
|
||||
def extract_access_token(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
api_token = request.headers.get("X-API-Token", "").strip()
|
||||
if api_token:
|
||||
return api_token
|
||||
|
||||
cookie_token = request.cookies.get("token", "").strip()
|
||||
return cookie_token or None
|
||||
|
||||
|
||||
def resolve_login_context(request: Request) -> AuthContext | None:
|
||||
token = extract_login_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = verify_jwt_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
return AuthContext(kind="jwt", payload=payload)
|
||||
|
||||
|
||||
def resolve_access_context(request: Request) -> AuthContext | None:
|
||||
token = extract_access_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = verify_jwt_token(token)
|
||||
if payload:
|
||||
return AuthContext(kind="jwt", payload=payload)
|
||||
|
||||
from app.services.api_tokens import verify_api_token
|
||||
|
||||
api_payload = verify_api_token(token)
|
||||
if api_payload:
|
||||
return AuthContext(kind="api_token", payload=api_payload)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _encode_json(data: dict) -> str:
|
||||
raw = json.dumps(data, separators=(",", ":")).encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
+95
-13
@@ -1,21 +1,43 @@
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app import state
|
||||
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, tasks, upload
|
||||
from app.auth import verify_jwt_token
|
||||
from app.api.routers import (
|
||||
api_tokens,
|
||||
auth,
|
||||
cache,
|
||||
config,
|
||||
database,
|
||||
health,
|
||||
history,
|
||||
license,
|
||||
remote,
|
||||
script,
|
||||
tasks,
|
||||
upload,
|
||||
)
|
||||
from app.auth import extract_access_token, resolve_access_context, resolve_login_context
|
||||
from app.config import BASE_DIR
|
||||
from app.services.api_tokens import touch_token_usage
|
||||
|
||||
|
||||
APP_VERSION = "3.0.0"
|
||||
APP_HOST = "0.0.0.0"
|
||||
APP_PORT = 9081
|
||||
FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist"
|
||||
LOGIN_ONLY_API_PREFIXES = (
|
||||
"/api/config",
|
||||
"/api/change-password",
|
||||
"/api/license",
|
||||
"/api/tokens",
|
||||
)
|
||||
LOGIN_ONLY_API_PATHS = {"/api/openapi.json", "/api/docs-ui", "/api/docs-info"}
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -27,6 +49,7 @@ def create_app() -> FastAPI:
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -36,31 +59,43 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.middleware("http")(jwt_middleware)
|
||||
app.middleware("http")(auth_middleware)
|
||||
register_routes(app)
|
||||
register_frontend(app)
|
||||
return app
|
||||
|
||||
|
||||
async def jwt_middleware(request: Request, call_next):
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path != "/api/login":
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
if path == "/api/login":
|
||||
return await call_next(request)
|
||||
|
||||
if _is_login_only_api(path):
|
||||
if resolve_login_context(request) is None:
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
|
||||
return await call_next(request)
|
||||
|
||||
if path.startswith("/api/"):
|
||||
access_context = resolve_access_context(request)
|
||||
if access_context is None:
|
||||
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 Token"})
|
||||
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not verify_jwt_token(token):
|
||||
return JSONResponse(status_code=401, content={"detail": "Token 无效或已过期"})
|
||||
request.state.auth_context = access_context
|
||||
if access_context.kind == "api_token":
|
||||
access_token = extract_access_token(request)
|
||||
if access_token:
|
||||
client_host = request.client.host if request.client else None
|
||||
touch_token_usage(access_token, client_host)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
routers = [
|
||||
api_tokens.router,
|
||||
auth.router,
|
||||
health.router,
|
||||
upload.router,
|
||||
@@ -78,12 +113,23 @@ def register_routes(app: FastAPI) -> None:
|
||||
|
||||
|
||||
def register_frontend(app: FastAPI) -> None:
|
||||
@app.get("/api/openapi.json", include_in_schema=False)
|
||||
async def serve_openapi(request: Request):
|
||||
if resolve_login_context(request) is None:
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
|
||||
return JSONResponse(app.openapi())
|
||||
|
||||
@app.get("/api/docs-ui", include_in_schema=False)
|
||||
async def serve_docs_ui(request: Request):
|
||||
if resolve_login_context(request) is None:
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
|
||||
return RedirectResponse(url="/api-center", status_code=302)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
async def serve_frontend(path: str = ""):
|
||||
if path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="接口不存在")
|
||||
|
||||
return _serve_frontend_path(path)
|
||||
|
||||
|
||||
@@ -118,6 +164,42 @@ def _safe_file(root: Path, path: str) -> Path | None:
|
||||
return requested
|
||||
|
||||
|
||||
def _is_login_only_api(path: str) -> bool:
|
||||
return path in LOGIN_ONLY_API_PATHS or path.startswith(LOGIN_ONLY_API_PREFIXES)
|
||||
|
||||
|
||||
def custom_openapi(app: FastAPI) -> dict:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
|
||||
components = schema.setdefault("components", {})
|
||||
security_schemes = components.setdefault("securitySchemes", {})
|
||||
security_schemes["BearerAuth"] = {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "登录 JWT 或 API Token,均可通过 Authorization: Bearer <token> 传递;API Token 也支持 X-API-Token: <token>。",
|
||||
}
|
||||
security_schemes["ApiTokenHeader"] = {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Token",
|
||||
"description": "API Token 也可以通过 X-API-Token 请求头传递。",
|
||||
}
|
||||
|
||||
for path, methods in schema.get("paths", {}).items():
|
||||
if not path.startswith("/api/") or path in {"/api/login"}:
|
||||
continue
|
||||
security = [{"BearerAuth": []}] if _is_login_only_api(path) else [{"BearerAuth": []}, {"ApiTokenHeader": []}]
|
||||
for operation in methods.values():
|
||||
if isinstance(operation, dict):
|
||||
operation["security"] = security
|
||||
|
||||
app.openapi_schema = schema
|
||||
return schema
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
from app.config import BASE_DIR
|
||||
|
||||
|
||||
API_TOKENS_PATH = BASE_DIR / "api_tokens.json"
|
||||
API_TOKEN_PREFIX = "cap_"
|
||||
API_TOKEN_SECRET = "CapaReportApiTokenSecret2026"
|
||||
_STORE_LOCK = RLock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiTokenRecord:
|
||||
id: str
|
||||
name: str
|
||||
token_hash: str
|
||||
prefix: str
|
||||
suffix: str
|
||||
created_at: str
|
||||
expires_at: str | None
|
||||
enabled: bool
|
||||
last_used_at: str | None = None
|
||||
last_used_from: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def ensure_store() -> None:
|
||||
if API_TOKENS_PATH.exists():
|
||||
return
|
||||
with _STORE_LOCK:
|
||||
if API_TOKENS_PATH.exists():
|
||||
return
|
||||
API_TOKENS_PATH.write_text(json.dumps({"tokens": []}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def list_tokens() -> list[dict[str, Any]]:
|
||||
with _STORE_LOCK:
|
||||
return [record_to_public_dict(record) for record in _load_records()]
|
||||
|
||||
|
||||
def create_token(
|
||||
name: str,
|
||||
expires_in_days: int | None = None,
|
||||
enabled: bool = True,
|
||||
expires_at: str | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
raw_token = generate_raw_token()
|
||||
resolved_expires_at = normalize_expiration(expires_at)
|
||||
if resolved_expires_at is None and expires_in_days is not None:
|
||||
resolved_expires_at = expires_at_from_days(expires_in_days)
|
||||
|
||||
record = ApiTokenRecord(
|
||||
id=secrets.token_hex(8),
|
||||
name=name.strip() or "未命名 Token",
|
||||
token_hash=hash_token(raw_token),
|
||||
prefix=raw_token[:12],
|
||||
suffix=raw_token[-12:],
|
||||
created_at=utc_now(),
|
||||
expires_at=resolved_expires_at,
|
||||
enabled=bool(enabled),
|
||||
)
|
||||
|
||||
with _STORE_LOCK:
|
||||
records = _load_records()
|
||||
records.append(record)
|
||||
_save_records(records)
|
||||
|
||||
return raw_token, record_to_public_dict(record)
|
||||
|
||||
|
||||
def update_token(token_id: str, **changes: Any) -> dict[str, Any]:
|
||||
with _STORE_LOCK:
|
||||
records = _load_records()
|
||||
for index, record in enumerate(records):
|
||||
if record.id != token_id:
|
||||
continue
|
||||
|
||||
if "name" in changes and isinstance(changes["name"], str):
|
||||
record.name = changes["name"].strip() or record.name
|
||||
if "enabled" in changes:
|
||||
record.enabled = bool(changes["enabled"])
|
||||
if "expires_at" in changes:
|
||||
record.expires_at = normalize_expiration(changes["expires_at"])
|
||||
|
||||
records[index] = record
|
||||
_save_records(records)
|
||||
return record_to_public_dict(record)
|
||||
|
||||
raise KeyError(f"Token not found: {token_id}")
|
||||
|
||||
|
||||
def delete_token(token_id: str) -> None:
|
||||
with _STORE_LOCK:
|
||||
records = [record for record in _load_records() if record.id != token_id]
|
||||
_save_records(records)
|
||||
|
||||
|
||||
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
|
||||
with _STORE_LOCK:
|
||||
records = _load_records()
|
||||
for index, record in enumerate(records):
|
||||
if record.id != token_id:
|
||||
continue
|
||||
|
||||
raw_token = generate_raw_token()
|
||||
record.token_hash = hash_token(raw_token)
|
||||
record.prefix = raw_token[:12]
|
||||
record.suffix = raw_token[-12:]
|
||||
record.created_at = utc_now()
|
||||
record.last_used_at = None
|
||||
record.last_used_from = None
|
||||
records[index] = record
|
||||
_save_records(records)
|
||||
return raw_token, record_to_public_dict(record)
|
||||
|
||||
raise KeyError(f"Token not found: {token_id}")
|
||||
|
||||
|
||||
def verify_api_token(raw_token: str) -> dict[str, Any] | None:
|
||||
ensure_store()
|
||||
token_hash = hash_token(raw_token)
|
||||
now = datetime.now(timezone.utc)
|
||||
with _STORE_LOCK:
|
||||
for record in _load_records():
|
||||
if not record.enabled or record.token_hash != token_hash:
|
||||
continue
|
||||
|
||||
expires_at = parse_datetime(record.expires_at)
|
||||
if expires_at and expires_at < now:
|
||||
continue
|
||||
|
||||
return record_to_context(record)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def touch_token_usage(raw_token: str, source: str | None = None) -> None:
|
||||
token_hash = hash_token(raw_token)
|
||||
now = utc_now()
|
||||
with _STORE_LOCK:
|
||||
records = _load_records()
|
||||
updated = False
|
||||
for index, record in enumerate(records):
|
||||
if record.token_hash != token_hash:
|
||||
continue
|
||||
record.last_used_at = now
|
||||
record.last_used_from = source or record.last_used_from
|
||||
records[index] = record
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
_save_records(records)
|
||||
|
||||
|
||||
def generate_raw_token() -> str:
|
||||
return API_TOKEN_PREFIX + secrets.token_urlsafe(36)
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
digest = hmac.new(API_TOKEN_SECRET.encode(), raw_token.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def normalize_expiration(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
trimmed = str(value).strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
parsed = parse_datetime(trimmed)
|
||||
if parsed is None:
|
||||
raise ValueError("Invalid expiration date")
|
||||
return parsed.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def expires_at_from_days(days: int) -> str:
|
||||
safe_days = max(int(days), 1)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=safe_days)
|
||||
return expires_at.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def parse_datetime(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
parsed = datetime.combine(parsed_date, time.max)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def record_to_public_dict(record: ApiTokenRecord, include_hash: bool = False) -> dict[str, Any]:
|
||||
expires_at = parse_datetime(record.expires_at)
|
||||
data = {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"prefix": record.prefix,
|
||||
"suffix": record.suffix,
|
||||
"created_at": record.created_at,
|
||||
"expires_at": record.expires_at,
|
||||
"enabled": record.enabled,
|
||||
"last_used_at": record.last_used_at,
|
||||
"last_used_from": record.last_used_from,
|
||||
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||
}
|
||||
if include_hash:
|
||||
data["token_hash"] = record.token_hash
|
||||
return data
|
||||
|
||||
|
||||
def record_to_context(record: ApiTokenRecord) -> dict[str, Any]:
|
||||
return {
|
||||
"token_id": record.id,
|
||||
"name": record.name,
|
||||
"created_at": record.created_at,
|
||||
"expires_at": record.expires_at,
|
||||
"token_type": "api_token",
|
||||
}
|
||||
|
||||
|
||||
def _load_records() -> list[ApiTokenRecord]:
|
||||
ensure_store()
|
||||
try:
|
||||
raw = json.loads(API_TOKENS_PATH.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raw = {"tokens": []}
|
||||
|
||||
tokens = raw.get("tokens", []) if isinstance(raw, dict) else []
|
||||
records: list[ApiTokenRecord] = []
|
||||
for item in tokens:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
records.append(
|
||||
ApiTokenRecord(
|
||||
id=str(item.get("id", "")) or secrets.token_hex(8),
|
||||
name=str(item.get("name", "未命名 Token")),
|
||||
token_hash=str(item.get("token_hash", "")),
|
||||
prefix=str(item.get("prefix", "")),
|
||||
suffix=str(item.get("suffix", "")),
|
||||
created_at=str(item.get("created_at", utc_now())),
|
||||
expires_at=item.get("expires_at"),
|
||||
enabled=bool(item.get("enabled", True)),
|
||||
last_used_at=item.get("last_used_at"),
|
||||
last_used_from=item.get("last_used_from"),
|
||||
)
|
||||
)
|
||||
|
||||
records.sort(key=lambda record: record.created_at, reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
def _save_records(records: list[ApiTokenRecord]) -> None:
|
||||
payload = {"tokens": [record.to_dict() for record in records]}
|
||||
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
Reference in New Issue
Block a user