380 lines
8.8 KiB
Go
380 lines
8.8 KiB
Go
//go:build linux || freebsd
|
|||
|
|
// +build linux freebsd
|
||
|
|
|
||
|
|
package http
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"mime/multipart"
|
||
|
|
"net/http"
|
||
|
|
"net/textproto"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"codeberg.org/petrbalvin/goget/internal/auth"
|
||
|
|
format "codeberg.org/petrbalvin/goget/internal/format"
|
||
|
|
)
|
||
|
|
|
||
|
|
// UploadRequest represents an upload request
|
||
|
|
type UploadRequest struct {
|
||
|
|
// URL to upload to
|
||
|
|
URL string
|
||
|
|
|
||
|
|
// Method: PUT or POST
|
||
|
|
Method string
|
||
|
|
|
||
|
|
// Path to the file for upload
|
||
|
|
FilePath string
|
||
|
|
|
||
|
|
// Form data fields (for POST)
|
||
|
|
FormData map[string]string
|
||
|
|
|
||
|
|
// Files for upload (for POST multipart)
|
||
|
|
Files []UploadFile
|
||
|
|
|
||
|
|
// Custom headers
|
||
|
|
Headers map[string]string
|
||
|
|
|
||
|
|
// Timeout
|
||
|
|
Timeout time.Duration
|
||
|
|
|
||
|
|
// Callback pro progress
|
||
|
|
ProgressCallback func(current, total int64, speed float64)
|
||
|
|
}
|
||
|
|
|
||
|
|
// UploadFile represents a file for multipart upload
|
||
|
|
type UploadFile struct {
|
||
|
|
// Field name in the form data
|
||
|
|
FieldName string
|
||
|
|
|
||
|
|
// Path to the file
|
||
|
|
FilePath string
|
||
|
|
|
||
|
|
// Custom file name (optional)
|
||
|
|
FileName string
|
||
|
|
|
||
|
|
// Content-Type (optional)
|
||
|
|
ContentType string
|
||
|
|
}
|
||
|
|
|
||
|
|
// UploadResult is the result of an upload
|
||
|
|
type UploadResult struct {
|
||
|
|
StatusCode int
|
||
|
|
ContentLength int64
|
||
|
|
Duration time.Duration
|
||
|
|
BytesUploaded int64
|
||
|
|
Speed float64
|
||
|
|
ResponseBody string
|
||
|
|
}
|
||
|
|
|
||
|
|
// Upload uploads a file using PUT or POST
|
||
|
|
func (c *Client) Upload(ctx context.Context, req *UploadRequest) (*UploadResult, error) {
|
||
|
|
startTime := time.Now()
|
||
|
|
|
||
|
|
if req.Method == "" {
|
||
|
|
req.Method = "PUT"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create request body
|
||
|
|
var body io.Reader
|
||
|
|
var contentType string
|
||
|
|
var totalSize int64
|
||
|
|
|
||
|
|
if req.Method == "PUT" {
|
||
|
|
// PUT upload - direct file
|
||
|
|
if req.FilePath == "" {
|
||
|
|
return nil, fmt.Errorf("put upload requires --upload-file")
|
||
|
|
}
|
||
|
|
|
||
|
|
fileInfo, err := os.Stat(req.FilePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||
|
|
}
|
||
|
|
totalSize = fileInfo.Size()
|
||
|
|
|
||
|
|
file, err := os.Open(req.FilePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
body = file
|
||
|
|
contentType = "application/octet-stream"
|
||
|
|
|
||
|
|
} else if req.Method == "POST" {
|
||
|
|
// POST upload - form data or multipart
|
||
|
|
if len(req.Files) > 0 {
|
||
|
|
// Multipart form data
|
||
|
|
var bodyBuffer bytes.Buffer
|
||
|
|
writer := multipart.NewWriter(&bodyBuffer)
|
||
|
|
|
||
|
|
// Add regular form fields
|
||
|
|
for key, value := range req.FormData {
|
||
|
|
if err := writer.WriteField(key, value); err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to write form field: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add files
|
||
|
|
for _, uf := range req.Files {
|
||
|
|
file, err := os.Open(uf.FilePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to open file %s: %w", uf.FilePath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
fileName := uf.FileName
|
||
|
|
if fileName == "" {
|
||
|
|
fileName = filepath.Base(uf.FilePath)
|
||
|
|
}
|
||
|
|
|
||
|
|
contentType := uf.ContentType
|
||
|
|
if contentType == "" {
|
||
|
|
contentType = "application/octet-stream"
|
||
|
|
}
|
||
|
|
|
||
|
|
h := make(textproto.MIMEHeader)
|
||
|
|
h.Set("Content-Disposition",
|
||
|
|
fmt.Sprintf(`form-data; name="%s"; filename="%s"`, uf.FieldName, fileName))
|
||
|
|
h.Set("Content-Type", contentType)
|
||
|
|
|
||
|
|
part, err := writer.CreatePart(h)
|
||
|
|
if err != nil {
|
||
|
|
file.Close()
|
||
|
|
return nil, fmt.Errorf("failed to create form part: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
fileInfo, _ := os.Stat(uf.FilePath)
|
||
|
|
totalSize += fileInfo.Size()
|
||
|
|
|
||
|
|
_, err = io.Copy(part, file)
|
||
|
|
file.Close()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to write file to form: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := writer.Close(); err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
body = &bodyBuffer
|
||
|
|
contentType = writer.FormDataContentType()
|
||
|
|
|
||
|
|
} else if len(req.FormData) > 0 {
|
||
|
|
// URL-encoded form data
|
||
|
|
form := url.Values{}
|
||
|
|
for key, value := range req.FormData {
|
||
|
|
form.Set(key, value)
|
||
|
|
}
|
||
|
|
|
||
|
|
body = strings.NewReader(form.Encode())
|
||
|
|
contentType = "application/x-www-form-urlencoded"
|
||
|
|
totalSize = int64(len(form.Encode()))
|
||
|
|
|
||
|
|
} else if req.FilePath != "" {
|
||
|
|
// POST with raw file body
|
||
|
|
fileInfo, err := os.Stat(req.FilePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||
|
|
}
|
||
|
|
totalSize = fileInfo.Size()
|
||
|
|
|
||
|
|
file, err := os.Open(req.FilePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
body = file
|
||
|
|
contentType = "application/octet-stream"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if body == nil {
|
||
|
|
return nil, fmt.Errorf("no upload data provided")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create HTTP request
|
||
|
|
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set headers
|
||
|
|
httpReq.Header.Set("Content-Type", contentType)
|
||
|
|
httpReq.Header.Set("User-Agent", c.config.UserAgent)
|
||
|
|
|
||
|
|
for key, value := range req.Headers {
|
||
|
|
httpReq.Header.Set(key, value)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply authentication
|
||
|
|
if c.config.Auth.Username != "" {
|
||
|
|
authType := c.config.Auth.AuthType
|
||
|
|
if authType == "" {
|
||
|
|
authType = "auto"
|
||
|
|
}
|
||
|
|
|
||
|
|
if authType == "auto" || authType == "basic" {
|
||
|
|
// Try basic auth first
|
||
|
|
applyBasicAuth(httpReq, c.config.Auth.Username, c.config.Auth.Password)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply OAuth token if configured
|
||
|
|
if c.config.Auth.OAuth != nil && c.config.Auth.OAuth.AccessToken != "" {
|
||
|
|
oauthClient := auth.NewOAuthClient(c.config.Auth.OAuth)
|
||
|
|
if oauthClient.IsTokenValid() {
|
||
|
|
oauthClient.ApplyToken(httpReq)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Execute request
|
||
|
|
|
||
|
|
if c.config.RateLimiter != nil {
|
||
|
|
// Wrap body with rate limiter for upload
|
||
|
|
body = c.config.RateLimiter.WrapReader(body)
|
||
|
|
httpReq.Body = io.NopCloser(body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Track progress
|
||
|
|
if req.ProgressCallback != nil && totalSize > 0 {
|
||
|
|
body = &progressReader{
|
||
|
|
r: body,
|
||
|
|
callback: req.ProgressCallback,
|
||
|
|
total: totalSize,
|
||
|
|
start: startTime,
|
||
|
|
offset: 0,
|
||
|
|
}
|
||
|
|
httpReq.Body = io.NopCloser(body)
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := c.httpClient.Do(httpReq)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("upload request failed: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
// Read response body
|
||
|
|
respBody, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
duration := time.Since(startTime)
|
||
|
|
|
||
|
|
return &UploadResult{
|
||
|
|
StatusCode: resp.StatusCode,
|
||
|
|
ContentLength: resp.ContentLength,
|
||
|
|
Duration: duration,
|
||
|
|
BytesUploaded: totalSize,
|
||
|
|
Speed: float64(totalSize) / duration.Seconds(),
|
||
|
|
ResponseBody: string(respBody),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UploadSimple performs a simple file upload with progress tracking
|
||
|
|
func (c *Client) UploadSimple(ctx context.Context, method, targetURL, filePath string, verbose bool) (*UploadResult, error) {
|
||
|
|
fileInfo, err := os.Stat(filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
totalSize := fileInfo.Size()
|
||
|
|
_ = totalSize // Used in progress callback
|
||
|
|
|
||
|
|
file, err := os.Open(filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
var progressCallback func(int64, int64, float64)
|
||
|
|
if verbose {
|
||
|
|
progressCallback = func(current, total int64, speed float64) {
|
||
|
|
percent := float64(current) / float64(total) * 100
|
||
|
|
fmt.Fprintf(os.Stderr, "\r[upload] %s/%s (%.1f%%) %s/s",
|
||
|
|
format.Bytes(current),
|
||
|
|
format.Bytes(total),
|
||
|
|
percent,
|
||
|
|
format.Speed(speed))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &UploadRequest{
|
||
|
|
URL: targetURL,
|
||
|
|
Method: method,
|
||
|
|
FilePath: filePath,
|
||
|
|
Timeout: c.config.Transport.Timeout,
|
||
|
|
ProgressCallback: progressCallback,
|
||
|
|
Headers: make(map[string]string),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy headers from client config
|
||
|
|
for k, v := range c.config.Headers {
|
||
|
|
req.Headers[k] = v
|
||
|
|
}
|
||
|
|
|
||
|
|
result, err := c.Upload(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UploadMultipart performs a multipart POST upload with files
|
||
|
|
func (c *Client) UploadMultipart(ctx context.Context, targetURL string, files []UploadFile, formData map[string]string, verbose bool) (*UploadResult, error) {
|
||
|
|
var progressCallback func(int64, int64, float64)
|
||
|
|
if verbose {
|
||
|
|
progressCallback = func(current, total int64, speed float64) {
|
||
|
|
percent := float64(current) / float64(total) * 100
|
||
|
|
fmt.Fprintf(os.Stderr, "\r[upload] %s/%s (%.1f%%) %s/s",
|
||
|
|
format.Bytes(current),
|
||
|
|
format.Bytes(total),
|
||
|
|
percent,
|
||
|
|
format.Speed(speed))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &UploadRequest{
|
||
|
|
URL: targetURL,
|
||
|
|
Method: "POST",
|
||
|
|
Files: files,
|
||
|
|
FormData: formData,
|
||
|
|
Timeout: c.config.Transport.Timeout,
|
||
|
|
ProgressCallback: progressCallback,
|
||
|
|
Headers: make(map[string]string),
|
||
|
|
}
|
||
|
|
|
||
|
|
for k, v := range c.config.Headers {
|
||
|
|
req.Headers[k] = v
|
||
|
|
}
|
||
|
|
|
||
|
|
result, err := c.Upload(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// applyBasicAuth applies HTTP Basic Authentication to request
|
||
|
|
func applyBasicAuth(req *http.Request, username, password string) {
|
||
|
|
req.SetBasicAuth(username, password)
|
||
|
|
}
|