102 lines
3.3 KiB
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)
|
|
}
|