repos / pico

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

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
M go.mod
M filehandlers/assets/asset.go
+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
M pgs/api.go
+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 {
A pgs/redirect.go
+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+}
A pgs/redirect_test.go
+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+}