- commit
- 44be1fd
- parent
- ab9f748
- author
- Eric Bower
- date
- 2024-02-25 19:59:42 +0000 UTC
feat(pgs): _headers file (#83) https://docs.netlify.com/routing/headers/ Closes: #82
5 files changed,
+350,
-6
+1,
-1
1@@ -356,7 +356,7 @@ func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
2 }
3
4 // special file we use for custom routing
5- if fname == "_redirects" {
6+ if fname == "_redirects" || fname == "_headers" {
7 return true, nil
8 }
9
+35,
-2
1@@ -232,7 +232,7 @@ func (h *AssetHandler) handle(w http.ResponseWriter) {
2 _, err := io.Copy(buf, redirectFp)
3 if err != nil {
4 h.Logger.Error(err.Error())
5- http.Error(w, "cannot read _redirect file", http.StatusInternalServerError)
6+ http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
7 return
8 }
9
10@@ -285,7 +285,40 @@ func (h *AssetHandler) handle(w http.ResponseWriter) {
11 contentType = storage.GetMimeType(assetFilepath)
12 }
13
14- w.Header().Add("Content-Type", contentType)
15+ var headers []*HeaderRule
16+ headersFp, _, _, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
17+ if err == nil {
18+ defer headersFp.Close()
19+ buf := new(strings.Builder)
20+ _, err := io.Copy(buf, headersFp)
21+ if err != nil {
22+ h.Logger.Error(err.Error())
23+ http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
24+ return
25+ }
26+
27+ headers, err = parseHeaderText(buf.String())
28+ if err != nil {
29+ h.Logger.Error(err.Error())
30+ }
31+ }
32+
33+ userHeaders := []*HeaderLine{}
34+ for _, headerRule := range headers {
35+ rr := regexp.MustCompile(headerRule.Path)
36+ match := rr.FindStringSubmatch(assetFilepath)
37+ if len(match) > 0 {
38+ userHeaders = headerRule.Headers
39+ }
40+ }
41+
42+ for _, hdr := range userHeaders {
43+ w.Header().Add(hdr.Name, hdr.Value)
44+ }
45+ if w.Header().Get("content-type") == "" {
46+ w.Header().Set("Content-Type", contentType)
47+ }
48+
49 w.WriteHeader(status)
50 _, err = io.Copy(w, contents)
51
+132,
-0
1@@ -0,0 +1,132 @@
2+package pgs
3+
4+import (
5+ "fmt"
6+ "slices"
7+ "strings"
8+)
9+
10+type HeaderRule struct {
11+ Path string
12+ Headers []*HeaderLine
13+}
14+
15+type HeaderLine struct {
16+ Path string
17+ Name string
18+ Value string
19+}
20+
21+var headerDenyList = []string{
22+ "accept-ranges",
23+ "age",
24+ "allow",
25+ "alt-svc",
26+ "connection",
27+ "content-encoding",
28+ "content-length",
29+ "content-range",
30+ "date",
31+ "location",
32+ "server",
33+ "trailer",
34+ "transfer-encoding",
35+ "upgrade",
36+}
37+
38+// from https://github.com/netlify/build/tree/main/packages/headers-parser
39+func parseHeaderText(text string) ([]*HeaderRule, error) {
40+ rules := []*HeaderRule{}
41+ parsed := []*HeaderLine{}
42+ lines := strings.Split(text, "\n")
43+ for _, line := range lines {
44+ parsedLine, err := parseLine(strings.TrimSpace(line))
45+ if parsedLine == nil {
46+ continue
47+ }
48+ if err != nil {
49+ return rules, err
50+ }
51+ parsed = append(parsed, parsedLine)
52+ }
53+
54+ var prevPath *HeaderRule
55+ for _, rule := range parsed {
56+ if rule.Path != "" {
57+ if prevPath != nil {
58+ if len(prevPath.Headers) > 0 {
59+ rules = append(rules, prevPath)
60+ }
61+ }
62+
63+ prevPath = &HeaderRule{
64+ Path: rule.Path,
65+ }
66+ } else if prevPath != nil {
67+ // do not add headers in deny list
68+ if slices.Contains(headerDenyList, rule.Name) {
69+ continue
70+ }
71+ prevPath.Headers = append(
72+ prevPath.Headers,
73+ &HeaderLine{Name: rule.Name, Value: rule.Value},
74+ )
75+ }
76+ }
77+
78+ // cleanup
79+ if prevPath != nil && len(prevPath.Headers) > 0 {
80+ rules = append(rules, prevPath)
81+ }
82+
83+ return rules, nil
84+}
85+
86+func parseLine(line string) (*HeaderLine, error) {
87+ rule := &HeaderLine{}
88+
89+ if isPathLine(line) {
90+ rule.Path = line
91+ return rule, nil
92+ }
93+
94+ if isEmpty(line) {
95+ return nil, nil
96+ }
97+
98+ if isComment(line) {
99+ return nil, nil
100+ }
101+
102+ if !strings.Contains(line, ":") {
103+ return nil, nil
104+ }
105+
106+ results := strings.SplitN(line, ":", 2)
107+ name := strings.ToLower(strings.TrimSpace(results[0]))
108+ value := strings.TrimSpace(results[1])
109+
110+ if name == "" {
111+ return nil, fmt.Errorf("header name cannot be empty")
112+ }
113+
114+ if value == "" {
115+ return nil, fmt.Errorf("header value cannot be empty")
116+ }
117+
118+ rule.Name = name
119+ rule.Value = value
120+ return rule, nil
121+}
122+
123+func isComment(line string) bool {
124+ return strings.HasPrefix(line, "#")
125+}
126+
127+func isEmpty(line string) bool {
128+ return line == ""
129+}
130+
131+func isPathLine(line string) bool {
132+ return strings.HasPrefix(line, "/")
133+}
+179,
-0
1@@ -0,0 +1,179 @@
2+package pgs
3+
4+import (
5+ "fmt"
6+ "testing"
7+
8+ "github.com/google/go-cmp/cmp"
9+)
10+
11+type HeaderFixture struct {
12+ name string
13+ input string
14+ expect []*HeaderRule
15+}
16+
17+func TestParseHeaderText(t *testing.T) {
18+ success := HeaderFixture{
19+ name: "success",
20+ input: "/path\n\ttest: one",
21+ expect: []*HeaderRule{
22+ {
23+ Path: "/path",
24+ Headers: []*HeaderLine{
25+ {Name: "test", Value: "one"},
26+ },
27+ },
28+ },
29+ }
30+
31+ successIndex := HeaderFixture{
32+ name: "successIndex",
33+ input: "/index.html\n\tX-Frame-Options: DENY",
34+ expect: []*HeaderRule{
35+ {
36+ Path: "/index.html",
37+ Headers: []*HeaderLine{
38+ {Name: "x-frame-options", Value: "DENY"},
39+ },
40+ },
41+ },
42+ }
43+
44+ compileList := ""
45+ for _, deny := range headerDenyList {
46+ compileList += fmt.Sprintf("\n\t%s: value", deny)
47+ }
48+
49+ denyList := HeaderFixture{
50+ name: "denyList",
51+ input: fmt.Sprintf("/\n\tX-Frame-Options: DENY%s", compileList),
52+ expect: []*HeaderRule{
53+ {
54+ Path: "/",
55+ Headers: []*HeaderLine{
56+ {Name: "x-frame-options", Value: "DENY"},
57+ },
58+ },
59+ },
60+ }
61+
62+ multiValue := HeaderFixture{
63+ name: "multiValue",
64+ input: "/*\n\tcache-control: max-age=0\n\tcache-control: no-cache\n\tcache-control: no-store\n\tcache-control: must-revalidate",
65+ expect: []*HeaderRule{
66+ {
67+ Path: "/*",
68+ Headers: []*HeaderLine{
69+ {Name: "cache-control", Value: "max-age=0"},
70+ {Name: "cache-control", Value: "no-cache"},
71+ {Name: "cache-control", Value: "no-store"},
72+ {Name: "cache-control", Value: "must-revalidate"},
73+ },
74+ },
75+ },
76+ }
77+
78+ comment := HeaderFixture{
79+ name: "comment",
80+ input: "/path\n\t# comment\n\ttest: one",
81+ expect: []*HeaderRule{
82+ {
83+ Path: "/path",
84+ Headers: []*HeaderLine{
85+ {Name: "test", Value: "one"},
86+ },
87+ },
88+ },
89+ }
90+
91+ invalidName := HeaderFixture{
92+ name: "invalidName",
93+ input: "/path\n\t: value",
94+ expect: []*HeaderRule{},
95+ }
96+
97+ invalidValue := HeaderFixture{
98+ name: "invalidValue",
99+ input: "/path\n\ttest:",
100+ expect: []*HeaderRule{},
101+ }
102+
103+ invalidForOrder := HeaderFixture{
104+ name: "invalidForOrder",
105+ input: "\ttest: one\n/path",
106+ expect: []*HeaderRule{},
107+ }
108+
109+ empty := HeaderFixture{
110+ name: "empty",
111+ input: "",
112+ expect: []*HeaderRule{},
113+ }
114+
115+ emptyLine := HeaderFixture{
116+ name: "emptyLine",
117+ input: "/path\n\n\ttest: one",
118+ expect: []*HeaderRule{
119+ {
120+ Path: "/path",
121+ Headers: []*HeaderLine{
122+ {Name: "test", Value: "one"},
123+ },
124+ },
125+ },
126+ }
127+
128+ duplicate := HeaderFixture{
129+ name: "duplicate",
130+ input: "/path\n\ttest: one\n/path\n\ttest: two",
131+ expect: []*HeaderRule{
132+ {
133+ Path: "/path",
134+ Headers: []*HeaderLine{
135+ {Name: "test", Value: "one"},
136+ },
137+ },
138+ {
139+ Path: "/path",
140+ Headers: []*HeaderLine{
141+ {Name: "test", Value: "two"},
142+ },
143+ },
144+ },
145+ }
146+
147+ noColon := HeaderFixture{
148+ name: "noColon",
149+ input: "/path\n\ttest = one",
150+ expect: []*HeaderRule{},
151+ }
152+
153+ fixtures := []HeaderFixture{
154+ success,
155+ successIndex,
156+ denyList,
157+ multiValue,
158+ comment,
159+ invalidName,
160+ invalidValue,
161+ invalidForOrder,
162+ empty,
163+ emptyLine,
164+ duplicate,
165+ noColon,
166+ }
167+
168+ for _, fixture := range fixtures {
169+ t.Run(fixture.name, func(t *testing.T) {
170+ results, err := parseHeaderText(fixture.input)
171+ if err != nil {
172+ t.Error(err)
173+ }
174+ fmt.Println(results)
175+ if cmp.Equal(results, fixture.expect) == false {
176+ t.Fatalf(cmp.Diff(fixture.expect, results))
177+ }
178+ })
179+ }
180+}
+3,
-3
1@@ -6,7 +6,7 @@ import (
2 "github.com/google/go-cmp/cmp"
3 )
4
5-type Fixture struct {
6+type RedirectFixture struct {
7 name string
8 input string
9 expect []*RedirectRule
10@@ -14,7 +14,7 @@ type Fixture struct {
11
12 func TestParseRedirectText(t *testing.T) {
13 empty := map[string]string{}
14- spa := Fixture{
15+ spa := RedirectFixture{
16 name: "spa",
17 input: "/* /index.html 200",
18 expect: []*RedirectRule{
19@@ -28,7 +28,7 @@ func TestParseRedirectText(t *testing.T) {
20 },
21 }
22
23- fixtures := []Fixture{
24+ fixtures := []RedirectFixture{
25 spa,
26 }
27