257 lines
7.4 KiB
Go
257 lines
7.4 KiB
Go
|
|
package main
|
|||
|
|
|
|||
|
|
// apiclient.go 是桌面端访问 nl-pms-api 的 HTTP+JWT 客户端:
|
|||
|
|
// 基址来自打包/本地 sync_base_url;带鉴权请求在 401 时用 refresh token 续期一次后重试。
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"io"
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const apiBodyLimit = 16 << 20
|
|||
|
|
|
|||
|
|
var apiHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|||
|
|
|
|||
|
|
// apiBaseURL 返回当前 API 基址(无尾斜杠)。优先本地 meta,再回退打包默认。
|
|||
|
|
func (a *App) apiBaseURL() string {
|
|||
|
|
if a.store != nil {
|
|||
|
|
if u := strings.TrimRight(strings.TrimSpace(a.store.Meta("sync_base_url")), "/"); u != "" {
|
|||
|
|
return u
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return strings.TrimRight(strings.TrimSpace(packagedSyncDefaults().BaseURL), "/")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiDo 发 JSON HTTP 请求。auth=true 时带 Bearer access token,遇 401 尝试刷新后重试一次。
|
|||
|
|
// 网络失败 → SYNC_OFFLINE;业务错误体 {"error":"CODE"} → errors.New(CODE)。
|
|||
|
|
func (a *App) apiDo(method, path string, body any, auth bool) (int, []byte, error) {
|
|||
|
|
return a.apiDoOnce(method, path, body, auth, true, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiDoWithHeaders 同 apiDo,可附加额外请求头(如 X-Admin-StepUp)。
|
|||
|
|
func (a *App) apiDoWithHeaders(method, path string, body any, auth bool, headers map[string]string) (int, []byte, error) {
|
|||
|
|
return a.apiDoOnce(method, path, body, auth, true, headers)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *App) apiDoOnce(method, path string, body any, auth, allowRefresh bool, headers map[string]string) (int, []byte, error) {
|
|||
|
|
base := a.apiBaseURL()
|
|||
|
|
if base == "" {
|
|||
|
|
return 0, nil, errors.New("SYNC_NOT_CONFIGURED")
|
|||
|
|
}
|
|||
|
|
var rdr io.Reader
|
|||
|
|
if body != nil {
|
|||
|
|
b, e := json.Marshal(body)
|
|||
|
|
if e != nil {
|
|||
|
|
return 0, nil, e
|
|||
|
|
}
|
|||
|
|
rdr = bytes.NewReader(b)
|
|||
|
|
}
|
|||
|
|
req, e := http.NewRequest(method, base+path, rdr)
|
|||
|
|
if e != nil {
|
|||
|
|
return 0, nil, errors.New("SYNC_OFFLINE")
|
|||
|
|
}
|
|||
|
|
if body != nil {
|
|||
|
|
req.Header.Set("Content-Type", "application/json")
|
|||
|
|
}
|
|||
|
|
for k, v := range headers {
|
|||
|
|
if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" {
|
|||
|
|
req.Header.Set(k, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if auth {
|
|||
|
|
tok := ""
|
|||
|
|
if a.store != nil {
|
|||
|
|
tok = strings.TrimSpace(a.store.Meta("sync_access_token"))
|
|||
|
|
}
|
|||
|
|
if tok == "" {
|
|||
|
|
return 0, nil, errors.New("SYNC_NOT_LOGGED_IN")
|
|||
|
|
}
|
|||
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|||
|
|
}
|
|||
|
|
resp, e := apiHTTPClient.Do(req)
|
|||
|
|
if e != nil {
|
|||
|
|
return 0, nil, errors.New("SYNC_OFFLINE")
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, apiBodyLimit))
|
|||
|
|
if resp.StatusCode == http.StatusUnauthorized && auth && allowRefresh {
|
|||
|
|
if a.apiTryRefresh() == nil {
|
|||
|
|
return a.apiDoOnce(method, path, body, auth, false, headers)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if resp.StatusCode >= 400 {
|
|||
|
|
var er struct {
|
|||
|
|
Error string `json:"error"`
|
|||
|
|
}
|
|||
|
|
if json.Unmarshal(raw, &er) == nil && er.Error != "" {
|
|||
|
|
return resp.StatusCode, raw, errors.New(er.Error)
|
|||
|
|
}
|
|||
|
|
return resp.StatusCode, raw, errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode))
|
|||
|
|
}
|
|||
|
|
return resp.StatusCode, raw, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiTryRefresh 用 refresh token 换新双令牌并写入 meta;失败返回错误码。
|
|||
|
|
func (a *App) apiTryRefresh() error {
|
|||
|
|
if a.store == nil {
|
|||
|
|
return errors.New("SYNC_NOT_LOGGED_IN")
|
|||
|
|
}
|
|||
|
|
rt := strings.TrimSpace(a.store.Meta("sync_refresh_token"))
|
|||
|
|
if rt == "" {
|
|||
|
|
return errors.New("SYNC_NOT_LOGGED_IN")
|
|||
|
|
}
|
|||
|
|
status, raw, e := a.apiDoOnce(http.MethodPost, "/api/v1/auth/refresh", map[string]string{
|
|||
|
|
"refreshToken": rt,
|
|||
|
|
}, false, false, nil)
|
|||
|
|
if e != nil {
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
if status != http.StatusOK {
|
|||
|
|
return errors.New("UNAUTHORIZED")
|
|||
|
|
}
|
|||
|
|
var out struct {
|
|||
|
|
AccessToken string `json:"accessToken"`
|
|||
|
|
RefreshToken string `json:"refreshToken"`
|
|||
|
|
UserID int64 `json:"userId"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
}
|
|||
|
|
if json.Unmarshal(raw, &out) != nil || out.AccessToken == "" {
|
|||
|
|
return errors.New("UNAUTHORIZED")
|
|||
|
|
}
|
|||
|
|
_ = a.store.SetMeta("sync_access_token", out.AccessToken)
|
|||
|
|
if out.RefreshToken != "" {
|
|||
|
|
_ = a.store.SetMeta("sync_refresh_token", out.RefreshToken)
|
|||
|
|
}
|
|||
|
|
if out.UserID > 0 {
|
|||
|
|
_ = a.store.SetMeta("sync_user_id", strconv.FormatInt(out.UserID, 10))
|
|||
|
|
}
|
|||
|
|
if out.Username != "" {
|
|||
|
|
_ = a.store.SetMeta("sync_username", out.Username)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiDecode 调用 apiDo 并把 2xx JSON 解到 out(out 可为 nil)。
|
|||
|
|
func (a *App) apiDecode(method, path string, body, out any, auth bool) error {
|
|||
|
|
return a.apiDecodeHeaders(method, path, body, out, auth, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *App) apiDecodeHeaders(method, path string, body, out any, auth bool, headers map[string]string) error {
|
|||
|
|
_, raw, e := a.apiDoWithHeaders(method, path, body, auth, headers)
|
|||
|
|
if e != nil {
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
if out == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if e := json.Unmarshal(raw, out); e != nil {
|
|||
|
|
return errors.New("SYNC_BAD_RESPONSE")
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiPutSetting 写入(或 LWW 更新)单条设置。
|
|||
|
|
func (a *App) apiPutSetting(name, value, updatedAt string) error {
|
|||
|
|
return a.apiPutSettingWithStepUp(name, value, updatedAt, false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *App) apiPutSettingWithStepUp(name, value, updatedAt string, withStepUp bool) error {
|
|||
|
|
headers := map[string]string{}
|
|||
|
|
if withStepUp {
|
|||
|
|
tok := a.currentStepUpToken()
|
|||
|
|
if tok == "" {
|
|||
|
|
return errors.New("ADMIN_STEPUP_REQUIRED")
|
|||
|
|
}
|
|||
|
|
headers["X-Admin-StepUp"] = tok
|
|||
|
|
}
|
|||
|
|
return a.apiDecodeHeaders(http.MethodPut, "/api/v1/settings/"+pathEscape(name), map[string]string{
|
|||
|
|
"value": value,
|
|||
|
|
"updatedAt": updatedAt,
|
|||
|
|
}, nil, true, headers)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiGetSetting 读单条设置;NOT_FOUND 时 ok=false 且 err=nil。
|
|||
|
|
func (a *App) apiGetSetting(name string) (value, updatedAt string, ok bool, err error) {
|
|||
|
|
var row struct {
|
|||
|
|
Value string `json:"value"`
|
|||
|
|
UpdatedAt string `json:"updatedAt"`
|
|||
|
|
}
|
|||
|
|
e := a.apiDecode(http.MethodGet, "/api/v1/settings/"+pathEscape(name), nil, &row, true)
|
|||
|
|
if e != nil {
|
|||
|
|
if e.Error() == "NOT_FOUND" {
|
|||
|
|
return "", "", false, nil
|
|||
|
|
}
|
|||
|
|
return "", "", false, e
|
|||
|
|
}
|
|||
|
|
return row.Value, row.UpdatedAt, true, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiGetGlobalSetting 读挂在管理员名下的全局设置。
|
|||
|
|
func (a *App) apiGetGlobalSetting(name string) (value, updatedAt string, ok bool, err error) {
|
|||
|
|
var row struct {
|
|||
|
|
Value string `json:"value"`
|
|||
|
|
UpdatedAt string `json:"updatedAt"`
|
|||
|
|
}
|
|||
|
|
e := a.apiDecode(http.MethodGet, "/api/v1/settings/global/"+pathEscape(name), nil, &row, true)
|
|||
|
|
if e != nil {
|
|||
|
|
if e.Error() == "NOT_FOUND" {
|
|||
|
|
return "", "", false, nil
|
|||
|
|
}
|
|||
|
|
return "", "", false, e
|
|||
|
|
}
|
|||
|
|
return row.Value, row.UpdatedAt, true, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apiListSettings 按前缀批量拉取(如 fest_img:)。
|
|||
|
|
func (a *App) apiListSettings(prefix string) ([]struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
Value string `json:"value"`
|
|||
|
|
UpdatedAt string `json:"updatedAt"`
|
|||
|
|
}, error) {
|
|||
|
|
var resp struct {
|
|||
|
|
Items []struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
Value string `json:"value"`
|
|||
|
|
UpdatedAt string `json:"updatedAt"`
|
|||
|
|
} `json:"items"`
|
|||
|
|
}
|
|||
|
|
q := "/api/v1/settings?prefix=" + pathEscape(prefix)
|
|||
|
|
if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
if resp.Items == nil {
|
|||
|
|
return []struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
Value string `json:"value"`
|
|||
|
|
UpdatedAt string `json:"updatedAt"`
|
|||
|
|
}{}, nil
|
|||
|
|
}
|
|||
|
|
return resp.Items, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// pathEscape 对路径段做 URL 转义(保留常见安全字符)。
|
|||
|
|
func pathEscape(s string) string {
|
|||
|
|
var b strings.Builder
|
|||
|
|
for _, r := range s {
|
|||
|
|
switch {
|
|||
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
|||
|
|
r == '-', r == '_', r == '.', r == '~', r == ':':
|
|||
|
|
b.WriteRune(r)
|
|||
|
|
default:
|
|||
|
|
for _, c := range []byte(string(r)) {
|
|||
|
|
b.WriteByte('%')
|
|||
|
|
const hex = "0123456789ABCDEF"
|
|||
|
|
b.WriteByte(hex[c>>4])
|
|||
|
|
b.WriteByte(hex[c&0xf])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return b.String()
|
|||
|
|
}
|