File size: 7,263 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | package syncer
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// 分类常量:决定用哪套合并规则(凭证看 expiresAt,普通数据看 mtime)。
const (
catAuth = "auth"
catData = "data"
)
// FileState 是一个文件在某一端的可比较状态。
//
// Content 只在需要时填充(本地端天然有;远端端按需下载)。比较优先用
// SHA256,避免为了判断"是否相同"而多跑一次下载。
type FileState struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Mtime time.Time `json:"mtime"`
// Content 是文件原始字节,**绝不能被序列化出去**。
//
// 它只用于本地合并与上传;而 Action 会被 `wb2api-sync -json` 整个打印出来,
// 一旦让 Content 进 JSON,`status -json` 就会把 accessToken / refreshToken
// 明文(base64)灌进终端、CI 日志或 issue 附件里——等于自己泄露凭证。
Content []byte `json:"-"`
// 仅 auth 文件填充:这三项是"谁的凭证更新"的判据。
IsAuth bool `json:"is_auth,omitempty"`
UID string `json:"uid,omitempty"`
Nickname string `json:"nickname,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
HasRefresh bool `json:"has_refresh,omitempty"`
Realm string `json:"realm,omitempty"`
}
// hasContent 报告该状态是否已带内容。
func (f *FileState) hasContent() bool { return f.Content != nil }
// scanLocal 扫描本地待同步文件,key 为桶内相对路径(如 auths/workbuddy-x.json)。
//
// 只扫顶层 *.json:
// - auths/ —— 与网关 auth.LoadDir 的口径一致(它也只解析一层);
// - data/ —— 只取 IncludeData 白名单。
//
// 子目录(如 data/request-logs/)一律不扫:请求归档是本机观测数据,
// 互相搬运既无意义又会把桶撑大。
func (e *Engine) scanLocal() (map[string]*FileState, error) {
out := map[string]*FileState{}
if e.cfg.AuthDir != "" {
files, err := readTopLevelJSON(e.cfg.AuthDir)
if err != nil {
return nil, err
}
for _, name := range files {
fp := filepath.Join(e.cfg.AuthDir, name)
st, err := statLocalFile(fp, "auths/"+name, catAuth)
if err != nil {
// 单个坏文件不阻断整体同步(与 auth.LoadDir 的容错口径一致)。
continue
}
out[st.Path] = st
}
}
for _, name := range e.cfg.IncludeData {
if e.cfg.DataDir == "" || name == "" || strings.ContainsAny(name, `/\`) {
continue
}
fp := filepath.Join(e.cfg.DataDir, name)
st, err := statLocalFile(fp, "data/"+name, catData)
if err != nil {
continue // 本地还没有这个文件是正常态(首跑)
}
out[st.Path] = st
}
return out, nil
}
// statLocalFile 读取并按 category 解析一个本地文件。
func statLocalFile(fp, relPath, category string) (*FileState, error) {
raw, err := os.ReadFile(fp)
if err != nil {
return nil, err
}
info, err := os.Stat(fp)
if err != nil {
return nil, err
}
st := newFileState(relPath, raw, info.ModTime(), category)
return st, nil
}
// newFileState 由内容 + mtime + 分类构造 FileState。
func newFileState(relPath string, raw []byte, mtime time.Time, category string) *FileState {
sum := sha256.Sum256(raw)
st := &FileState{
Path: relPath,
Size: int64(len(raw)),
SHA256: hex.EncodeToString(sum[:]),
Mtime: mtime,
Content: raw,
}
if category == catAuth {
annotateAuth(st, raw)
}
return st
}
// annotateAuth 给 auth 文件标注比较维度(uid / expiresAt / refreshToken 有无)。
//
// 用自带的容错解析而不是网关的 auth.Parse:启动器要做成独立 exe,不能和网关
// 内部包绑死(详见 cred.go 的说明)。解析不出来就退化成纯字节比较,不是错误。
func annotateAuth(st *FileState, raw []byte) {
c, ok := parseCredential(raw)
if !ok {
return
}
st.IsAuth = true
st.UID = c.UID
st.Nickname = c.Nickname
st.ExpiresAt = c.ExpiresAt
st.HasRefresh = c.HasRefresh
st.Realm = c.Realm
}
// readTopLevelJSON 返回 dir 下顶层的 *.json 文件名(升序,结果稳定)。
// dir 不存在返回空集而非错误——本地首次运行时 auths/ 还没建。
func readTopLevelJSON(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".json") {
continue
}
out = append(out, ent.Name())
}
sort.Strings(out)
return out, nil
}
// ---------------------------------------------------------------------------
// 写入
// ---------------------------------------------------------------------------
// writeLocalAtomic 原子写入本地文件(tmp + rename),与网关自身的落盘约定一致。
//
// 权限 0600:auths/ 里是明文 refreshToken,不能给组/其他用户读。
func writeLocalAtomic(fp string, content []byte, mtime time.Time) error {
dir := filepath.Dir(fp)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dir, err)
}
tmp := fp + ".tmp"
if err := os.WriteFile(tmp, content, 0o600); err != nil {
return fmt.Errorf("写入 %s 失败: %w", tmp, err)
}
if err := os.Rename(tmp, fp); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("替换 %s 失败: %w", fp, err)
}
// 保留远端 mtime:下一轮比较靠它判断谁更新,丢了会导致对端反复重传。
if !mtime.IsZero() {
_ = os.Chtimes(fp, mtime, mtime)
}
return nil
}
// localPath 把桶内相对路径映射为本地绝对路径。
func (e *Engine) localPath(relPath string) (string, error) {
clean := path.Clean("/" + strings.ReplaceAll(relPath, `\`, "/"))
switch {
case strings.HasPrefix(clean, "/auths/"):
name := path.Base(clean)
if name == "" || name == "." || name == "/" {
return "", fmt.Errorf("非法 auths 路径: %q", relPath)
}
return filepath.Join(e.cfg.AuthDir, name), nil
case strings.HasPrefix(clean, "/data/"):
name := path.Base(clean)
if name == "" || name == "." || name == "/" {
return "", fmt.Errorf("非法 data 路径: %q", relPath)
}
return filepath.Join(e.cfg.DataDir, name), nil
default:
return "", fmt.Errorf("同步路径不在白名单内: %q(只允许 auths/ 与 data/ 顶层 json)", relPath)
}
}
// PathAllowed 报告一个桶内相对路径是否属于同步白名单。
// 面板接口与本地写入都过这一关,杜绝 ../ 穿越与任意路径写盘。
func PathAllowed(relPath string, includeData []string) bool {
p := strings.ReplaceAll(relPath, `\`, "/")
if strings.Contains(p, "..") {
return false
}
clean := path.Clean("/" + p)
if strings.HasPrefix(clean, "/auths/") {
name := path.Base(clean)
return name != "." && name != "/" && strings.HasSuffix(strings.ToLower(name), ".json") &&
path.Dir(clean) == "/auths"
}
if strings.HasPrefix(clean, "/data/") {
if path.Dir(clean) != "/data" {
return false
}
name := path.Base(clean)
for _, allowed := range includeData {
if name == allowed {
return true
}
}
return false
}
return false
}
|