204848 commited on
Commit
0aefde8
·
1 Parent(s): 7fb3b2e

feat: 添加文件审核严格模式设置,增强文件上传安全性

Browse files
internal/deepseek/client/client_upload.go CHANGED
@@ -60,7 +60,8 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
60
  purpose := strings.TrimSpace(req.Purpose)
61
  modelType := strings.ToLower(strings.TrimSpace(req.ModelType))
62
  // 与 Android 真实客户端一致:upload_file 始终携带 x-model-type(默认 default),
63
- // 且申请/携带 PoW( completion 需要 PoW)。
 
64
  if modelType == "" {
65
  modelType = "default"
66
  }
@@ -78,8 +79,10 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
78
  captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
79
  attempts := 0
80
  refreshed := false
 
81
  lastFailureKind := FailureUnknown
82
  lastFailureMessage := ""
 
83
  for attempts < maxAttempts {
84
  clients := c.requestClientsForAuth(ctx, a)
85
  headers := c.authHeadersWithAuth(a)
@@ -87,6 +90,20 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
87
  headers["x-model-type"] = modelType
88
  headers["x-file-size"] = strconv.Itoa(len(req.Data))
89
  headers["x-thinking-enabled"] = "1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  resp, err := c.doUpload(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekUploadFileURL, headers, body)
91
  if err != nil {
92
  config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename)
@@ -143,12 +160,14 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
143
  if !refreshed && shouldAttemptRefresh(resp.StatusCode, code, bizCode, msg, bizMsg) {
144
  if c.Auth.RefreshToken(ctx, a) {
145
  refreshed = true
 
146
  attempts++
147
  continue
148
  }
149
  }
150
  if c.Auth.SwitchAccount(ctx, a) {
151
  refreshed = false
 
152
  attempts++
153
  continue
154
  }
 
60
  purpose := strings.TrimSpace(req.Purpose)
61
  modelType := strings.ToLower(strings.TrimSpace(req.ModelType))
62
  // 与 Android 真实客户端一致:upload_file 始终携带 x-model-type(默认 default),
63
+ // 且申请携带 PoW(target_path=/api/v0/file/upload_file,HAR 实测)。
64
+ // 缺少 x-ds-pow-response 会被服务端以 MISSING_HEADER 拒绝。
65
  if modelType == "" {
66
  modelType = "default"
67
  }
 
79
  captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
80
  attempts := 0
81
  refreshed := false
82
+ powRefreshed := false
83
  lastFailureKind := FailureUnknown
84
  lastFailureMessage := ""
85
+ var powResp string
86
  for attempts < maxAttempts {
87
  clients := c.requestClientsForAuth(ctx, a)
88
  headers := c.authHeadersWithAuth(a)
 
90
  headers["x-model-type"] = modelType
91
  headers["x-file-size"] = strconv.Itoa(len(req.Data))
92
  headers["x-thinking-enabled"] = "1"
93
+ // 每次上传尝试前申请 PoW(首次 + 切换账号后重新申请)。
94
+ if strings.TrimSpace(powResp) == "" || powRefreshed {
95
+ pow, powErr := c.GetPowForTarget(ctx, a, dsprotocol.DeepSeekUploadTargetPath, maxAttempts)
96
+ if powErr != nil {
97
+ config.Logger.Warn("[upload_file] get pow failed", "error", powErr, "account", a.AccountID, "filename", filename)
98
+ lastFailureKind = FailureUnknown
99
+ lastFailureMessage = powErr.Error()
100
+ attempts++
101
+ continue
102
+ }
103
+ powResp = pow
104
+ powRefreshed = false
105
+ }
106
+ headers["x-ds-pow-response"] = powResp
107
  resp, err := c.doUpload(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekUploadFileURL, headers, body)
108
  if err != nil {
109
  config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename)
 
160
  if !refreshed && shouldAttemptRefresh(resp.StatusCode, code, bizCode, msg, bizMsg) {
161
  if c.Auth.RefreshToken(ctx, a) {
162
  refreshed = true
163
+ powRefreshed = true
164
  attempts++
165
  continue
166
  }
167
  }
168
  if c.Auth.SwitchAccount(ctx, a) {
169
  refreshed = false
170
+ powRefreshed = true
171
  attempts++
172
  continue
173
  }
internal/deepseek/client/client_upload_test.go CHANGED
@@ -96,20 +96,26 @@ func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) {
96
  }
97
  }
98
 
99
- func TestUploadFileUsesMultipartHeadersWithoutPow(t *testing.T) {
 
 
 
 
100
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
101
  var seenPow string
102
  var seenContentType string
103
  var seenFileSize string
104
  var seenModelType string
105
  var seenBody string
106
- call := 0
107
  client := &Client{
108
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
109
- call++
110
  bodyBytes, _ := io.ReadAll(req.Body)
111
- switch call {
112
- case 1:
 
 
 
113
  seenPow = req.Header.Get("x-ds-pow-response")
114
  seenContentType = req.Header.Get("Content-Type")
115
  seenFileSize = req.Header.Get("x-file-size")
@@ -117,7 +123,7 @@ func TestUploadFileUsesMultipartHeadersWithoutPow(t *testing.T) {
117
  seenBody = string(bodyBytes)
118
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
119
  default:
120
- t.Fatalf("unexpected request count %d", call)
121
  return nil, nil
122
  }
123
  }),
@@ -139,9 +145,12 @@ func TestUploadFileUsesMultipartHeadersWithoutPow(t *testing.T) {
139
  if result.ID != "file_789" {
140
  t.Fatalf("expected uploaded file id file_789, got %#v", result)
141
  }
142
- // Android 真实客户端一致:upload 不携带 PoW。
143
- if seenPow != "" {
144
- t.Fatalf("expected no x-ds-pow-response header, got %q", seenPow)
 
 
 
145
  }
146
  if seenFileSize != "5" {
147
  t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
@@ -160,16 +169,18 @@ func TestUploadFileUsesMultipartHeadersWithoutPow(t *testing.T) {
160
  func TestUploadFileDefaultsModelTypeToDefaultWhenAbsent(t *testing.T) {
161
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
162
  var seenModelType string
163
- call := 0
164
  client := &Client{
165
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
166
- call++
167
- switch call {
168
- case 1:
 
 
169
  seenModelType = req.Header.Get("x-model-type")
170
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
171
  default:
172
- t.Fatalf("unexpected request count %d", call)
173
  return nil, nil
174
  }
175
  }),
@@ -185,6 +196,9 @@ func TestUploadFileDefaultsModelTypeToDefaultWhenAbsent(t *testing.T) {
185
  if err != nil {
186
  t.Fatalf("UploadFile error: %v", err)
187
  }
 
 
 
188
  // 上层未传 ModelType 时,应与 Android 客户端一致默认填 default。
189
  if seenModelType != "default" {
190
  t.Fatalf("expected x-model-type=default, got %q", seenModelType)
@@ -204,20 +218,16 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
204
  client := &Client{
205
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
206
  call++
207
- switch call {
208
- case 1:
209
- // upload 不再申请 PoW,第一个请求就是 upload 本身。
210
- if req.Header.Get("x-ds-pow-response") != "" {
211
- t.Fatalf("upload should not carry PoW header, got %q", req.Header.Get("x-ds-pow-response"))
 
 
212
  }
213
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
214
- case 2, 3:
215
- if req.Method != http.MethodGet {
216
- t.Fatalf("expected GET fetch request, got %s", req.Method)
217
- }
218
- if req.URL.Path != "/api/v0/file/fetch_files" {
219
- t.Fatalf("expected fetch files path /api/v0/file/fetch_files, got %q", req.URL.Path)
220
- }
221
  if got := req.URL.Query().Get("file_ids"); got != "file_789" {
222
  t.Fatalf("expected file_ids=file_789, got %q", got)
223
  }
@@ -227,7 +237,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
227
  }
228
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(respBody)), Request: req}, nil
229
  default:
230
- t.Fatalf("unexpected request count %d", call)
231
  return nil, nil
232
  }
233
  }),
@@ -251,6 +261,6 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
251
  t.Fatalf("expected final status processed, got %#v", result.Status)
252
  }
253
  if call != 3 {
254
- t.Fatalf("expected 3 requests (upload + 2 fetch), got %d", call)
255
  }
256
  }
 
96
  }
97
  }
98
 
99
+ // powChallengeResponse create_pow_challenge 的最小可用响应:
100
+ // challenge 为 64 位 hex,difficulty=1 让 ComputePow 能立即求解。
101
+ const powChallengeResponse = `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"0000000000000000000000000000000000000000000000000000000000000000","salt":"salt","difficulty":1,"expire_at":9999999999,"signature":"sig","target_path":"/api/v0/file/upload_file"}}}}`
102
+
103
+ func TestUploadFileUsesMultipartHeadersWithPow(t *testing.T) {
104
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
105
  var seenPow string
106
  var seenContentType string
107
  var seenFileSize string
108
  var seenModelType string
109
  var seenBody string
110
+ uploadCalled := false
111
  client := &Client{
112
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
 
113
  bodyBytes, _ := io.ReadAll(req.Body)
114
+ switch {
115
+ case strings.Contains(req.URL.Path, "create_pow_challenge"):
116
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
117
+ case strings.Contains(req.URL.Path, "upload_file"):
118
+ uploadCalled = true
119
  seenPow = req.Header.Get("x-ds-pow-response")
120
  seenContentType = req.Header.Get("Content-Type")
121
  seenFileSize = req.Header.Get("x-file-size")
 
123
  seenBody = string(bodyBytes)
124
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
125
  default:
126
+ t.Fatalf("unexpected request path %q", req.URL.Path)
127
  return nil, nil
128
  }
129
  }),
 
145
  if result.ID != "file_789" {
146
  t.Fatalf("expected uploaded file id file_789, got %#v", result)
147
  }
148
+ if !uploadCalled {
149
+ t.Fatal("expected upload request to be made")
150
+ }
151
+ // 与 Android 真实客户端一致:upload 必须携带 PoW(target_path=/api/v0/file/upload_file)。
152
+ if seenPow == "" {
153
+ t.Fatal("expected x-ds-pow-response header on upload, got empty")
154
  }
155
  if seenFileSize != "5" {
156
  t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
 
169
  func TestUploadFileDefaultsModelTypeToDefaultWhenAbsent(t *testing.T) {
170
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
171
  var seenModelType string
172
+ uploadCalled := false
173
  client := &Client{
174
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
175
+ switch {
176
+ case strings.Contains(req.URL.Path, "create_pow_challenge"):
177
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
178
+ case strings.Contains(req.URL.Path, "upload_file"):
179
+ uploadCalled = true
180
  seenModelType = req.Header.Get("x-model-type")
181
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
182
  default:
183
+ t.Fatalf("unexpected request path %q", req.URL.Path)
184
  return nil, nil
185
  }
186
  }),
 
196
  if err != nil {
197
  t.Fatalf("UploadFile error: %v", err)
198
  }
199
+ if !uploadCalled {
200
+ t.Fatal("expected upload request to be made")
201
+ }
202
  // 上层未传 ModelType 时,应与 Android 客户端一致默认填 default。
203
  if seenModelType != "default" {
204
  t.Fatalf("expected x-model-type=default, got %q", seenModelType)
 
218
  client := &Client{
219
  regular: doerFunc(func(req *http.Request) (*http.Response, error) {
220
  call++
221
+ switch {
222
+ case strings.Contains(req.URL.Path, "create_pow_challenge"):
223
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
224
+ case strings.Contains(req.URL.Path, "upload_file"):
225
+ // upload 必须携带 PoW(HAR 实测 target_path=/api/v0/file/upload_file)。
226
+ if req.Header.Get("x-ds-pow-response") == "" {
227
+ t.Fatalf("upload should carry PoW header, got empty")
228
  }
229
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
230
+ case req.Method == http.MethodGet && req.URL.Path == "/api/v0/file/fetch_files":
 
 
 
 
 
 
231
  if got := req.URL.Query().Get("file_ids"); got != "file_789" {
232
  t.Fatalf("expected file_ids=file_789, got %q", got)
233
  }
 
237
  }
238
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(respBody)), Request: req}, nil
239
  default:
240
+ t.Fatalf("unexpected request %s %q", req.Method, req.URL.Path)
241
  return nil, nil
242
  }
243
  }),
 
261
  t.Fatalf("expected final status processed, got %#v", result.Status)
262
  }
263
  if call != 3 {
264
+ t.Fatalf("expected 3 requests (pow + upload + 1 fetch), got %d", call)
265
  }
266
  }
webui/src/features/settings/FileAuditSection.jsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function FileAuditSection({ t, form, setForm }) {
2
+ return (
3
+ <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
+ <div className="space-y-1">
5
+ <h3 className="font-semibold">{t('settings.fileAuditTitle')}</h3>
6
+ <p className="text-sm text-muted-foreground">{t('settings.fileAuditDesc')}</p>
7
+ </div>
8
+ <label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
9
+ <input
10
+ type="checkbox"
11
+ checked={Boolean(form.file_audit?.strict)}
12
+ onChange={(e) => setForm((prev) => ({
13
+ ...prev,
14
+ file_audit: {
15
+ ...prev.file_audit,
16
+ strict: e.target.checked,
17
+ },
18
+ }))}
19
+ className="mt-1 h-4 w-4 rounded border-border"
20
+ />
21
+ <div className="space-y-1">
22
+ <span className="text-sm font-medium block">{t('settings.fileAuditEnabled')}</span>
23
+ <span className="text-xs text-muted-foreground block">{t('settings.fileAuditHelp')}</span>
24
+ </div>
25
+ </label>
26
+ </div>
27
+ )
28
+ }
webui/src/features/settings/SettingsContainer.jsx CHANGED
@@ -7,6 +7,7 @@ import PlatformSection from './PlatformSection'
7
  import RuntimeSection from './RuntimeSection'
8
  import BehaviorSection from './BehaviorSection'
9
  import CurrentInputFileSection from './CurrentInputFileSection'
 
10
  import AutoDeleteSection from './AutoDeleteSection'
11
  import ModelSection from './ModelSection'
12
  import BackupSection from './BackupSection'
@@ -100,6 +101,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
100
 
101
  <CurrentInputFileSection t={t} form={form} setForm={setForm} />
102
 
 
 
103
  <AutoDeleteSection t={t} form={form} setForm={setForm} />
104
 
105
  <ModelSection t={t} form={form} setForm={setForm} />
 
7
  import RuntimeSection from './RuntimeSection'
8
  import BehaviorSection from './BehaviorSection'
9
  import CurrentInputFileSection from './CurrentInputFileSection'
10
+ import FileAuditSection from './FileAuditSection'
11
  import AutoDeleteSection from './AutoDeleteSection'
12
  import ModelSection from './ModelSection'
13
  import BackupSection from './BackupSection'
 
101
 
102
  <CurrentInputFileSection t={t} form={form} setForm={setForm} />
103
 
104
+ <FileAuditSection t={t} form={form} setForm={setForm} />
105
+
106
  <AutoDeleteSection t={t} form={form} setForm={setForm} />
107
 
108
  <ModelSection t={t} form={form} setForm={setForm} />
webui/src/locales/en.json CHANGED
@@ -444,6 +444,10 @@
444
  "currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a randomly named context file (filename never includes history).",
445
  "currentInputFileMinChars": "Current input threshold (characters)",
446
  "currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
 
 
 
 
447
  "modelTitle": "Model mapping",
448
  "modelAliases": "Global model aliases (JSON)",
449
  "autoDeleteTitle": "Session Cleanup Policy",
 
444
  "currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a randomly named context file (filename never includes history).",
445
  "currentInputFileMinChars": "Current input threshold (characters)",
446
  "currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
447
+ "fileAuditTitle": "File audit strict mode",
448
+ "fileAuditEnabled": "Strictly wait for audit pass",
449
+ "fileAuditDesc": "Default off. DeepSeek audits uploaded files asynchronously (audit_result: unknown→pass/reject); even if not passed, the completion stream is still pushed in full and a recall event is appended at the end (this project consumes the full stream directly, so recall has no effect). When enabled: forces waiting for audit_result=pass before treating the file as ready; audit failure returns an error. File metadata is recorded in chat history regardless of this toggle.",
450
+ "fileAuditHelp": "Recommended to keep off. Only enable when investigating audit issues.",
451
  "modelTitle": "Model mapping",
452
  "modelAliases": "Global model aliases (JSON)",
453
  "autoDeleteTitle": "Session Cleanup Policy",
webui/src/locales/zh.json CHANGED
@@ -444,6 +444,10 @@
444
  "currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为随机命名的上下文文件(文件名不包含 history)。",
445
  "currentInputFileMinChars": "当前输入阈值(字符数)",
446
  "currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
 
 
 
 
447
  "modelTitle": "模型映射",
448
  "modelAliases": "全局模型映射(JSON)",
449
  "autoDeleteTitle": "会话删除策略",
 
444
  "currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为随机命名的上下文文件(文件名不包含 history)。",
445
  "currentInputFileMinChars": "当前输入阈值(字符数)",
446
  "currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
447
+ "fileAuditTitle": "文件审核严格模式",
448
+ "fileAuditEnabled": "严格等待审核通过",
449
+ "fileAuditDesc": "默认关闭。DeepSeek 上传文件后会异步审核(audit_result:unknown→pass/reject),即使未通过也会正常推送 completion 流,仅在最后追加撤回事件(本项目直接消费完整流,撤回无影响)。开启后:上传后强制等待 audit_result=pass 才视为就绪,审核失败会返回错误。文件元信息无论开关都会记录到对话历史。",
450
+ "fileAuditHelp": "建议保持关闭。仅在排查审核问题时开启。",
451
  "modelTitle": "模型映射",
452
  "modelAliases": "全局模型映射(JSON)",
453
  "autoDeleteTitle": "会话删除策略",