feat(api): 新增开放 API 与 Token 管理
- 新增 API Token 的创建、查询、更新和撤销接口,并支持 Bearer 认证 - 新增 `/api/open` 域名、邮箱、发信与消息查询接口,补充权限校验 - 更新数据库迁移、路由与测试,并新增开放 API 文档
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const defaultAPITokenTTL = 90 * 24 * time.Hour
|
||||
|
||||
func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,created_at,updated_at
|
||||
FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []APIToken{}
|
||||
for rows.Next() {
|
||||
item, err := scanAPIToken(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan api tokens")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseOptionalFutureTime(req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if expiresAt == nil {
|
||||
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
|
||||
expiresAt = &defaultExpiry
|
||||
}
|
||||
id := newID("apt")
|
||||
token := "lq_" + randomToken()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var expiresValue any
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, now, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create api token")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"token": token, "item": item})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
current, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
name := current.Name
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
var expiresValue any
|
||||
if current.ExpiresAt != nil {
|
||||
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
expiresValue = nil
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
disabled := current.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,updated_at=? WHERE id=? AND user_id=?`,
|
||||
name, expiresValue, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM api_tokens WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,created_at,updated_at
|
||||
FROM api_tokens WHERE id=? AND user_id=?`, id, userID)
|
||||
return scanAPIToken(row)
|
||||
}
|
||||
|
||||
type apiTokenScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAPIToken(row apiTokenScanner) (APIToken, error) {
|
||||
var item APIToken
|
||||
var lastUsed, expires sql.NullString
|
||||
var disabled int
|
||||
var created, updated string
|
||||
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &created, &updated); err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.LastUsedAt = nullableTime(lastUsed)
|
||||
item.ExpiresAt = nullableTime(expires)
|
||||
item.Disabled = intBool(disabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return nil, errors.New("expiresAt must be an RFC3339 timestamp")
|
||||
}
|
||||
t = t.UTC()
|
||||
if !t.After(now) {
|
||||
return nil, errors.New("expiresAt must be in the future")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
Reference in New Issue
Block a user