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) }