//go:build linux || freebsd // +build linux freebsd package http import ( "bytes" "context" "crypto/tls" "fmt" "io" "mime/multipart" "net/http" "net/http/httptrace" "net/url" "os" "path/filepath" "strings" "time" "codeberg.org/petrbalvin/goget/internal/archive" "codeberg.org/petrbalvin/goget/internal/auth" "codeberg.org/petrbalvin/goget/internal/compression" "codeberg.org/petrbalvin/goget/internal/cookie" "codeberg.org/petrbalvin/goget/internal/core" format "codeberg.org/petrbalvin/goget/internal/format" "codeberg.org/petrbalvin/goget/internal/hsts" "codeberg.org/petrbalvin/goget/internal/mirror" "codeberg.org/petrbalvin/goget/internal/output" "codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/recursive" "codeberg.org/petrbalvin/goget/internal/transport" ) type Client struct { protocol.BaseProtocol httpClient *http.Client transport *http.Transport dialer *transport.Dialer config *Config } type Config struct { Transport TransportConfig Auth AuthConfig Recursive RecursiveConfig Output OutputConfig // HTTP request construction UserAgent string Headers map[string]string CustomHeaders map[string]string PostData string PostFile string FormData []string Range string Compressed bool Referer string FailOnHTTPError bool Spider bool // Download mechanics Parallel *core.ParallelConfig RateLimiter *transport.TokenBucket RateLimit int64 // Dependencies CookieJar *cookie.Jar } func DefaultConfig() *Config { return &Config{ Transport: TransportConfig{ Timeout: 30 * time.Minute, BufferSize: 32 * 1024, FollowRedirects: true, MaxRedirects: 10, TLS: transport.DefaultTLSConfig(), Dialer: transport.DefaultDialerConfig(), Retry: transport.DefaultRetryConfig(), }, Auth: AuthConfig{ AuthType: "auto", }, Recursive: RecursiveConfig{ MaxDepth: 3, ExcludePatterns: []string{}, MirrorAssets: true, ConvertLinks: true, RespectRobots: true, }, Output: OutputConfig{ OutputDir: ".", }, UserAgent: core.Name + "/" + core.Version, Headers: make(map[string]string), Parallel: core.DefaultParallelConfig(), } } func NewClient(cfg *Config) (*Client, error) { if cfg == nil { cfg = DefaultConfig() } if cfg.Transport.Dialer == nil { cfg.Transport.Dialer = transport.DefaultDialerConfig() } if cfg.Transport.BindInterface != "" { cfg.Transport.Dialer.BindInterface = cfg.Transport.BindInterface } if cfg.Transport.BlockPrivateIPs { cfg.Transport.Dialer.BlockPrivateIPs = true } dialer := transport.NewDialer(cfg.Transport.Dialer) if len(cfg.Transport.DNSServers) > 0 { dialer.SetCustomDNS(cfg.Transport.DNSServers) } tlsCfg, err := cfg.Transport.TLS.ToTLSConfig() if err != nil { return nil, fmt.Errorf("failed to create tls config: %w", err) } httpTransport := &http.Transport{ DialContext: dialer.DialContext, TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment, DisableCompression: true, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } if cfg.Transport.Proxy != nil { proxyTransport, err := transport.NewProxyTransport(cfg.Transport.Proxy, httpTransport) if err != nil { return nil, fmt.Errorf("failed to configure proxy: %w", err) } httpTransport = proxyTransport } httpTransport.ForceAttemptHTTP2 = true httpClient := &http.Client{ Transport: httpTransport, Timeout: cfg.Transport.Timeout, } if cfg.CookieJar != nil { httpClient.Jar = cfg.CookieJar } if !cfg.Transport.FollowRedirects { httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } } else { maxRedirects := cfg.Transport.MaxRedirects if maxRedirects <= 0 { maxRedirects = 50 // unlimited redirects, capped for safety } httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } return nil } } if cfg.Transport.MaxRetries > 0 { cfg.Transport.Retry.MaxRetries = cfg.Transport.MaxRetries } if cfg.Transport.RetryDelay > 0 { cfg.Transport.Retry.InitialDelay = cfg.Transport.RetryDelay } client := &Client{ BaseProtocol: *protocol.NewBaseProtocol(protocol.ProtocolInfo{ Name: "HTTP/HTTPS", Scheme: "http", Version: "1.1/2.0", Operations: []string{"download", "upload", "head", "recursive"}, Features: []string{"resume", "compression", "redirects", "tls", "parallel", "auth", "cookies", "recursive", "extract"}, DefaultPort: 80, }), httpClient: httpClient, transport: httpTransport, dialer: dialer, config: cfg, } return client, nil } func (c *Client) Scheme() string { return "http" } func (c *Client) CanHandle(u *url.URL) bool { return u != nil && (u.Scheme == "http" || u.Scheme == "https") } func (c *Client) Capabilities() []string { return []string{"download", "resume", "compression", "redirects", "tls", "proxy", "parallel", "auth", "cookies", "recursive", "extract"} } func (c *Client) SupportsResume() bool { return true } func (c *Client) SupportsCompression() bool { return true } func (c *Client) SupportsParallel() bool { return true } func (c *Client) SupportsRecursive() bool { return true } func (c *Client) SetHeader(key, value string) { c.config.Headers[key] = value } // unchanged - flat field func (c *Client) GetTransport() *http.Transport { return c.transport } // SetHSTSCache sets the HSTS cache for automatic HTTPS upgrades. func (c *Client) SetHSTSCache(cache *hsts.Cache) { c.config.Transport.HSTSCache = cache } func (c *Client) updateHSTS(resp *http.Response) { if c.config.Transport.HSTSCache != nil && resp != nil { c.config.Transport.HSTSCache.Update(resp) } } func (c *Client) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { if !c.CanHandle(req.URL) { return nil, core.NewProtocolError("url not supported by http protocol", nil, req.URL.String()) } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[http] Download() called: url=%s, output=%s, recursive=%v, mirror=%v, extract=%v\n", req.URL.String(), req.Output, req.Recursive, c.config.Recursive.Mirror, c.config.Output.AutoExtract) } // HSTS: upgrade http to https if host is known if c.config.Transport.HSTSCache != nil { if c.config.Transport.HSTSCache.Apply(req.URL) && c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[hsts] upgraded to %s\n", req.URL.String()) } } // Mirror mode takes precedence if c.config.Recursive.Mirror { return c.downloadMirror(ctx, req) } if req.Recursive { return c.downloadRecursive(ctx, req) } result, err := c.downloadSingle(ctx, req) if err != nil { return nil, err } // Auto-extract after successful download if c.config.Output.AutoExtract && req.Output != "" && req.Output != "-" && archive.IsArchiveFormat(req.Output) { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[auto-extract] Detected archive: %s\n", req.Output) } extractDir := c.config.Output.ExtractDir if extractDir == "" { extractDir = req.Output + ".extracted" } if err := os.MkdirAll(extractDir, 0755); err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[auto-extract] Warning: failed to create extract dir: %v\n", err) } } else { // Open the downloaded file for extraction srcFile, err := os.Open(req.Output) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[auto-extract] Warning: failed to open archive: %v\n", err) } } else { // Auto-detect format and extract if err := archive.AutoExtract(req.Output, srcFile, extractDir); err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[auto-extract] Warning: failed to extract: %v\n", err) } } else if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[auto-extract] Extracted to: %s\n", extractDir) // List extracted files for verification if entries, err := os.ReadDir(extractDir); err == nil { for _, entry := range entries { fmt.Fprintf(os.Stderr, "[auto-extract] - %s\n", entry.Name()) } } } srcFile.Close() } } } return result, nil } func (c *Client) downloadRecursive(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { startTime := time.Now() outputDir := c.config.Output.OutputDir if outputDir == "" { outputDir = "." } if req.Output != "" && req.Output != "-" && !req.Recursive { outputDir = filepath.Dir(req.Output) } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[recursive] outputDir=%s, url=%s\n", outputDir, req.URL.String()) } // --recursive-parallel takes precedence over --parallel when set; both // ultimately control the Crawler worker count, but the recursive variant // is the user-facing knob for recursive downloads. crawlerParallel := c.config.Recursive.Parallel if crawlerParallel <= 0 { crawlerParallel = c.config.Parallel.Connections } crawlerCfg := &recursive.CrawlerConfig{ MaxDepth: c.config.Recursive.MaxDepth, FollowExternal: c.config.Recursive.FollowExternal, ExcludePatterns: c.config.Recursive.ExcludePatterns, IncludePatterns: []string{}, AcceptPatterns: parseAcceptPatterns(c.config.Recursive.Accept), NoParent: c.config.Recursive.NoParent, PageRequisites: c.config.Recursive.PageRequisites, ConvertLinks: c.config.Recursive.ConvertLinks, OutputDir: outputDir, Verbose: c.config.Output.Verbose, Parallel: crawlerParallel, Delay: c.config.Recursive.Wait, RandomWait: c.config.Recursive.RandomWait, UserAgent: c.config.UserAgent, DryRun: c.config.Recursive.DryRun, } if c.config.Recursive.Wait == 0 { crawlerCfg.Delay = 100 * time.Millisecond // default } crawler := recursive.NewCrawler(crawlerCfg, c) stats, err := crawler.Download(ctx, req.URL) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[recursive] error: %v\n", err) } return nil, core.NewNetworkError("recursive download failed", err, req.URL.String()) } duration := time.Since(startTime) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[recursive] done: %d files, %s total\n", stats.DownloadedURLs, format.Bytes(stats.TotalBytes)) } result := &core.DownloadResult{ BytesDownloaded: stats.TotalBytes, TotalSize: stats.TotalBytes, Duration: duration, Protocol: "HTTP/HTTPS (recursive)", IPVersion: 6, Speed: float64(stats.TotalBytes) / duration.Seconds(), OutputPath: outputDir, Resumed: false, Parallel: true, ChunksCount: stats.DownloadedURLs, } return result, nil } func (c *Client) downloadMirror(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { startTime := time.Now() outputDir := c.config.Output.OutputDir if outputDir == "" { outputDir = "." } if req.Output != "" && req.Output != "-" { outputDir = filepath.Dir(req.Output) } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[mirror] outputDir=%s, url=%s, convertLinks=%v, downloadAssets=%v\n", outputDir, req.URL.String(), c.config.Recursive.ConvertLinks, c.config.Recursive.MirrorAssets) } mirrorCfg := &mirror.MirrorConfig{ BaseURL: req.URL, OutputDir: outputDir, MaxDepth: 0, // 0 = infinite for mirror mode FollowExternal: false, ExcludePatterns: c.config.Recursive.ExcludePatterns, IncludePatterns: []string{}, Verbose: c.config.Output.Verbose, Parallel: 8, // More workers for mirror mode Delay: 50 * time.Millisecond, UserAgent: c.config.UserAgent, ConvertLinks: c.config.Recursive.ConvertLinks, DownloadAssets: c.config.Recursive.MirrorAssets, PruneEmpty: true, RestrictFiles: true, Timeout: c.config.Transport.Timeout, RespectRobots: c.config.Recursive.RespectRobots, DryRun: c.config.Recursive.DryRun, } // Add rate limiter if configured if c.config.RateLimit > 0 { mirrorCfg.RateLimiter = transport.NewTokenBucket(c.config.RateLimit, c.config.RateLimit) } m := mirror.NewMirror(mirrorCfg, c) stats, err := m.Download(ctx) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[mirror] error: %v\n", err) } return nil, core.NewNetworkError("mirror download failed", err, req.URL.String()) } duration := time.Since(startTime) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[mirror] done: %d files, %s total, %d links converted\n", stats.DownloadedURLs, format.Bytes(stats.TotalBytes), stats.ConvertedLinks) } result := &core.DownloadResult{ BytesDownloaded: stats.TotalBytes, TotalSize: stats.TotalBytes, Duration: duration, Protocol: "HTTP/HTTPS (mirror)", IPVersion: 6, Speed: float64(stats.TotalBytes) / duration.Seconds(), OutputPath: outputDir, Resumed: false, Parallel: true, ChunksCount: int(stats.DownloadedURLs), } return result, nil } func (c *Client) downloadSingle(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { startTime := time.Now() if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] downloadSingle() start: url=%s, output=%s\n", req.URL.String(), req.Output) } username, password := auth.GetAuthForURL("", "", c.config.Auth.Username, c.config.Auth.Password, req.URL) headReq, err := http.NewRequestWithContext(ctx, "HEAD", req.URL.String(), nil) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] HEAD request error: %v\n", err) } return nil, core.NewNetworkError("failed to create HEAD request", err, req.URL.String()) } headReq.Header.Set("User-Agent", c.config.UserAgent) if username != "" { auth.BasicAuth(headReq, username, password) } headResp, err := c.httpClient.Do(headReq) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] HEAD request failed: %v\n", err) } } else { defer headResp.Body.Close() c.updateHSTS(headResp) } totalSize := int64(-1) supportsRange := false etag := "" lastModified := "" if headResp != nil && headResp.StatusCode == http.StatusOK { totalSize = headResp.ContentLength supportsRange = strings.ToLower(headResp.Header.Get("Accept-Ranges")) == "bytes" etag = headResp.Header.Get("ETag") lastModified = headResp.Header.Get("Last-Modified") // Content-Disposition: use server-provided filename if c.config.Output.ContentDisposition && req.Output == "" { if cd := headResp.Header.Get("Content-Disposition"); cd != "" { if filename := ParseContentDisposition(cd); filename != "" { safe := SanitizeFilename(filename) if safe != "" { req.Output = safe if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] Content-Disposition: filename=%s\n", safe) } } } } } // Show headers if c.config.Output.ShowHeaders { fmt.Fprintf(os.Stderr, "\n--- Response Headers ---\n") fmt.Fprintf(os.Stderr, "HTTP/%d.%d %d %s\n", headResp.ProtoMajor, headResp.ProtoMinor, headResp.StatusCode, headResp.Status) for key, values := range headResp.Header { for _, value := range values { fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) } } fmt.Fprintf(os.Stderr, "Content-Length: %d\n", totalSize) fmt.Fprintf(os.Stderr, "------------------------\n") } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] HEAD: size=%d, supportsRange=%v\n", totalSize, supportsRange) } } useParallel := req.Parallel != nil && supportsRange && totalSize > 0 && totalSize >= req.Parallel.MinSize connections := 1 if useParallel { connections = req.Parallel.GetConnections(totalSize) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] Using parallel: %d connections\n", connections) } } var resumeChunks map[int]int64 if req.Resume && req.Parallel != nil && useParallel { meta, _ := output.LoadResumeMetadata(req.Output) if meta != nil && meta.Downloaded > 0 { if len(meta.Chunks) > 0 { resumeChunks = meta.Chunks } else { resumeChunks = make(map[int]int64) } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] Resume: %d bytes from metadata (chunks: %d)\n", meta.Downloaded, len(resumeChunks)) } } } var result *core.DownloadResult var downloadErr error if useParallel { result, downloadErr = c.downloadParallel(ctx, req, totalSize, connections, resumeChunks, username, password) } else { result, downloadErr = c.downloadSequential(ctx, req, totalSize, etag, lastModified, username, password) } if downloadErr != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] download error: %v\n", downloadErr) } return nil, downloadErr } result.Duration = time.Since(startTime) result.Speed = float64(result.BytesDownloaded) / result.Duration.Seconds() if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[single] done: %s downloaded, speed=%s/s\n", format.Bytes(result.BytesDownloaded), format.Speed(result.Speed)) } return result, nil } // buildRequestBody builds the request body from form data or POST data configuration. // Returns the HTTP method, body reader, content type, and any error. func (c *Client) buildRequestBody(urlStr string) (method string, body io.Reader, contentType string, err error) { method = "GET" contentType = "" if len(c.config.FormData) > 0 { method = "POST" var buf bytes.Buffer writer := multipart.NewWriter(&buf) for _, field := range c.config.FormData { parts := strings.SplitN(field, "=", 2) name := parts[0] value := "" if len(parts) > 1 { value = parts[1] } if strings.HasPrefix(value, "@") { // File upload: field=@/path/to/file filePath := strings.TrimPrefix(value, "@") f, err := os.Open(filePath) if err != nil { return "", nil, "", core.NewFileError(fmt.Sprintf("form file not found: %s", filePath), err) } part, err := writer.CreateFormFile(name, filepath.Base(filePath)) if err != nil { f.Close() return "", nil, "", core.NewNetworkError("failed to create form file field", err, urlStr) } if _, err := io.Copy(part, f); err != nil { f.Close() return "", nil, "", core.NewNetworkError("failed to copy form file", err, urlStr) } f.Close() } else { // Regular field: field=value if err := writer.WriteField(name, value); err != nil { return "", nil, "", core.NewNetworkError("failed to write form field", err, urlStr) } } } writer.Close() body = &buf contentType = writer.FormDataContentType() } else if c.config.PostData != "" { method = "POST" body = strings.NewReader(c.config.PostData) } return method, body, contentType, nil } // applyRequestAuth applies authentication to the HTTP request based on the client config. // It handles OAuth 2.0 tokens (with auto-refresh), Basic auth, and sets the Range header // for resume. func (c *Client) applyRequestAuth(ctx context.Context, httpReq *http.Request, username, password string, startOffset int64, etag, lastModified string) error { // Determine auth type authType := c.config.Auth.AuthType if authType == "" { authType = "auto" } // Apply OAuth 2.0 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) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Applied OAuth 2.0 token\n") } } else if c.config.Auth.OAuth.TokenURL != "" { // Try to request a new token if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] OAuth token expired, requesting new one...\n") } tokenResp, err := oauthClient.RequestToken(ctx) if err == nil { oauthClient.UpdateToken(tokenResp) oauthClient.ApplyToken(httpReq) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Applied new OAuth 2.0 token\n") } } else if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Failed to request OAuth token: %v\n", err) } } } // For auto/basic, try with credentials first if username != "" && (authType == "auto" || authType == "basic") { auth.BasicAuth(httpReq, username, password) } if startOffset > 0 { httpReq.Header.Set("Range", fmt.Sprintf("bytes=%d-", startOffset)) if etag != "" { httpReq.Header.Set("If-Range", etag) } else if lastModified != "" { httpReq.Header.Set("If-Range", lastModified) } } return nil } // openOutputFile opens the output file for writing, either for append (resume) or create. // When req.Output is "-" or empty with no Writer, it returns os.Stdout. func (c *Client) openOutputFile(req *core.DownloadRequest, startOffset int64) (io.Writer, *os.File, error) { if req.Writer != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Using provided Writer\n") } return req.Writer, nil, nil } if req.Output != "" && req.Output != "-" { var file *os.File var err error if startOffset > 0 { file, err = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Opening file for append: %s\n", req.Output) } } else { if err := os.MkdirAll(filepath.Dir(req.Output), 0755); err != nil { return nil, nil, core.NewFileError("failed to create output directory", err) } file, err = os.Create(req.Output) if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Creating new file: %s\n", req.Output) } } if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Failed to open/create file: %v\n", err) } return nil, nil, core.NewFileError("failed to open output file", err) } return file, file, nil } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Writing to stdout\n") } return os.Stdout, nil, nil } func (c *Client) downloadSequential(ctx context.Context, req *core.DownloadRequest, totalSize int64, etag, lastModified, username, password string) (*core.DownloadResult, error) { startTime := time.Now() resumed := false startOffset := int64(0) if req.Resume && req.Output != "" && req.Output != "-" { canResume, meta, err := output.CanResume(req.Output, req.URL.String()) if err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Resume check failed: %v\n", err) } output.CleanupResumeFiles(req.Output) } else if canResume && meta != nil { startOffset = meta.Downloaded resumed = true if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Resuming from byte %d\n", startOffset) } } } // Max time: apply context deadline if c.config.Transport.MaxTime > 0 { deadlineCtx, deadlineCancel := context.WithTimeout(ctx, c.config.Transport.MaxTime) defer deadlineCancel() ctx = deadlineCtx } // Max file size: check Content-Length from HEAD response if c.config.Transport.MaxFileSize > 0 && totalSize > 0 && totalSize > c.config.Transport.MaxFileSize { return nil, core.NewNetworkError( fmt.Sprintf("file too large: %s (max %s)", format.Bytes(totalSize), format.Bytes(c.config.Transport.MaxFileSize)), nil, core.SafeURL(req.URL)) } // Build request body from form data or POST data method, body, contentType, err := c.buildRequestBody(core.SafeURL(req.URL)) if err != nil { return nil, err } httpReq, err := http.NewRequestWithContext(ctx, method, req.URL.String(), body) if err != nil { return nil, core.NewNetworkError("failed to create request", err, core.SafeURL(req.URL)) } httpReq.Header.Set("User-Agent", c.config.UserAgent) if c.config.Compressed { httpReq.Header.Set("Accept-Encoding", "gzip, deflate") } else { httpReq.Header.Set("Accept-Encoding", "identity") } // Referer header if c.config.Referer != "" { httpReq.Header.Set("Referer", c.config.Referer) } // Range header (partial download) if c.config.Range != "" { httpReq.Header.Set("Range", "bytes="+c.config.Range) } // Set Content-Type for POST data if contentType != "" { httpReq.Header.Set("Content-Type", contentType) } else if method == "POST" { if httpReq.Header.Get("Content-Type") == "" { httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") } } // Apply custom headers (-H/--header) for key, val := range c.config.CustomHeaders { httpReq.Header.Set(key, val) } // Handle authentication based on AuthType authType := c.config.Auth.AuthType if authType == "" { authType = "auto" } c.applyRequestAuth(ctx, httpReq, username, password, startOffset, etag, lastModified) for key, value := range req.Headers { httpReq.Header.Set(key, value) } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Sending GET request to %s (auth=%s)\n", core.SafeURL(req.URL), authType) } // HTTP trace timing // tt is allocated up front (rather than inside the deferred closure // below) because Go evaluates the return-statement expressions before // running deferred functions. If tt were nil when // `return &core.DownloadResult{TraceTimings: tt, ...}` is evaluated, // the caller would receive a nil TraceTimings pointer even though the // defer would then populate tt. Allocating here ensures the return // value captures a non-nil pointer that the defer can fill in place. var tt *core.TraceTimings if c.config.Output.TraceTime { tt = &core.TraceTimings{} // requestStart is captured before the trace is attached so it is // never zero, even on connections that the HTTP client reuses // from its pool (where DNS, TCP and TLS callbacks all stay // silent and dnsStart/tcpStart/tlsStart remain zero-valued). // Without it, TTFB = time.Since(dnsStart) would be the duration // since year 1, i.e. ~55 years, which is plainly bogus. requestStart := time.Now() var ( dnsStart time.Time dnsEnd time.Time tcpStart time.Time tcpEnd time.Time tlsStart time.Time tlsEnd time.Time gotFirstByte time.Time ) trace := &httptrace.ClientTrace{ DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() }, DNSDone: func(_ httptrace.DNSDoneInfo) { dnsEnd = time.Now() }, ConnectStart: func(_, _ string) { tcpStart = time.Now() }, ConnectDone: func(_, _ string, _ error) { tcpEnd = time.Now() }, TLSHandshakeStart: func() { tlsStart = time.Now() }, TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsEnd = time.Now() }, GotFirstResponseByte: func() { gotFirstByte = time.Now() }, } httpReq = httpReq.WithContext(httptrace.WithClientTrace(httpReq.Context(), trace)) defer func() { if !dnsEnd.IsZero() && !dnsStart.IsZero() { tt.DNSLookup = dnsEnd.Sub(dnsStart) } if !tcpEnd.IsZero() && !tcpStart.IsZero() { tt.TCPConnect = tcpEnd.Sub(tcpStart) } if !tlsEnd.IsZero() && !tlsStart.IsZero() { tt.TLSHandshake = tlsEnd.Sub(tlsStart) } if !gotFirstByte.IsZero() { // Always measure TTFB from requestStart, not dnsStart. // On reused connections dnsStart is the zero value and // time.Since(zero) would yield ~55 years. tt.TTFB = gotFirstByte.Sub(requestStart) } tt.Total = tt.DNSLookup + tt.TCPConnect + tt.TLSHandshake + tt.TTFB }() } var resp *http.Response retryErr := transport.Retry(ctx, func(ctx context.Context, attempt int) error { var err error resp, err = c.httpClient.Do(httpReq) return err }, c.config.Transport.Retry) if retryErr != nil { return nil, core.NewNetworkError("request failed after retries", retryErr, core.SafeURL(req.URL)) } defer resp.Body.Close() c.updateHSTS(resp) if c.config.Output.ShowHeaders { fmt.Fprintf(os.Stderr, "\n--- Response Headers ---\n") for key, values := range resp.Header { for _, value := range values { fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) } } fmt.Fprintf(os.Stderr, "------------------------\n") } if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Response: status=%s, content-length=%d\n", resp.Status, resp.ContentLength) } // Handle 401 Unauthorized - try Digest Auth if auto mode if resp.StatusCode == http.StatusUnauthorized && authType == "auto" && username != "" { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Got 401, trying Digest Auth...\n") } // Use auth package's DigestAuthHandler instead of inline logic digestHandler := auth.NewDigestAuthHandler(username, password) authedReq, err := digestHandler.HandleResponse(httpReq, resp) if err != nil { resp.Body.Close() return nil, core.NewNetworkError("digest auth failed", err, core.SafeURL(req.URL)) } // The old body was consumed by HandleResponse; close the original response _ = resp.Body.Close() if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Retrying with Digest Auth\n") } // Retry request with digest auth resp, err = c.httpClient.Do(authedReq) if err != nil { return nil, core.NewNetworkError("digest auth request failed", err, core.SafeURL(req.URL)) } } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { // Retry on any HTTP error if RetryAllErrors is enabled if c.config.Transport.RetryAllErrors { resp.Body.Close() for attempt := 0; attempt < c.config.Transport.Retry.MaxRetries; attempt++ { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Retrying on HTTP %d (attempt %d/%d)\n", resp.StatusCode, attempt+1, c.config.Transport.Retry.MaxRetries) } time.Sleep(c.config.Transport.Retry.InitialDelay) resp, err = c.httpClient.Do(httpReq) if err != nil { // http.Client.Do may return a non-nil resp alongside an // error (e.g. read error after the response was received). // Close the body so the connection can be returned to the // pool, otherwise we leak connections on every failed retry. if resp != nil { resp.Body.Close() } continue } if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent { goto okStatus } resp.Body.Close() } } return nil, core.NewProtocolError( fmt.Sprintf("HTTP error: %s", resp.Status), nil, core.SafeURL(req.URL), ) } okStatus: if resp.StatusCode == http.StatusPartialContent && startOffset == 0 { return nil, core.NewProtocolError("unexpected partial content", nil, core.SafeURL(req.URL)) } writer, file, err := c.openOutputFile(req, startOffset) if err != nil { return nil, err } if file != nil { defer file.Close() } var reader io.Reader = resp.Body if c.config.RateLimiter != nil { reader = c.config.RateLimiter.WrapReader(reader) } // Decompression if req.AutoDecompress && startOffset == 0 { contentEncoding := resp.Header.Get("Content-Encoding") compressionType := compression.GetCompressionFromHeader(contentEncoding) if compressionType != "identity" { decompressor, ok := compression.Get(compressionType) if ok { wrappedReader, err := decompressor.Reader(resp.Body) if err == nil { reader = wrappedReader if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Decompressing with %s\n", compressionType) } } } } } if req.ProgressCallback != nil { reader = &progressReader{ r: reader, callback: req.ProgressCallback, total: totalSize, start: startTime, offset: startOffset, } } buf := make([]byte, c.config.Transport.BufferSize) var bytesDownloaded int64 = 0 for { if ctx.Err() != nil { if req.Resume && req.Output != "" && bytesDownloaded > 0 { meta := output.NewResumeMetadata( req.URL.String(), resp.Header.Get("ETag"), resp.Header.Get("Last-Modified"), startOffset+bytesDownloaded, totalSize, ) meta.Save(req.Output) } return nil, ctx.Err() } n, err := reader.Read(buf) if n > 0 { _, wErr := writer.Write(buf[:n]) if wErr != nil { return nil, core.NewFileError("failed to write data", wErr) } bytesDownloaded += int64(n) } if err == io.EOF { break } if err != nil { return nil, core.NewNetworkError("failed to read response body", err, core.SafeURL(req.URL)) } } if req.Output != "" && req.Output != "-" { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[seq] Deleting resume metadata for %s\n", req.Output) } output.DeleteResumeMetadata(req.Output) } if c.config.Output.KeepTimestamps && req.Output != "" && req.Output != "-" { lastModified := resp.Header.Get("Last-Modified") if lastModified != "" { modTime, err := time.Parse(http.TimeFormat, lastModified) if err == nil { if err := os.Chtimes(req.Output, time.Now(), modTime); err != nil { if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[timestamps] failed to set modtime: %v\n", err) } } else if c.config.Output.Verbose { fmt.Fprintf(os.Stderr, "[timestamps] set modtime to %s\n", modTime.Format(time.RFC3339)) } } } } // --fail: treat HTTP 4xx/5xx as download failure if c.config.FailOnHTTPError && resp.StatusCode >= 400 { return nil, core.NewProtocolError( fmt.Sprintf("HTTP %d %s", resp.StatusCode, resp.Status), nil, core.SafeURL(req.URL)) } duration := time.Since(startTime) speed := float64(0) if duration.Seconds() > 0 { speed = float64(bytesDownloaded) / duration.Seconds() } return &core.DownloadResult{ BytesDownloaded: startOffset + bytesDownloaded, TotalSize: totalSize, Duration: duration, Speed: speed, Protocol: "HTTP/HTTPS", IPVersion: 6, OutputPath: req.Output, Resumed: resumed, Parallel: false, HTTPStatusCode: resp.StatusCode, TraceTimings: tt, }, nil } type progressReader struct { r io.Reader callback func(current, total int64, speed float64) total int64 read int64 start time.Time offset int64 } func (pr *progressReader) Read(p []byte) (int, error) { n, err := pr.r.Read(p) if n > 0 && pr.callback != nil { pr.read += int64(n) duration := time.Since(pr.start) speed := float64(pr.offset+pr.read) / duration.Seconds() pr.callback(pr.offset+pr.read, pr.total, speed) } return n, err } // parseAcceptPatterns parses comma-separated accept patterns into a slice. func parseAcceptPatterns(s string) []string { if s == "" { return nil } var patterns []string for _, p := range strings.Split(s, ",") { p = strings.TrimSpace(p) if p != "" { patterns = append(patterns, p) } } return patterns }