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
}