@pippit-dev/cli 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +23 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +235 -0
  6. package/cmd/canvas/canvas_test.go +126 -0
  7. package/cmd/root.go +29 -4
  8. package/cmd/root_test.go +60 -0
  9. package/cmd/short_drama_test.go +4 -7
  10. package/cmd/update/update.go +34 -5
  11. package/cmd/update/update_test.go +84 -0
  12. package/internal/auth/auth_test.go +813 -0
  13. package/internal/auth/browser_darwin.go +14 -0
  14. package/internal/auth/browser_env.go +35 -0
  15. package/internal/auth/browser_linux.go +14 -0
  16. package/internal/auth/browser_windows.go +14 -0
  17. package/internal/auth/identity.go +89 -0
  18. package/internal/auth/loopback.go +324 -0
  19. package/internal/auth/manager.go +338 -144
  20. package/internal/auth/store.go +303 -0
  21. package/internal/auth/store_file_unix.go +176 -0
  22. package/internal/auth/store_file_windows.go +11 -0
  23. package/internal/auth/types.go +72 -0
  24. package/internal/canvas/allocate.go +70 -0
  25. package/internal/canvas/apply.go +203 -0
  26. package/internal/canvas/canvas_test.go +446 -0
  27. package/internal/canvas/create.go +380 -0
  28. package/internal/canvas/get.go +156 -0
  29. package/internal/canvas/types.go +68 -0
  30. package/internal/canvas/upload.go +250 -0
  31. package/internal/common/access_key.go +42 -6
  32. package/internal/common/access_key_test.go +113 -0
  33. package/internal/common/client.go +119 -21
  34. package/internal/common/client_test.go +213 -0
  35. package/internal/common/runner.go +15 -0
  36. package/internal/config/config.go +0 -20
  37. package/internal/config/config_test.go +0 -18
  38. package/package.json +1 -1
  39. package/skills/short-drama/SKILL.md +4 -4
  40. package/skills/xyq-nest-skill/SKILL.md +11 -1
@@ -0,0 +1,250 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "mime"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/Pippit-dev/pippit-cli/internal/common"
15
+ )
16
+
17
+ const defaultUploadPollInterval = time.Second
18
+
19
+ var uploadContentTypeFallbacks = map[string]string{
20
+ ".jpeg": "image/jpeg",
21
+ ".jpg": "image/jpeg",
22
+ ".png": "image/png",
23
+ ".webp": "image/webp",
24
+ ".mp3": "audio/mpeg",
25
+ ".wav": "audio/wav",
26
+ ".mp4": "video/mp4",
27
+ ".mov": "video/quicktime",
28
+ }
29
+
30
+ type UploadOptions struct {
31
+ Path string
32
+ PollInterval time.Duration
33
+ WaitTimeout time.Duration
34
+ }
35
+
36
+ // AssetLocator reports the durable Pippit ID and optional observed media
37
+ // locations. URLs may be signed or short lived and must not be persisted into
38
+ // Canvas patches; callers should persist PippitAssetID instead.
39
+ type AssetLocator struct {
40
+ PippitAssetID string `json:"pippit_asset_id"`
41
+ SourceID string `json:"source_id,omitempty"`
42
+ DownloadURL string `json:"download_url,omitempty"`
43
+ InternalURL string `json:"internal_url,omitempty"`
44
+ CoverURL string `json:"cover_url,omitempty"`
45
+ VID string `json:"vid,omitempty"`
46
+ }
47
+
48
+ type UploadResult struct {
49
+ State string `json:"state"`
50
+ AssetID string `json:"asset_id,omitempty"`
51
+ PippitAssetID string `json:"pippit_asset_id"`
52
+ Locator AssetLocator `json:"locator"`
53
+ Asset json.RawMessage `json:"asset,omitempty"`
54
+ LogID string `json:"log_id,omitempty"`
55
+ QueryLogID string `json:"query_log_id,omitempty"`
56
+ PollAttempts int `json:"poll_attempts"`
57
+ Warning string `json:"warning,omitempty"`
58
+ }
59
+
60
+ func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*UploadResult, error) {
61
+ client, err := runnerClient(runner, "canvas upload")
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+ if opts.PollInterval < 0 || opts.WaitTimeout < 0 {
66
+ return nil, fmt.Errorf("canvas upload polling durations must not be negative")
67
+ }
68
+ path := strings.TrimSpace(opts.Path)
69
+ if path == "" {
70
+ return nil, fmt.Errorf("canvas upload path is required")
71
+ }
72
+ info, err := os.Stat(path)
73
+ if err != nil {
74
+ return nil, fmt.Errorf("inspect canvas upload file: %w", err)
75
+ }
76
+ if !info.Mode().IsRegular() {
77
+ return nil, fmt.Errorf("canvas upload path %q is not a regular file", path)
78
+ }
79
+ fileName := filepath.Base(path)
80
+ extension := strings.ToLower(filepath.Ext(fileName))
81
+ contentType := mime.TypeByExtension(extension)
82
+ if contentType == "" {
83
+ contentType = uploadContentTypeFallbacks[extension]
84
+ }
85
+ if contentType == "" {
86
+ contentType = "application/octet-stream"
87
+ }
88
+
89
+ var envelope responseEnvelope
90
+ if err := client.SendMultipartRequest(ctx, UploadPath, nil, common.MultipartFile{
91
+ FieldName: "file",
92
+ Path: path,
93
+ FileName: fileName,
94
+ ContentType: contentType,
95
+ }, &envelope); err != nil {
96
+ return nil, fmt.Errorf("canvas upload request failed; outcome may be ambiguous, check assets before retrying: %w", err)
97
+ }
98
+ if err := envelope.validate("canvas upload"); err != nil {
99
+ return nil, err
100
+ }
101
+ assetID, pippitAssetID, err := parseUploadData(envelope.Data)
102
+ if err != nil {
103
+ return nil, common.NewLogIDError(fmt.Sprintf("canvas upload returned invalid data: %v", err), envelope.LogID)
104
+ }
105
+ result := &UploadResult{
106
+ State: StateProcessing,
107
+ AssetID: assetID,
108
+ PippitAssetID: pippitAssetID,
109
+ Locator: AssetLocator{PippitAssetID: pippitAssetID},
110
+ LogID: strings.TrimSpace(envelope.LogID),
111
+ }
112
+
113
+ asset, queryLogID, attempts, err := waitForUploadedAsset(ctx, pippitAssetID, opts.PollInterval, opts.WaitTimeout, runner)
114
+ result.PollAttempts = attempts
115
+ if err != nil {
116
+ // The upload response already returned a durable Pippit asset ID. Keep
117
+ // it machine-readable so callers can resume querying without uploading
118
+ // the same bytes again.
119
+ result.Warning = err.Error()
120
+ return result, nil
121
+ }
122
+ result.State = StateReady
123
+ result.Locator = locatorFromAsset(asset, pippitAssetID)
124
+ result.Asset = asset
125
+ result.QueryLogID = queryLogID
126
+ return result, nil
127
+ }
128
+
129
+ func parseUploadData(raw json.RawMessage) (string, string, error) {
130
+ var data map[string]json.RawMessage
131
+ if err := json.Unmarshal(raw, &data); err != nil {
132
+ return "", "", err
133
+ }
134
+ assetID, err := optionalStringField(data, "asset_id", "AssetId", "assetId")
135
+ if err != nil {
136
+ return "", "", err
137
+ }
138
+ pippitAssetID, err := optionalStringField(data, "pippit_asset_id", "PippitAssetID", "pippitAssetId")
139
+ if err != nil {
140
+ return "", "", err
141
+ }
142
+ if pippitAssetID == "" {
143
+ return "", "", fmt.Errorf("data.pippit_asset_id is missing")
144
+ }
145
+ return assetID, pippitAssetID, nil
146
+ }
147
+
148
+ func optionalStringField(values map[string]json.RawMessage, keys ...string) (string, error) {
149
+ raw, ok := rawField(values, keys...)
150
+ if !ok || string(raw) == "null" {
151
+ return "", nil
152
+ }
153
+ var value string
154
+ if err := json.Unmarshal(raw, &value); err != nil {
155
+ return "", fmt.Errorf("%s must be a JSON string", keys[0])
156
+ }
157
+ return strings.TrimSpace(value), nil
158
+ }
159
+
160
+ func waitForUploadedAsset(
161
+ ctx context.Context,
162
+ assetID string,
163
+ pollInterval, waitTimeout time.Duration,
164
+ runner *common.Runner,
165
+ ) (json.RawMessage, string, int, error) {
166
+ if pollInterval <= 0 {
167
+ pollInterval = defaultUploadPollInterval
168
+ }
169
+ if waitTimeout <= 0 {
170
+ waitTimeout = 2 * time.Minute
171
+ }
172
+ waitCtx, cancel := context.WithTimeout(ctx, waitTimeout)
173
+ defer cancel()
174
+ for attempts := 1; ; attempts++ {
175
+ result, err := queryAssets(waitCtx, []string{assetID}, false, runner)
176
+ if err != nil {
177
+ if waitCtx.Err() != nil {
178
+ return nil, "", attempts, uploadWaitError(waitCtx.Err(), assetID, waitTimeout)
179
+ }
180
+ return nil, "", attempts, fmt.Errorf("query uploaded canvas asset: %w", err)
181
+ }
182
+ if len(result.Assets) == 1 {
183
+ return result.Assets[0], result.LogID, attempts, nil
184
+ }
185
+
186
+ timer := time.NewTimer(pollInterval)
187
+ select {
188
+ case <-waitCtx.Done():
189
+ timer.Stop()
190
+ return nil, result.LogID, attempts, uploadWaitError(waitCtx.Err(), assetID, waitTimeout)
191
+ case <-timer.C:
192
+ }
193
+ }
194
+ }
195
+
196
+ func uploadWaitError(err error, assetID string, timeout time.Duration) error {
197
+ if errors.Is(err, context.DeadlineExceeded) {
198
+ return fmt.Errorf("uploaded canvas asset %q was not queryable within %s", assetID, timeout)
199
+ }
200
+ return fmt.Errorf("wait for uploaded canvas asset %q canceled: %w", assetID, err)
201
+ }
202
+
203
+ func locatorFromAsset(raw json.RawMessage, pippitAssetID string) AssetLocator {
204
+ var value any
205
+ _ = json.Unmarshal(raw, &value)
206
+ return AssetLocator{
207
+ PippitAssetID: pippitAssetID,
208
+ SourceID: findFirstString(value, "SourceID", "source_id", "sourceId"),
209
+ DownloadURL: findFirstString(value, "DownloadUrl", "DownloadURL", "download_url", "downloadUrl"),
210
+ InternalURL: findFirstString(value, "InternalUrl", "InternalURL", "internal_url", "internalUrl"),
211
+ CoverURL: findFirstString(value, "CoverUrl", "CoverURL", "cover_url", "coverUrl"),
212
+ VID: findFirstString(value, "VID", "vid"),
213
+ }
214
+ }
215
+
216
+ func findFirstString(value any, keys ...string) string {
217
+ keySet := make(map[string]struct{}, len(keys))
218
+ for _, key := range keys {
219
+ keySet[key] = struct{}{}
220
+ }
221
+ return walkFirstString(value, keySet, 0)
222
+ }
223
+
224
+ func walkFirstString(value any, keys map[string]struct{}, depth int) string {
225
+ if depth > 10 {
226
+ return ""
227
+ }
228
+ switch typed := value.(type) {
229
+ case map[string]any:
230
+ for key, candidate := range typed {
231
+ if _, ok := keys[key]; ok {
232
+ if text, ok := candidate.(string); ok && strings.TrimSpace(text) != "" {
233
+ return strings.TrimSpace(text)
234
+ }
235
+ }
236
+ }
237
+ for _, candidate := range typed {
238
+ if found := walkFirstString(candidate, keys, depth+1); found != "" {
239
+ return found
240
+ }
241
+ }
242
+ case []any:
243
+ for _, candidate := range typed {
244
+ if found := walkFirstString(candidate, keys, depth+1); found != "" {
245
+ return found
246
+ }
247
+ }
248
+ }
249
+ return ""
250
+ }
@@ -2,30 +2,66 @@ package common
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "fmt"
6
7
  "net/http"
7
8
  "strings"
8
9
 
10
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
9
11
  "github.com/Pippit-dev/pippit-cli/internal/config"
10
12
  )
11
13
 
12
14
  type accessKeyAuthorizer struct {
13
- accessKey string
15
+ accessKey func(context.Context) (string, error)
14
16
  }
15
17
 
16
18
  func NewAccessKeyAuthorizer(accessKey string) RequestAuthorizer {
17
- return &accessKeyAuthorizer{accessKey: strings.TrimSpace(accessKey)}
19
+ trimmed := strings.TrimSpace(accessKey)
20
+ return NewAccessKeyProviderAuthorizer(func() string { return trimmed })
21
+ }
22
+
23
+ // NewAccessKeyProviderAuthorizer resolves the Access Key immediately before
24
+ // each request. This lets interactive commands update their in-memory runtime
25
+ // configuration without rebuilding the shared HTTP client.
26
+ func NewAccessKeyProviderAuthorizer(accessKey func() string) RequestAuthorizer {
27
+ if accessKey == nil {
28
+ accessKey = func() string { return "" }
29
+ }
30
+ return NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) {
31
+ return accessKey(), nil
32
+ })
33
+ }
34
+
35
+ // NewAccessKeyContextProviderAuthorizer resolves a credential lazily for each
36
+ // request. This keeps keychain access out of --help and lets a browser login in
37
+ // the same process authorize the next request without rebuilding the client.
38
+ func NewAccessKeyContextProviderAuthorizer(accessKey func(context.Context) (string, error)) RequestAuthorizer {
39
+ if accessKey == nil {
40
+ accessKey = func(context.Context) (string, error) { return "", nil }
41
+ }
42
+ return &accessKeyAuthorizer{accessKey: accessKey}
18
43
  }
19
44
 
20
45
  func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) error {
21
46
  if err := ctx.Err(); err != nil {
22
47
  return err
23
48
  }
24
- if a.accessKey == "" && req.Method == http.MethodPost {
25
- return fmt.Errorf("%s 缺失. 请前往小云雀官网个人设置页创建 Access Key,地址:https://xyq.jianying.com/home?tab_name=home\n配置后重试:\n export %s=\"<your-access-key>\"", config.EnvXYQAccessKey, config.EnvXYQAccessKey)
49
+ accessKey := ""
50
+ if a != nil && a.accessKey != nil {
51
+ resolved, err := a.accessKey(ctx)
52
+ if err != nil && req.Method == http.MethodPost {
53
+ if errors.Is(err, internal_auth.ErrCredentialNotFound) || errors.Is(err, internal_auth.ErrCredentialExpired) {
54
+ return fmt.Errorf("未找到可用的小云雀登录凭证;请先运行 pippit-tool-cli login: %w", err)
55
+ }
56
+ return fmt.Errorf("读取小云雀 CLI 安全登录凭证失败;请检查本机凭证库后重试: %w", err)
57
+ }
58
+ accessKey = strings.TrimSpace(resolved)
59
+ }
60
+ if accessKey == "" && req.Method == http.MethodPost {
61
+ return fmt.Errorf("未登录小云雀 CLI;请先运行 pippit-tool-cli login(CI 仍可设置 %s)", config.EnvXYQAccessKey)
26
62
  }
27
- if a.accessKey != "" {
28
- req.Header.Set("Authorization", "Bearer "+a.accessKey)
63
+ if accessKey != "" {
64
+ req.Header.Set("Authorization", "Bearer "+accessKey)
29
65
  }
30
66
  return nil
31
67
  }
@@ -0,0 +1,113 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "strings"
8
+ "testing"
9
+
10
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
11
+ )
12
+
13
+ func TestAccessKeyProviderAuthorizerReadsLatestValue(t *testing.T) {
14
+ accessKey := " first-key "
15
+ authorizer := NewAccessKeyProviderAuthorizer(func() string { return accessKey })
16
+
17
+ first := newAccessKeyTestRequest(t, http.MethodPost)
18
+ if err := authorizer.Inject(context.Background(), first); err != nil {
19
+ t.Fatalf("Inject(first) error = %v", err)
20
+ }
21
+ if got := first.Header.Get("Authorization"); got != "Bearer first-key" {
22
+ t.Fatalf("first Authorization = %q, want latest first key", got)
23
+ }
24
+
25
+ accessKey = "second-key"
26
+ second := newAccessKeyTestRequest(t, http.MethodPost)
27
+ if err := authorizer.Inject(context.Background(), second); err != nil {
28
+ t.Fatalf("Inject(second) error = %v", err)
29
+ }
30
+ if got := second.Header.Get("Authorization"); got != "Bearer second-key" {
31
+ t.Fatalf("second Authorization = %q, want updated key", got)
32
+ }
33
+ }
34
+
35
+ func TestAccessKeyProviderAuthorizerRejectsMissingKeyWithoutLeakingPriorValue(t *testing.T) {
36
+ accessKey := "prior-secret"
37
+ authorizer := NewAccessKeyProviderAuthorizer(func() string { return accessKey })
38
+ accessKey = " "
39
+
40
+ err := authorizer.Inject(context.Background(), newAccessKeyTestRequest(t, http.MethodPost))
41
+ if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") {
42
+ t.Fatalf("Inject() error = %v, want missing Access Key guidance", err)
43
+ }
44
+ if strings.Contains(err.Error(), "prior-secret") {
45
+ t.Fatalf("Inject() error leaks a previous Access Key: %v", err)
46
+ }
47
+ }
48
+
49
+ func TestAccessKeyContextProviderAuthorizerLoadsSavedCredentialLazily(t *testing.T) {
50
+ called := 0
51
+ authorizer := NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) {
52
+ called++
53
+ if err := ctx.Err(); err != nil {
54
+ return "", err
55
+ }
56
+ return " saved-browser-key ", nil
57
+ })
58
+ request := newAccessKeyTestRequest(t, http.MethodPost)
59
+ if called != 0 {
60
+ t.Fatal("credential provider was called before request authorization")
61
+ }
62
+ if err := authorizer.Inject(context.Background(), request); err != nil {
63
+ t.Fatalf("Inject() error = %v", err)
64
+ }
65
+ if called != 1 || request.Header.Get("Authorization") != "Bearer saved-browser-key" {
66
+ t.Fatalf("called=%d Authorization=%q", called, request.Header.Get("Authorization"))
67
+ }
68
+ }
69
+
70
+ func TestAccessKeyContextProviderAuthorizerPreservesCredentialStateErrors(t *testing.T) {
71
+ for _, cause := range []error{internal_auth.ErrCredentialNotFound, internal_auth.ErrCredentialExpired} {
72
+ authorizer := NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) {
73
+ return "", cause
74
+ })
75
+ err := authorizer.Inject(context.Background(), newAccessKeyTestRequest(t, http.MethodPost))
76
+ if !errors.Is(err, cause) || !strings.Contains(err.Error(), "pippit-tool-cli login") {
77
+ t.Fatalf("Inject() error = %v, want wrapped %v", err, cause)
78
+ }
79
+ }
80
+ }
81
+
82
+ func TestAccessKeyProviderAuthorizerAllowsUnauthenticatedRead(t *testing.T) {
83
+ authorizer := NewAccessKeyProviderAuthorizer(nil)
84
+ request := newAccessKeyTestRequest(t, http.MethodGet)
85
+
86
+ if err := authorizer.Inject(context.Background(), request); err != nil {
87
+ t.Fatalf("Inject() error = %v", err)
88
+ }
89
+ if got := request.Header.Get("Authorization"); got != "" {
90
+ t.Fatalf("Authorization = %q, want empty", got)
91
+ }
92
+ }
93
+
94
+ func TestAccessKeyAuthorizerKeepsConstantAPIBehavior(t *testing.T) {
95
+ authorizer := NewAccessKeyAuthorizer(" constant-key ")
96
+ request := newAccessKeyTestRequest(t, http.MethodPost)
97
+
98
+ if err := authorizer.Inject(context.Background(), request); err != nil {
99
+ t.Fatalf("Inject() error = %v", err)
100
+ }
101
+ if got := request.Header.Get("Authorization"); got != "Bearer constant-key" {
102
+ t.Fatalf("Authorization = %q, want trimmed constant key", got)
103
+ }
104
+ }
105
+
106
+ func newAccessKeyTestRequest(t *testing.T, method string) *http.Request {
107
+ t.Helper()
108
+ request, err := http.NewRequest(method, "https://example.test/api", nil)
109
+ if err != nil {
110
+ t.Fatalf("NewRequest() error = %v", err)
111
+ }
112
+ return request
113
+ }
@@ -43,14 +43,20 @@ type httpClient struct {
43
43
  }
44
44
 
45
45
  func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
46
- return &httpClient{
47
- baseURL: strings.TrimRight(baseURL, "/"),
48
- httpClient: &http.Client{
49
- Timeout: timeout,
50
- },
46
+ return newHTTPClient(baseURL, timeout, authorizer)
47
+ }
48
+
49
+ func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
50
+ client := &httpClient{
51
+ baseURL: strings.TrimRight(baseURL, "/"),
51
52
  headers: make(http.Header),
52
53
  authorizer: authorizer,
53
54
  }
55
+ client.httpClient = &http.Client{
56
+ Timeout: timeout,
57
+ CheckRedirect: client.checkRedirect,
58
+ }
59
+ return client
54
60
  }
55
61
 
56
62
  func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out any) error {
@@ -85,15 +91,10 @@ func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, bo
85
91
  req.Header.Set("Content-Type", "application/json")
86
92
  }
87
93
 
88
- if c.authorizer == nil {
89
- return fmt.Errorf("授权请求缺少认证器")
90
- }
91
- if err := c.authorizer.Inject(ctx, req); err != nil {
92
- return fmt.Errorf("写入认证请求头失败: %w", err)
94
+ if err := c.prepareRequest(ctx, req, headers); err != nil {
95
+ return err
93
96
  }
94
97
 
95
- c.injectHeaders(req, headers)
96
-
97
98
  // If out is **http.Response, return the raw response for streaming (e.g. file download).
98
99
  if out != nil {
99
100
  if rv := reflect.ValueOf(out); rv.Kind() == reflect.Ptr && rv.Elem().Kind() == reflect.Ptr {
@@ -143,17 +144,11 @@ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fiel
143
144
  req.Header.Set("Content-Type", writer.FormDataContentType())
144
145
  req.Header.Set("Accept", "application/json")
145
146
 
146
- if c.authorizer == nil {
147
- _ = pr.Close()
148
- _ = pw.Close()
149
- return fmt.Errorf("授权请求缺少认证器")
150
- }
151
- if err := c.authorizer.Inject(ctx, req); err != nil {
147
+ if err := c.prepareRequest(ctx, req, nil); err != nil {
152
148
  _ = pr.Close()
153
149
  _ = pw.Close()
154
- return fmt.Errorf("写入认证请求头失败: %w", err)
150
+ return err
155
151
  }
156
- c.injectHeaders(req, nil)
157
152
 
158
153
  go func() {
159
154
  err := writeMultipartBody(writer, fields, file)
@@ -212,6 +207,102 @@ func (c *httpClient) injectHeaders(req *http.Request, headers map[string]string)
212
207
  }
213
208
  }
214
209
 
210
+ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, headers map[string]string) error {
211
+ trusted, err := c.isBaseURLOrigin(req.URL)
212
+ if err != nil {
213
+ return err
214
+ }
215
+
216
+ c.injectHeaders(req, headers)
217
+ // Authentication is protected. Neither the client's generic headers nor a
218
+ // caller-provided map may set it; Authorization is rebuilt below only for
219
+ // the configured API origin.
220
+ req.Header.Del("Authorization")
221
+ if !trusted {
222
+ // Absolute third-party URLs are used for result downloads. Authorization
223
+ // remains empty outside the API origin.
224
+ return nil
225
+ }
226
+ if c.authorizer == nil {
227
+ return fmt.Errorf("授权请求缺少认证器")
228
+ }
229
+ if err := c.authorizer.Inject(ctx, req); err != nil {
230
+ return fmt.Errorf("写入认证请求头失败: %w", err)
231
+ }
232
+ return nil
233
+ }
234
+
235
+ func (c *httpClient) isBaseURLOrigin(target *url.URL) (bool, error) {
236
+ if c.baseURL == "" {
237
+ return false, nil
238
+ }
239
+ base, err := url.Parse(c.baseURL)
240
+ if err != nil || base.Scheme == "" || base.Host == "" {
241
+ return false, fmt.Errorf("解析 base URL %q 失败", c.baseURL)
242
+ }
243
+ return sameOrigin(base, target), nil
244
+ }
245
+
246
+ func (c *httpClient) checkRedirect(req *http.Request, via []*http.Request) error {
247
+ if len(via) >= 10 {
248
+ return fmt.Errorf("停止重定向:已达到 10 次上限")
249
+ }
250
+ trusted, err := c.isBaseURLOrigin(req.URL)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ initialTrusted, err := c.isBaseURLOrigin(via[0].URL)
255
+ if err != nil {
256
+ return err
257
+ }
258
+ if initialTrusted && !trusted {
259
+ // API requests may contain unreleased canvas data or upload metadata.
260
+ // Stripping credentials is insufficient because Go can preserve the
261
+ // method and body for 307/308 redirects. API calls have no valid reason
262
+ // to leave the configured origin, so reject the redirect entirely.
263
+ return fmt.Errorf("拒绝 Pippit API 跨域重定向到 %s", req.URL.Redacted())
264
+ }
265
+ if trusted {
266
+ // net/http may rebuild redirect headers from the original request. Once a
267
+ // chain has crossed an untrusted origin, never restore protected headers
268
+ // even if a later hop points back at the API origin.
269
+ for _, previous := range via {
270
+ previousTrusted, err := c.isBaseURLOrigin(previous.URL)
271
+ if err != nil {
272
+ return err
273
+ }
274
+ if !previousTrusted {
275
+ trusted = false
276
+ break
277
+ }
278
+ }
279
+ }
280
+ if !trusted {
281
+ req.Header.Del("Authorization")
282
+ }
283
+ return nil
284
+ }
285
+
286
+ func sameOrigin(a, b *url.URL) bool {
287
+ return strings.EqualFold(a.Scheme, b.Scheme) &&
288
+ strings.EqualFold(a.Hostname(), b.Hostname()) &&
289
+ effectivePort(a) == effectivePort(b)
290
+ }
291
+
292
+ func effectivePort(u *url.URL) string {
293
+ if port := u.Port(); port != "" {
294
+ return port
295
+ }
296
+ switch strings.ToLower(u.Scheme) {
297
+ case "http":
298
+ return "80"
299
+ case "https":
300
+ return "443"
301
+ default:
302
+ return ""
303
+ }
304
+ }
305
+
215
306
  func (c *httpClient) do(req *http.Request, out any) error {
216
307
  resp, err := c.httpClient.Do(req)
217
308
  if err != nil {
@@ -240,7 +331,14 @@ func (c *httpClient) do(req *http.Request, out any) error {
240
331
  }
241
332
 
242
333
  func (c *httpClient) resolveURL(path string, query map[string]string) (string, error) {
243
- if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
334
+ parsed, err := url.Parse(path)
335
+ if err != nil {
336
+ return "", fmt.Errorf("解析 URL 失败: %w", err)
337
+ }
338
+ if parsed.IsAbs() {
339
+ if parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
340
+ return "", fmt.Errorf("仅支持 http 或 https URL: %q", path)
341
+ }
244
342
  return appendQuery(path, query)
245
343
  }
246
344
  if c.baseURL == "" {