repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

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
M filehandlers/assets/handler.go
+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 
M pgs/api.go
+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 
A pgs/header.go
+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+}
A pgs/header_test.go
+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+}
M pgs/redirect_test.go
+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