Files
dingtalk-workspace-cli/internal/localio/upload.go

102 lines
3.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
const defaultUploadLimit = int64(5 << 30)
var (
newUploadHTTPClient = secureHTTPClient
validateUploadURL = func(raw string) error {
_, err := ValidateDownloadURL(raw)
return err
}
openUploadFile = os.Open
newUploadRequest = http.NewRequestWithContext
)
// UploadResult records only non-sensitive transfer facts. The signed URL is
// deliberately never returned.
type UploadResult struct {
SizeBytes int64
Attempts int
}
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
// a second origin. Transient failures reuse the same verified open file so a
// path replacement cannot change the bytes sent by a later attempt.
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
client := newUploadHTTPClient()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
}
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
if validate == nil {
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
}
if err := validate(rawURL); err != nil {
return UploadResult{}, err
}
if maxBytes <= 0 {
maxBytes = defaultUploadLimit
}
file, err := openUploadFile(path)
if err != nil {
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
}
result := UploadResult{SizeBytes: info.Size()}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
result.Attempts = attempt
reader := io.NewSectionReader(file, 0, info.Size())
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
if requestErr != nil {
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
}
request.ContentLength = info.Size()
response, callErr := client.Do(request)
if callErr == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
bodyCloseErr := response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
return result, nil
}
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
}
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
} else {
lastErr = callErr
}
if attempt < 3 {
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return result, ctx.Err()
case <-timer.C:
}
}
}
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
}