- commit
- bd85d12
- parent
- 5e82119
- author
- Eric Bower
- date
- 2023-10-20 18:38:43 +0000 UTC
feat(pgs): support `_redirects` special file for routing (#48)
5 files changed,
+274,
-22
+14,
-0
1@@ -30,6 +30,20 @@ func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
2 return false, fmt.Errorf("ERROR: file (%s) has exceeded maximum file size (%d bytes)", fname, h.Cfg.MaxAssetSize)
3 }
4
5+ // ".well-known" is a special case
6+ if strings.Contains(fname, "/.well-known/") {
7+ if shared.IsTextFile(string(data.Text)) {
8+ return true, nil
9+ } else {
10+ return false, fmt.Errorf("(%s) not a utf-8 text file", data.Filepath)
11+ }
12+ }
13+
14+ // special file we use for custom routing
15+ if fname == "_redirects" {
16+ return true, nil
17+ }
18+
19 if !shared.IsExtAllowed(fname, h.Cfg.AllowedExt) {
20 extStr := strings.Join(h.Cfg.AllowedExt, ",")
21 err := fmt.Errorf(
M
go.mod
+1,
-0
1@@ -13,6 +13,7 @@ require (
2 github.com/charmbracelet/wish v0.7.0
3 github.com/disintegration/imaging v1.6.2
4 github.com/gliderlabs/ssh v0.3.5
5+ github.com/google/go-cmp v0.5.9
6 github.com/gorilla/feeds v1.1.1
7 github.com/kolesa-team/go-webp v1.0.4
8 github.com/lib/pq v1.10.7
+76,
-22
1@@ -7,6 +7,7 @@ import (
2 "net/http"
3 "net/url"
4 "path/filepath"
5+ "regexp"
6 "strings"
7 "time"
8
9@@ -136,35 +137,67 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
10 }
11 }
12
13-func calcPossibleRoutes(projectName, fp string) []string {
14+type HttpReply struct {
15+ Filepath string
16+ Query map[string]string
17+ Status int
18+}
19+
20+func calcPossibleRoutes(projectName, fp string, userRedirects []*RedirectRule) []*HttpReply {
21 fname := filepath.Base(fp)
22 fdir := filepath.Dir(fp)
23 fext := filepath.Ext(fp)
24
25+ dirRoute := shared.GetAssetFileName(&utils.FileEntry{
26+ Filepath: filepath.Join(projectName, fp, "index.html"),
27+ })
28+
29 // hack: we need to accommodate routes that are just directories
30 // and point the user to the index.html of each root dir.
31 if fname == "." || fext == "" {
32- return []string{
33- shared.GetAssetFileName(&utils.FileEntry{
34- Filepath: filepath.Join(projectName, fp, "index.html"),
35- }),
36- shared.GetAssetFileName(&utils.FileEntry{
37- Filepath: filepath.Join(
38- projectName,
39- fdir,
40- fmt.Sprintf("%s.html", fname),
41- ),
42- }),
43+ nameRoute := shared.GetAssetFileName(&utils.FileEntry{
44+ Filepath: filepath.Join(
45+ projectName,
46+ fdir,
47+ fmt.Sprintf("%s.html", fname),
48+ ),
49+ })
50+
51+ rts := []*HttpReply{
52+ {Filepath: dirRoute, Status: 200},
53+ {Filepath: nameRoute, Status: 200},
54 }
55+
56+ for _, redirect := range userRedirects {
57+ rr := regexp.MustCompile(redirect.From)
58+ match := rr.FindStringSubmatch(fp)
59+ if len(match) > 0 {
60+ ruleRoute := shared.GetAssetFileName(&utils.FileEntry{
61+ Filepath: filepath.Join(projectName, redirect.To),
62+ })
63+ rule := &HttpReply{
64+ Filepath: ruleRoute,
65+ Status: redirect.Status,
66+ Query: redirect.Query,
67+ }
68+ if redirect.Force {
69+ rts = append([]*HttpReply{rule}, rts...)
70+ } else {
71+ rts = append(rts, rule)
72+ }
73+ }
74+ }
75+
76+ return rts
77 }
78
79- return []string{
80- shared.GetAssetFileName(&utils.FileEntry{
81- Filepath: filepath.Join(projectName, fdir, fname),
82- }),
83- shared.GetAssetFileName(&utils.FileEntry{
84- Filepath: filepath.Join(projectName, fp, "index.html"),
85- }),
86+ defRoute := shared.GetAssetFileName(&utils.FileEntry{
87+ Filepath: filepath.Join(projectName, fdir, fname),
88+ })
89+
90+ return []*HttpReply{
91+ {Filepath: defRoute, Status: 200},
92+ {Filepath: dirRoute, Status: 200},
93 }
94 }
95
96@@ -176,14 +209,34 @@ func assetHandler(w http.ResponseWriter, h *AssetHandler) {
97 return
98 }
99
100- routes := calcPossibleRoutes(h.ProjectDir, h.Filepath)
101+ var redirects []*RedirectRule
102+ redirectFp, err := h.Storage.GetFile(bucket, filepath.Join(h.ProjectDir, "_redirects"))
103+ if err == nil {
104+ defer redirectFp.Close()
105+ buf := new(strings.Builder)
106+ _, err := io.Copy(buf, redirectFp)
107+ if err != nil {
108+ h.Logger.Error(err)
109+ http.Error(w, "cannot read _redirect file", http.StatusInternalServerError)
110+ return
111+ }
112+
113+ redirects, err = parseRedirectText(buf.String())
114+ if err != nil {
115+ h.Logger.Error(err)
116+ }
117+ }
118+
119+ routes := calcPossibleRoutes(h.ProjectDir, h.Filepath, redirects)
120 var contents storage.ReaderAtCloser
121 assetFilepath := ""
122+ status := 200
123 for _, fp := range routes {
124- c, err := h.Storage.GetFile(bucket, fp)
125+ c, err := h.Storage.GetFile(bucket, fp.Filepath)
126 if err == nil {
127 contents = c
128- assetFilepath = fp
129+ assetFilepath = fp.Filepath
130+ status = fp.Status
131 break
132 }
133 }
134@@ -201,6 +254,7 @@ func assetHandler(w http.ResponseWriter, h *AssetHandler) {
135
136 contentType := shared.GetMimeType(assetFilepath)
137 w.Header().Add("Content-Type", contentType)
138+ w.WriteHeader(status)
139 _, err = io.Copy(w, contents)
140
141 if err != nil {
+137,
-0
1@@ -0,0 +1,137 @@
2+package pgs
3+
4+import (
5+ "fmt"
6+ "regexp"
7+ "strconv"
8+ "strings"
9+)
10+
11+type RedirectRule struct {
12+ From string
13+ To string
14+ Status int
15+ Query map[string]string
16+ Conditions map[string]string
17+ Force bool
18+ Signed bool
19+}
20+
21+var reSplitWhitespace = regexp.MustCompile(`\s+`)
22+
23+func isUrl(text string) bool {
24+ return strings.HasPrefix(text, "http://") || strings.HasPrefix(text, "https://")
25+}
26+
27+func isToPart(part string) bool {
28+ return strings.HasPrefix(part, "/") || isUrl(part)
29+}
30+
31+func hasStatusCode(part string) (int, bool) {
32+ status := 0
33+ forced := false
34+ pt := part
35+ if strings.HasSuffix(part, "!") {
36+ pt = strings.TrimSuffix(part, "!")
37+ forced = true
38+ }
39+
40+ status, err := strconv.Atoi(pt)
41+ if err != nil {
42+ return 0, forced
43+ }
44+ return status, forced
45+}
46+
47+func parsePairs(pairs []string) map[string]string {
48+ mapper := map[string]string{}
49+ for _, pair := range pairs {
50+ val := strings.SplitN(pair, "=", 1)
51+ if len(val) > 1 {
52+ mapper[val[0]] = val[1]
53+ }
54+ }
55+ return mapper
56+}
57+
58+/*
59+https://github.com/netlify/build/blob/main/packages/redirect-parser/src/line_parser.js#L9-L26
60+Parse `_redirects` file to an array of objects.
61+Each line in that file must be either:
62+ - An empty line
63+ - A comment starting with #
64+ - A redirect line, optionally ended with a comment
65+
66+Each redirect line has the following format:
67+
68+ from [query] [to] [status[!]] [conditions]
69+
70+The parts are:
71+ - "from": a path or a URL
72+ - "query": a whitespace-separated list of "key=value"
73+ - "to": a path or a URL
74+ - "status": an HTTP status integer
75+ - "!": an optional exclamation mark appended to "status" meant to indicate
76+ "forced"
77+ - "conditions": a whitespace-separated list of "key=value"
78+ - "Sign" is a special condition
79+*/
80+func parseRedirectText(text string) ([]*RedirectRule, error) {
81+ rules := []*RedirectRule{}
82+ origLines := strings.Split(text, "\n")
83+ for _, line := range origLines {
84+ trimmed := strings.TrimSpace(line)
85+ // ignore empty lines
86+ if trimmed == "" {
87+ continue
88+ }
89+
90+ // ignore comments
91+ if strings.HasPrefix(trimmed, "#") {
92+ continue
93+ }
94+
95+ parts := reSplitWhitespace.Split(trimmed, -1)
96+ if len(parts) < 2 {
97+ return rules, fmt.Errorf("Missing destination path/URL")
98+ }
99+
100+ from := parts[0]
101+ rest := parts[0:]
102+ status, forced := hasStatusCode(rest[0])
103+ if status != 0 {
104+ rules = append(rules, &RedirectRule{
105+ Query: map[string]string{},
106+ Status: status,
107+ Force: forced,
108+ })
109+ } else {
110+ toIndex := -1
111+ for idx, part := range rest {
112+ if isToPart(part) {
113+ toIndex = idx
114+ }
115+ }
116+
117+ if toIndex == -1 {
118+ return rules, fmt.Errorf("The destination path/URL must start with '/', 'http:' or 'https:'")
119+ }
120+
121+ queryParts := parts[:toIndex]
122+ to := parts[toIndex]
123+ lastParts := parts[toIndex+1:]
124+ sts, frcd := hasStatusCode(lastParts[0])
125+
126+ rules = append(rules, &RedirectRule{
127+ To: to,
128+ From: from,
129+ Status: sts,
130+ Force: frcd,
131+ Query: parsePairs(queryParts),
132+ Conditions: parsePairs(lastParts[1:]),
133+ })
134+ }
135+ }
136+
137+ return rules, nil
138+}
+46,
-0
1@@ -0,0 +1,46 @@
2+package pgs
3+
4+import (
5+ "testing"
6+
7+ "github.com/google/go-cmp/cmp"
8+)
9+
10+type Fixture struct {
11+ name string
12+ input string
13+ expect []*RedirectRule
14+}
15+
16+func TestParseRedirectText(t *testing.T) {
17+ empty := map[string]string{}
18+ spa := Fixture{
19+ name: "spa",
20+ input: "/* /index.html 200",
21+ expect: []*RedirectRule{
22+ {
23+ From: "/*",
24+ To: "/index.html",
25+ Status: 200,
26+ Query: empty,
27+ Conditions: empty,
28+ },
29+ },
30+ }
31+
32+ fixtures := []Fixture{
33+ spa,
34+ }
35+
36+ for _, fixture := range fixtures {
37+ t.Run(fixture.name, func(t *testing.T) {
38+ results, err := parseRedirectText(fixture.input)
39+ if err != nil {
40+ t.Error(err)
41+ }
42+ if cmp.Equal(results, fixture.expect) == false {
43+ t.Fatalf(cmp.Diff(fixture.expect, results))
44+ }
45+ })
46+ }
47+}