9c9576b5b9
ci / test (macos-latest) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / lint (push) Has been cancelled
ci / govulncheck (push) Has been cancelled
ci / tidy (push) Has been cancelled
ci / webview (push) Has been cancelled
ci / windows-gui (push) Has been cancelled
release / check (push) Has been cancelled
release / release (push) Has been cancelled
Docs / build (push) Has been cancelled
Docs / deploy-pages (push) Has been cancelled
Docs / deploy-cloudflare (push) Has been cancelled
186 lines
5.5 KiB
Go
186 lines
5.5 KiB
Go
package asset
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Downloader fetches asset bytes over plain HTTP. It is separate from the Chrome
|
|
// pool: assets are public bytes that rarely need a real browser, so a fast HTTP
|
|
// client keeps the crawl cheap. Failures are returned to the caller, which logs
|
|
// them and moves on — a missing asset degrades a page, it never aborts a clone.
|
|
type Downloader struct {
|
|
Client *http.Client
|
|
UserAgent string
|
|
MaxBytes int64 // per-asset cap; 0 = unlimited
|
|
Retries int // extra attempts for a transient failure (0 = try once)
|
|
}
|
|
|
|
// NewDownloader builds a Downloader with a sane client and the given timeout.
|
|
func NewDownloader(userAgent string, timeout time.Duration, maxBytes int64) *Downloader {
|
|
return &Downloader{
|
|
Client: &http.Client{Timeout: timeout},
|
|
UserAgent: userAgent,
|
|
MaxBytes: maxBytes,
|
|
// A few sites (and the bot-protection in front of them) reject the first
|
|
// request of a burst with a 403 or 429 but serve a retry fine, so give
|
|
// transient failures a couple of extra tries before giving up.
|
|
Retries: 3,
|
|
}
|
|
}
|
|
|
|
// Result is a downloaded asset.
|
|
type Result struct {
|
|
Body []byte
|
|
ContentType string
|
|
IsCSS bool
|
|
}
|
|
|
|
// ErrTooLarge reports that an asset exceeds the size cap and was skipped without
|
|
// being saved. It is deliberately a skip, not a download failure: the caller
|
|
// leaves the asset out of the mirror rather than writing a truncated fragment of
|
|
// it, so a 500 MB installer or video never bloats the archive with a corrupt
|
|
// quarter of itself.
|
|
var ErrTooLarge = errors.New("asset over size cap")
|
|
|
|
// StatusError reports a non-2xx HTTP response. It carries the code so callers
|
|
// can render a clear message ("HTTP 403 Forbidden") and decide whether a retry
|
|
// is worthwhile, without the URL baked in (the caller already has it).
|
|
type StatusError struct {
|
|
Code int
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if t := http.StatusText(e.Code); t != "" {
|
|
return fmt.Sprintf("HTTP %d %s", e.Code, t)
|
|
}
|
|
return fmt.Sprintf("HTTP %d", e.Code)
|
|
}
|
|
|
|
// Get fetches u, sending referer as the Referer header. It reads at most
|
|
// MaxBytes and reports whether the body is CSS (so the caller can rewrite it).
|
|
// A transient failure (a 403/429/5xx or a network blip) is retried with a short
|
|
// backoff up to Retries times.
|
|
func (d *Downloader) Get(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
attempts := d.Retries + 1
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
var lastErr error
|
|
for i := 0; i < attempts; i++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff(i)):
|
|
}
|
|
}
|
|
res, err := d.try(ctx, u, referer)
|
|
if err == nil {
|
|
return res, nil
|
|
}
|
|
lastErr = err
|
|
if !transient(err) {
|
|
break
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
// try performs a single fetch attempt.
|
|
func (d *Downloader) try(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.UserAgent != "" {
|
|
req.Header.Set("User-Agent", d.UserAgent)
|
|
}
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
resp, err := d.Client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, &StatusError{Code: resp.StatusCode}
|
|
}
|
|
// Skip an over-cap asset instead of truncating it. A Content-Length lets us
|
|
// bail before reading a byte; otherwise we read one byte past the cap and, if
|
|
// the body really is larger, discard what we have. Either way nothing partial
|
|
// reaches disk.
|
|
if d.MaxBytes > 0 && resp.ContentLength > d.MaxBytes {
|
|
return nil, ErrTooLarge
|
|
}
|
|
var r io.Reader = resp.Body
|
|
if d.MaxBytes > 0 {
|
|
// Read at most one byte past the cap so a body with no (or a lying)
|
|
// Content-Length cannot stream gigabytes into memory before we notice.
|
|
r = io.LimitReader(resp.Body, d.MaxBytes+1)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.MaxBytes > 0 && int64(len(body)) > d.MaxBytes {
|
|
return nil, ErrTooLarge
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
return &Result{
|
|
Body: body,
|
|
ContentType: ct,
|
|
IsCSS: isCSS(ct, u),
|
|
}, nil
|
|
}
|
|
|
|
// backoff returns the pause before retry attempt i (1-based): 500ms, 1s, 2s, …
|
|
func backoff(i int) time.Duration {
|
|
d := 500 * time.Millisecond << (i - 1)
|
|
if max := 5 * time.Second; d > max {
|
|
d = max
|
|
}
|
|
return d
|
|
}
|
|
|
|
// transient reports whether an error is worth retrying. Bot-protection statuses
|
|
// (403/429), request-timeout and too-early (408/425), and 5xx server errors are
|
|
// transient; other 4xx (404, 401, 410, …) are permanent. A network error is
|
|
// retried, but a cancelled or expired context is not.
|
|
func transient(err error) bool {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
if errors.Is(err, ErrTooLarge) {
|
|
return false
|
|
}
|
|
var se *StatusError
|
|
if errors.As(err, &se) {
|
|
switch se.Code {
|
|
case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests:
|
|
return true
|
|
}
|
|
return se.Code >= 500
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isCSS reports whether a response is a stylesheet, by content-type or by a
|
|
// .css path when the server sends no useful type.
|
|
func isCSS(contentType string, u *url.URL) bool {
|
|
if strings.Contains(strings.ToLower(contentType), "text/css") {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(strings.ToLower(u.Path), ".css")
|
|
}
|