- pdftext/outline: resolve named destinations through the catalog /Dests and the /Names /Dests tree, resolve indirect /Dest, /A and /Title, and treat reader.Null (what Dict.Get returns for a missing key) as absent. Before this every LaTeX and Acrobat PDF had an empty outline. pdftest gains NamedDests; TestOutlineNamedDestinations. - api: pool() always goes through its Once (data race on concurrent first use); TestDefaultPoolConcurrentFirstUse. Revalidation of a stale cache entry counts against the caller/host limit. - pdftext/runner: output over MaxText kills the child at once instead of waiting for the deadline; TestRunnerTooMuchTextFailsFast. - fetch: Accept includes application/pdf;q=0.8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
6.8 KiB
Go
234 lines
6.8 KiB
Go
package fetch
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// DefaultPDFMaxBody is the body cap for a PDF response: 100 MiB, versus the
|
|
// 5 MiB web cap (DefaultMaxBody).
|
|
DefaultPDFMaxBody = 100 << 20
|
|
// DefaultPDFTimeout is the deadline for a PDF response: 120 s, versus the
|
|
// 20 s web deadline (DefaultTimeout).
|
|
DefaultPDFTimeout = 120 * time.Second
|
|
)
|
|
|
|
// magicPDF marks the start of a PDF, found within the first sniffWindow bytes of
|
|
// an octet-stream or a bodyless response.
|
|
var magicPDF = []byte("%PDF-")
|
|
|
|
// sniffWindow is how many leading bytes are inspected to recognise a PDF whose
|
|
// Content-Type does not say so.
|
|
const sniffWindow = 1024
|
|
|
|
func (f *Fetcher) pdfMaxBody() int64 {
|
|
if f.PDFMaxBody <= 0 {
|
|
return DefaultPDFMaxBody
|
|
}
|
|
return f.PDFMaxBody
|
|
}
|
|
|
|
func (f *Fetcher) pdfTimeout() time.Duration {
|
|
if f.PDFTimeout <= 0 {
|
|
return DefaultPDFTimeout
|
|
}
|
|
return f.PDFTimeout
|
|
}
|
|
|
|
// clientTimeout is the http.Client.Timeout: it must cover the longest deadline
|
|
// this Fetcher can give a request, so a PDF is not cut off at the web deadline.
|
|
func (f *Fetcher) clientTimeout() time.Duration {
|
|
t := f.timeout()
|
|
if p := f.pdfTimeout(); p > t {
|
|
return p
|
|
}
|
|
return t
|
|
}
|
|
|
|
// normalizeContentType lower-cases a media type and drops its parameters.
|
|
func normalizeContentType(ct string) string {
|
|
ct = strings.ToLower(strings.TrimSpace(ct))
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = strings.TrimSpace(ct[:i])
|
|
}
|
|
return ct
|
|
}
|
|
|
|
// isPDFContentType reports whether a media type is unambiguously a PDF.
|
|
func isPDFContentType(ct string) bool {
|
|
ct = normalizeContentType(ct)
|
|
return ct == "application/pdf" || ct == "application/x-pdf"
|
|
}
|
|
|
|
// FetchIf GETs rawURL like Fetch but adds conditional-GET validators: a non-empty
|
|
// etag is sent as If-None-Match and a non-empty lastModified as If-Modified-Since.
|
|
// A 304 answer returns a Resource with Status 304, an empty Body and a nil error.
|
|
// With both empty it is exactly Fetch.
|
|
func (f *Fetcher) FetchIf(ctx context.Context, rawURL, etag, lastModified string) (*Resource, error) {
|
|
return f.doFetch(ctx, rawURL, etag, lastModified)
|
|
}
|
|
|
|
// doFetch is the shared body of Fetch and FetchIf: one guard check, one client,
|
|
// one set of limits and error mappings, differing only in request headers.
|
|
func (f *Fetcher) doFetch(ctx context.Context, rawURL, etag, lastModified string) (*Resource, error) {
|
|
if f == nil || f.Guard == nil {
|
|
return nil, errf(500, "", "internal error")
|
|
}
|
|
|
|
// A PDF may take longer than a web page. Give the request the PDF deadline
|
|
// and cancel it at the ordinary (web) deadline unless the response is
|
|
// recognised as a PDF sooner, by headers or by the first bytes sniffed.
|
|
pdfDeadline := f.pdfTimeout()
|
|
ctx, cancel := context.WithTimeout(ctx, pdfDeadline)
|
|
var webTimer *time.Timer
|
|
if web := f.timeout(); web < pdfDeadline {
|
|
webTimer = time.AfterFunc(web, cancel)
|
|
}
|
|
|
|
if _, _, err := f.Guard.CheckURL(ctx, rawURL); err != nil {
|
|
cancel()
|
|
return nil, mapGuard(err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, errf(400, "", "invalid url")
|
|
}
|
|
req.Header.Set("User-Agent", f.ua())
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,application/pdf;q=0.8,*/*;q=0.1")
|
|
if etag != "" {
|
|
req.Header.Set("If-None-Match", etag)
|
|
}
|
|
if lastModified != "" {
|
|
req.Header.Set("If-Modified-Since", lastModified)
|
|
}
|
|
// no cookies, no auth, no Referer
|
|
|
|
resp, err := f.httpClient().Do(req)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, mapDial(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// The headers are in; a declared PDF type no longer needs the web deadline.
|
|
ct := resp.Header.Get("Content-Type")
|
|
pdfByHeader := isPDFContentType(ct)
|
|
if pdfByHeader && webTimer != nil {
|
|
webTimer.Stop()
|
|
}
|
|
|
|
body, err := f.readBody(resp, ct, pdfByHeader, func() {
|
|
// Sniffing an octet-stream or bodyless response as a PDF: keep the PDF
|
|
// deadline instead of cancelling at the web deadline.
|
|
if webTimer != nil {
|
|
webTimer.Stop()
|
|
}
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
|
|
final := rawURL
|
|
if resp.Request != nil && resp.Request.URL != nil {
|
|
final = resp.Request.URL.String()
|
|
}
|
|
res := &Resource{
|
|
URL: rawURL,
|
|
FinalURL: final,
|
|
Status: resp.StatusCode,
|
|
ContentType: ct,
|
|
Header: resp.Header.Clone(),
|
|
Body: body,
|
|
FetchedAt: time.Now().UTC(),
|
|
}
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusOK:
|
|
cancel()
|
|
return res, nil
|
|
case resp.StatusCode == 403 || resp.StatusCode == 429 || resp.StatusCode == 451:
|
|
cancel()
|
|
return res, errf(422, "browser", "upstream %d (likely bot-blocked); hint: browser", resp.StatusCode)
|
|
case resp.StatusCode == 503 && looksLikeChallenge(body, resp.Header):
|
|
cancel()
|
|
return res, errf(422, "browser", "upstream 503 (likely bot-blocked); hint: browser")
|
|
case resp.StatusCode == http.StatusNotModified:
|
|
cancel()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return &Resource{
|
|
URL: rawURL,
|
|
FinalURL: final,
|
|
Status: resp.StatusCode,
|
|
ContentType: ct,
|
|
Header: resp.Header.Clone(),
|
|
}, nil
|
|
case resp.StatusCode >= 400:
|
|
cancel()
|
|
return res, errf(422, "", "upstream %d", resp.StatusCode)
|
|
default:
|
|
cancel()
|
|
return res, nil
|
|
}
|
|
}
|
|
|
|
// readBody reads resp.Body under the cap that applies and reports whether the
|
|
// response is a PDF (by declared type or by sniffing an octet-stream/bodyless
|
|
// body). onPDF, when non-nil, is called once the body is sniffed as a PDF so the
|
|
// caller can drop the web deadline.
|
|
func (f *Fetcher) readBody(resp *http.Response, ct string, pdfByHeader bool, onPDF func()) ([]byte, error) {
|
|
norm := normalizeContentType(ct)
|
|
if !pdfByHeader && norm != "application/octet-stream" && norm != "binary/octet-stream" && norm != "" {
|
|
// A known non-PDF type: the web cap, no sniffing.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBody()+1))
|
|
if err != nil {
|
|
return nil, mapDial(err)
|
|
}
|
|
if int64(len(body)) > f.maxBody() {
|
|
return nil, errf(413, "", "too large (>%d MB decompressed)", f.maxBody()>>20)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// PDF by declared type, or a type that may hide one: sniff the leading bytes
|
|
// to pick the cap, then read under it.
|
|
pdf := pdfByHeader
|
|
cap := f.maxBody()
|
|
if pdf {
|
|
cap = f.pdfMaxBody()
|
|
}
|
|
var body []byte
|
|
if !pdfByHeader {
|
|
head, err := io.ReadAll(io.LimitReader(resp.Body, sniffWindow))
|
|
if err != nil {
|
|
return nil, mapDial(err)
|
|
}
|
|
if bytes.Contains(head, magicPDF) {
|
|
pdf = true
|
|
cap = f.pdfMaxBody()
|
|
if onPDF != nil {
|
|
onPDF()
|
|
}
|
|
}
|
|
body = head
|
|
}
|
|
if remaining := cap + 1 - int64(len(body)); remaining > 0 {
|
|
more, err := io.ReadAll(io.LimitReader(resp.Body, remaining))
|
|
if err != nil {
|
|
return nil, mapDial(err)
|
|
}
|
|
body = append(body, more...)
|
|
}
|
|
if int64(len(body)) > cap {
|
|
return nil, errf(413, "", "too large (>%d MB decompressed)", cap>>20)
|
|
}
|
|
return body, nil
|
|
}
|