142 lines
4.0 KiB
Go
142 lines
4.0 KiB
Go
package fetch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ddc/internal/guard"
|
|
)
|
|
|
|
const userAgent = "ddc/1.0"
|
|
|
|
var (
|
|
ErrResumeUnsupported = errors.New("connection was lost and the server does not support resuming")
|
|
ErrSourceChanged = errors.New("file changed on the server while downloading")
|
|
errConnectionFailed = errors.New("connection to the server failed")
|
|
errServerUnavailable = errors.New("server is temporarily unavailable")
|
|
)
|
|
|
|
type remoteFile struct {
|
|
url string
|
|
validator string
|
|
totalBytes int64
|
|
}
|
|
|
|
func (source *remoteFile) describe(response *http.Response) {
|
|
source.totalBytes = max(response.ContentLength, 0)
|
|
source.validator = validatorOf(response.Header)
|
|
}
|
|
|
|
func validatorOf(header http.Header) string {
|
|
if entityTag := header.Get("ETag"); entityTag != "" && !strings.HasPrefix(entityTag, "W/") {
|
|
return entityTag
|
|
}
|
|
return header.Get("Last-Modified")
|
|
}
|
|
|
|
type cancelingBody struct {
|
|
io.ReadCloser
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (body *cancelingBody) Close() error {
|
|
body.cancel()
|
|
return body.ReadCloser.Close()
|
|
}
|
|
|
|
func (downloader *Downloader) open(ctx context.Context, source *remoteFile, position, end int64) (*http.Response, error) {
|
|
requestContext, cancelRequest := context.WithCancel(ctx)
|
|
response, err := downloader.send(requestContext, source, position, end)
|
|
if err != nil {
|
|
cancelRequest()
|
|
return nil, err
|
|
}
|
|
response.Body = &cancelingBody{ReadCloser: response.Body, cancel: cancelRequest}
|
|
return response, nil
|
|
}
|
|
|
|
func (downloader *Downloader) send(ctx context.Context, source *remoteFile, position, end int64) (*http.Response, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("User-Agent", userAgent)
|
|
request.Header.Set("Accept-Encoding", "identity")
|
|
|
|
ranged := position > 0 || (end > 0 && end < source.totalBytes)
|
|
if ranged {
|
|
request.Header.Set("Range", rangeHeader(position, end))
|
|
if source.validator != "" {
|
|
request.Header.Set("If-Range", source.validator)
|
|
}
|
|
}
|
|
|
|
response, err := downloader.client.Do(request)
|
|
if err != nil {
|
|
return nil, connectionError(err)
|
|
}
|
|
if err := checkResponse(response, source, position, ranged); err != nil {
|
|
response.Body.Close()
|
|
return nil, err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func rangeHeader(position, end int64) string {
|
|
if end > 0 {
|
|
return fmt.Sprintf("bytes=%d-%d", position, end-1)
|
|
}
|
|
return fmt.Sprintf("bytes=%d-", position)
|
|
}
|
|
|
|
func checkResponse(response *http.Response, source *remoteFile, position int64, ranged bool) error {
|
|
status := response.StatusCode
|
|
switch {
|
|
case !ranged && status == http.StatusOK:
|
|
if source.totalBytes > 0 && response.ContentLength != source.totalBytes {
|
|
return ErrSourceChanged
|
|
}
|
|
return nil
|
|
case ranged && status == http.StatusPartialContent:
|
|
return checkContentRange(response.Header.Get("Content-Range"), source.totalBytes, position)
|
|
case ranged && status == http.StatusOK:
|
|
if source.validator != "" && validatorOf(response.Header) != source.validator {
|
|
return ErrSourceChanged
|
|
}
|
|
return ErrResumeUnsupported
|
|
case status == http.StatusTooManyRequests || status >= http.StatusInternalServerError:
|
|
return fmt.Errorf("%w (%s)", errServerUnavailable, response.Status)
|
|
default:
|
|
return fmt.Errorf("server responded with %s", response.Status)
|
|
}
|
|
}
|
|
|
|
func checkContentRange(header string, totalBytes, offset int64) error {
|
|
rangeText, found := strings.CutPrefix(header, "bytes ")
|
|
if !found {
|
|
return ErrResumeUnsupported
|
|
}
|
|
span, totalText, found := strings.Cut(rangeText, "/")
|
|
startText, _, _ := strings.Cut(span, "-")
|
|
start, err := strconv.ParseInt(startText, 10, 64)
|
|
if !found || err != nil || start != offset {
|
|
return ErrResumeUnsupported
|
|
}
|
|
if totalBytes > 0 && totalText != "*" && totalText != strconv.FormatInt(totalBytes, 10) {
|
|
return ErrSourceChanged
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func connectionError(err error) error {
|
|
if errors.Is(err, guard.ErrBlockedAddress) || errors.Is(err, guard.ErrBlockedPort) || errors.Is(err, guard.ErrTooManyRedirects) {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%w: %w", errConnectionFailed, err)
|
|
}
|