main pico / pkg / apps / pgs / header.go
Antonio Mika  ·  2026-10-09
  1package pgs
  2
  3import (
  4	"fmt"
  5	"log/slog"
  6	"regexp"
  7	"slices"
  8	"strings"
  9)
 10
 11type HeaderRule struct {
 12	Path    string
 13	Headers []*HeaderLine
 14}
 15
 16type HeaderLine struct {
 17	Path  string
 18	Name  string
 19	Value string
 20}
 21
 22var headerDenyList = []string{
 23	"accept-ranges",
 24	"age",
 25	"allow",
 26	"alt-svc",
 27	"connection",
 28	"content-encoding",
 29	"content-length",
 30	"content-range",
 31	"date",
 32	"location",
 33	"server",
 34	"trailer",
 35	"transfer-encoding",
 36	"upgrade",
 37}
 38
 39// from https://github.com/netlify/build/tree/main/packages/headers-parser
 40func parseHeaderText(text string) ([]*HeaderRule, error) {
 41	rules := []*HeaderRule{}
 42	parsed := []*HeaderLine{}
 43	lines := strings.Split(text, "\n")
 44	for _, line := range lines {
 45		parsedLine, err := parseLine(strings.TrimSpace(line))
 46		if parsedLine == nil {
 47			continue
 48		}
 49		if err != nil {
 50			return rules, err
 51		}
 52		parsed = append(parsed, parsedLine)
 53	}
 54
 55	var prevPath *HeaderRule
 56	for _, rule := range parsed {
 57		if rule.Path != "" {
 58			if prevPath != nil {
 59				if len(prevPath.Headers) > 0 {
 60					rules = append(rules, prevPath)
 61				}
 62			}
 63
 64			prevPath = &HeaderRule{
 65				Path: rule.Path,
 66			}
 67		} else if prevPath != nil {
 68			// do not add headers in deny list
 69			if slices.Contains(headerDenyList, rule.Name) {
 70				continue
 71			}
 72			prevPath.Headers = append(
 73				prevPath.Headers,
 74				&HeaderLine{Name: rule.Name, Value: rule.Value},
 75			)
 76		}
 77	}
 78
 79	// cleanup
 80	if prevPath != nil && len(prevPath.Headers) > 0 {
 81		rules = append(rules, prevPath)
 82	}
 83
 84	return rules, nil
 85}
 86
 87// headerMatcher is a _headers rule with its path compiled.
 88type headerMatcher struct {
 89	path    *regexp.Regexp
 90	headers []*HeaderLine
 91}
 92
 93// compileHeaderRules compiles each rule's path once, when the rules are
 94// cached, and drops rules whose path is not a valid pattern.
 95func compileHeaderRules(rules []*HeaderRule, logger *slog.Logger) []headerMatcher {
 96	matchers := make([]headerMatcher, 0, len(rules))
 97	for _, rule := range rules {
 98		re, err := regexp.Compile(rule.Path)
 99		if err != nil {
100			logger.Error("skipping _headers rule with invalid path", "path", rule.Path, "err", err)
101			continue
102		}
103		matchers = append(matchers, headerMatcher{path: re, headers: rule.Headers})
104	}
105	return matchers
106}
107
108func parseLine(line string) (*HeaderLine, error) {
109	rule := &HeaderLine{}
110
111	if isPathLine(line) {
112		rule.Path = line
113		return rule, nil
114	}
115
116	if isEmpty(line) {
117		return nil, nil
118	}
119
120	if isComment(line) {
121		return nil, nil
122	}
123
124	if !strings.Contains(line, ":") {
125		return nil, nil
126	}
127
128	results := strings.SplitN(line, ":", 2)
129	name := strings.ToLower(strings.TrimSpace(results[0]))
130	value := strings.TrimSpace(results[1])
131
132	if name == "" {
133		return nil, fmt.Errorf("header name cannot be empty")
134	}
135
136	if value == "" {
137		return nil, fmt.Errorf("header value cannot be empty")
138	}
139
140	rule.Name = name
141	rule.Value = value
142	return rule, nil
143}
144
145func isComment(line string) bool {
146	return strings.HasPrefix(line, "#")
147}
148
149func isEmpty(line string) bool {
150	return line == ""
151}
152
153func isPathLine(line string) bool {
154	return strings.HasPrefix(line, "/")
155}