repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

10.2 KB · 364 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"net/http"
  9	"net/url"
 10	"regexp"
 11	"strings"
 12	"time"
 13
 14	"github.com/temoto/robotstxt"
 15)
 16
 17const (
 18	// PageCap is the fence against a calendar or a faceted search that
 19	// generates URLs forever.
 20	PageCap = 500
 21	// Concurrency stays low because this is pointed at somebody else's site,
 22	// sometimes a small one on shared hosting.
 23	Concurrency = 4
 24	// ExternalLinkCap bounds the outbound HEAD probes, since a large site can
 25	// surface thousands of distinct external links.
 26	ExternalLinkCap = 500
 27	// CrawlDeadline sits under the scheduler's fifteen-minute watchdog, so an
 28	// overrunning crawl ends itself and records what it found instead of being
 29	// killed.
 30	CrawlDeadline = 540 * time.Second
 31
 32	// MaxBodyBytes caps one HTML body, because buffering a mislabeled download
 33	// four times over could exhaust the container.
 34	MaxBodyBytes = 5 * 1024 * 1024
 35
 36	crawlerRequestTimeout = 15 * time.Second
 37	externalLinkTimeout   = 8 * time.Second
 38
 39	// A real user agent with a contact URL, unlike the checker's Chrome
 40	// impersonation, so an operator reading their logs knows who to reach.
 41	crawlerUserAgent = "status (+" + baseURL + ")"
 42)
 43
 44type FetchResult struct {
 45	URL          string
 46	RequestedURL string
 47	Status       int
 48	Headers      map[string]string
 49	Body         []byte
 50	ContentType  string
 51	ElapsedMS    int64
 52	RedirectHops int
 53	Err          string
 54}
 55
 56func newCrawlClient() *http.Client {
 57	return &http.Client{
 58		Timeout: crawlerRequestTimeout,
 59		CheckRedirect: func(req *http.Request, via []*http.Request) error {
 60			if len(via) >= 10 {
 61				return http.ErrUseLastResponse
 62			}
 63			return nil
 64		},
 65	}
 66}
 67
 68// fetchPage retrieves one URL and never fails. A failed fetch comes back as
 69// status 0 with the error text, which reads as an unreachable link.
 70func fetchPage(ctx context.Context, client *http.Client, rawURL string) FetchResult {
 71	started := time.Now()
 72	result := FetchResult{URL: rawURL, RequestedURL: rawURL, Headers: map[string]string{}}
 73
 74	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
 75	if err != nil {
 76		result.Err = err.Error()
 77		result.ElapsedMS = time.Since(started).Milliseconds()
 78		return result
 79	}
 80	req.Header.Set("User-Agent", crawlerUserAgent)
 81
 82	resp, err := client.Do(req)
 83	if err != nil {
 84		result.Err = err.Error()
 85		result.ElapsedMS = time.Since(started).Milliseconds()
 86		return result
 87	}
 88	defer resp.Body.Close()
 89
 90	result.URL = canonicalURL(resp.Request.URL)
 91	result.Status = resp.StatusCode
 92	// Go follows redirects inside Do and hands back only the final response,
 93	// with the chain reachable through resp.Request.
 94	for r := resp.Request; r != nil; r = r.Response.Request {
 95		if r.Response == nil {
 96			break
 97		}
 98		result.RedirectHops++
 99	}
100
101	for k, v := range resp.Header {
102		if len(v) > 0 {
103			result.Headers[strings.ToLower(k)] = v[0]
104		}
105	}
106	result.ContentType = strings.ToLower(result.Headers["content-type"])
107
108	// Only HTML bodies are read, so a linked 200MB video costs a round trip
109	// instead of 200MB of memory.
110	if resp.StatusCode == http.StatusOK && strings.Contains(result.ContentType, "text/html") {
111		body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes))
112		if err != nil && len(body) == 0 {
113			result.Err = err.Error()
114		}
115		if len(body) == MaxBodyBytes {
116			slog.Info(fmt.Sprintf("body cap %d hit for %s", MaxBodyBytes, rawURL), slog.String("component", "crawler"))
117		}
118		result.Body = body
119	} else {
120		// Drained so the connection goes back to the pool instead of being
121		// dropped and re-handshaked.
122		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
123	}
124
125	result.ElapsedMS = time.Since(started).Milliseconds()
126	return result
127}
128
129// canonicalURL renders a fetched URL with an explicit "/" path, since
130// url.String() keeps the empty path it was given and nothing else spells it
131// that way.
132func canonicalURL(u *url.URL) string {
133	if u.Path == "" {
134		clone := *u
135		clone.Path = "/"
136		return clone.String()
137	}
138	return u.String()
139}
140
141// headStatus probes an external link. A 403, 405 or 501 usually means the
142// server refuses HEAD rather than that the link is broken, so those retry.
143func headStatus(ctx context.Context, client *http.Client, rawURL string) int {
144	ctx, cancel := context.WithTimeout(ctx, externalLinkTimeout)
145	defer cancel()
146
147	try := func(method string) int {
148		req, err := http.NewRequestWithContext(ctx, method, rawURL, nil)
149		if err != nil {
150			return 0
151		}
152		req.Header.Set("User-Agent", crawlerUserAgent)
153		resp, err := client.Do(req)
154		if err != nil {
155			return 0
156		}
157		defer resp.Body.Close()
158		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
159		return resp.StatusCode
160	}
161
162	status := try(http.MethodHead)
163	switch status {
164	case http.StatusForbidden, http.StatusMethodNotAllowed, http.StatusNotImplemented:
165		return try(http.MethodGet)
166	}
167	return status
168}
169
170// probeCompression reports the server's Content-Encoding for a URL, or "" when
171// the response came back uncompressed. Accept-Encoding is set by hand because
172// Go's transport otherwise decodes for you and strips the header off.
173func probeCompression(ctx context.Context, client *http.Client, rawURL string) string {
174	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
175	if err != nil {
176		return ""
177	}
178	req.Header.Set("User-Agent", crawlerUserAgent)
179	req.Header.Set("Accept-Encoding", "gzip, br, zstd, deflate")
180
181	resp, err := client.Do(req)
182	if err != nil {
183		return ""
184	}
185	defer resp.Body.Close()
186	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
187
188	enc := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
189	if enc == "" || enc == "identity" {
190		return ""
191	}
192	return enc
193}
194
195// Robots wraps a parsed robots.txt. A missing or unparseable file allows
196// everything, so a typo cannot produce an empty audit that reads as healthy.
197type Robots struct {
198	group *robotstxt.Group
199}
200
201func (r *Robots) Allowed(rawURL string) bool {
202	if r == nil || r.group == nil {
203		return true
204	}
205	u, err := url.Parse(rawURL)
206	if err != nil {
207		return true
208	}
209	path := u.EscapedPath()
210	if path == "" {
211		path = "/"
212	}
213	if u.RawQuery != "" {
214		path += "?" + u.RawQuery
215	}
216	return r.group.Test(path)
217}
218
219type RobotsCtx struct {
220	URL               string
221	Exists            bool
222	Raw               string
223	ReferencesSitemap bool
224}
225
226func loadRobots(ctx context.Context, client *http.Client, origin string) (*Robots, RobotsCtx) {
227	robotsURL := strings.TrimSuffix(origin, "/") + "/robots.txt"
228	out := RobotsCtx{URL: robotsURL}
229
230	result := fetchPageAllowingText(ctx, client, robotsURL)
231	if result.Status != http.StatusOK || len(result.Body) == 0 {
232		return &Robots{}, out
233	}
234
235	out.Exists = true
236	out.Raw = string(result.Body)
237	for _, line := range strings.Split(out.Raw, "\n") {
238		if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "sitemap:") {
239			out.ReferencesSitemap = true
240			break
241		}
242	}
243
244	data, err := robotstxt.FromBytes(result.Body)
245	if err != nil {
246		slog.Info(fmt.Sprintf("robots.txt at %s did not parse, allowing everything: %v", robotsURL, err), slog.String("component", "crawler"))
247		return &Robots{}, out
248	}
249	return &Robots{group: data.FindGroup("*")}, out
250}
251
252// fetchPageAllowingText is fetchPage for robots.txt and sitemap.xml, whose
253// bodies fetchPage would throw away as non-HTML.
254func fetchPageAllowingText(ctx context.Context, client *http.Client, rawURL string) FetchResult {
255	started := time.Now()
256	result := FetchResult{URL: rawURL, RequestedURL: rawURL, Headers: map[string]string{}}
257
258	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
259	if err != nil {
260		result.Err = err.Error()
261		return result
262	}
263	req.Header.Set("User-Agent", crawlerUserAgent)
264
265	resp, err := client.Do(req)
266	if err != nil {
267		result.Err = err.Error()
268		result.ElapsedMS = time.Since(started).Milliseconds()
269		return result
270	}
271	defer resp.Body.Close()
272
273	result.URL = canonicalURL(resp.Request.URL)
274	result.Status = resp.StatusCode
275	result.Body, _ = io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes))
276	result.ElapsedMS = time.Since(started).Milliseconds()
277	return result
278}
279
280var locPattern = regexp.MustCompile(`(?is)<loc>\s*([^<]+?)\s*</loc>`)
281
282// loadSitemap collects the page URLs a site advertises. Sitemaps nest, so it
283// follows index documents too, capped at twenty against a self-referring one.
284func loadSitemap(ctx context.Context, client *http.Client, origin, robotsText string) []string {
285	var queue []string
286	for _, line := range strings.Split(robotsText, "\n") {
287		trimmed := strings.TrimSpace(line)
288		if len(trimmed) < 8 || !strings.EqualFold(trimmed[:8], "sitemap:") {
289			continue
290		}
291		// Sliced off the original and not the lowercased copy, since a URL path
292		// is case sensitive.
293		if target := strings.TrimSpace(trimmed[8:]); target != "" {
294			queue = append(queue, target)
295		}
296	}
297	if len(queue) == 0 {
298		queue = append(queue, strings.TrimSuffix(origin, "/")+"/sitemap.xml")
299	}
300
301	seen := map[string]bool{}
302	var urls []string
303
304	for len(queue) > 0 && len(seen) < 20 {
305		current := queue[0]
306		queue = queue[1:]
307		if seen[current] {
308			continue
309		}
310		seen[current] = true
311
312		result := fetchPageAllowingText(ctx, client, current)
313		if result.Status != http.StatusOK {
314			continue
315		}
316		for _, m := range locPattern.FindAllStringSubmatch(string(result.Body), -1) {
317			loc := strings.TrimSpace(m[1])
318			if loc == "" {
319				continue
320			}
321			lower := strings.ToLower(loc)
322			if strings.HasSuffix(lower, ".xml") || strings.Contains(lower, "sitemap") {
323				queue = append(queue, loc)
324			} else {
325				urls = append(urls, loc)
326			}
327		}
328	}
329	return urls
330}
331
332// sameSite counts the www variant as the same site in both directions, or
333// every link between a site's apex and its www host reports as external.
334func sameSite(rawURL, host string) bool {
335	u, err := url.Parse(rawURL)
336	if err != nil {
337		return false
338	}
339	target := strings.ToLower(u.Hostname())
340	if target == "" {
341		return false
342	}
343	host = strings.ToLower(host)
344	return target == host ||
345		target == "www."+host ||
346		host == "www."+target
347}
348
349// crawlerHostileHosts answer 403 or 404 to anything that is not a browser, so
350// the HEAD probe skips them. Keep the list short, it suppresses real findings.
351var crawlerHostileHosts = map[string]bool{
352	"linkedin.com":     true,
353	"www.linkedin.com": true,
354}
355
356func isCrawlerHostile(rawURL string) bool {
357	u, err := url.Parse(rawURL)
358	if err != nil {
359		return false
360	}
361	host := strings.ToLower(u.Hostname())
362	return crawlerHostileHosts[host] || strings.HasSuffix(host, ".linkedin.com")
363}