@pippit-dev/cli 1.0.16 → 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.
- package/README.md +24 -3
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +235 -0
- package/cmd/canvas/canvas_test.go +126 -0
- package/cmd/generate_video/generate_video.go +1 -1
- package/cmd/root.go +29 -4
- package/cmd/root_test.go +60 -0
- package/cmd/short_drama_test.go +4 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +203 -0
- package/internal/canvas/canvas_test.go +446 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +0 -20
- package/internal/config/config_test.go +0 -18
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +17 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
28
|
-
req.Header.Set("Authorization", "Bearer "+
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
89
|
-
return
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 == "" {
|