Antonio Mika
·
2025-03-12
calc_route.go
1package pgs
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "path/filepath"
8 "regexp"
9 "strings"
10
11 "github.com/picosh/pico/pkg/send/utils"
12 "github.com/picosh/pico/pkg/shared"
13 "github.com/picosh/pico/pkg/shared/storage"
14)
15
16type HttpReply struct {
17 Filepath string
18 Query map[string]string
19 Status int
20}
21
22func expandRoute(projectName, fp string, status int) []*HttpReply {
23 if fp == "" {
24 fp = "/"
25 }
26 mimeType := storage.GetMimeType(fp)
27 fname := filepath.Base(fp)
28 fdir := filepath.Dir(fp)
29 fext := filepath.Ext(fp)
30 routes := []*HttpReply{}
31
32 if mimeType != "text/plain" {
33 return routes
34 }
35
36 if fext == ".txt" {
37 return routes
38 }
39
40 // we know it's a directory so send the index.html for it
41 if strings.HasSuffix(fp, "/") {
42 dirRoute := shared.GetAssetFileName(&utils.FileEntry{
43 Filepath: filepath.Join(projectName, fp, "index.html"),
44 })
45
46 routes = append(
47 routes,
48 &HttpReply{Filepath: dirRoute, Status: status},
49 )
50 } else {
51 if fname == "." {
52 return routes
53 }
54
55 // pretty urls where we just append .html to base of fp
56 nameRoute := shared.GetAssetFileName(&utils.FileEntry{
57 Filepath: filepath.Join(
58 projectName,
59 fdir,
60 fmt.Sprintf("%s.html", fname),
61 ),
62 })
63
64 routes = append(
65 routes,
66 &HttpReply{Filepath: nameRoute, Status: status},
67 )
68 }
69
70 return routes
71}
72
73func checkIsRedirect(status int) bool {
74 return status >= 300 && status <= 399
75}
76
77func correlatePlaceholder(orig, pattern string) (string, string) {
78 origList := splitFp(orig)
79 patternList := splitFp(pattern)
80 nextList := []string{}
81 for idx, item := range patternList {
82 if len(origList) <= idx {
83 continue
84 }
85
86 if strings.HasPrefix(item, ":") {
87 nextList = append(nextList, origList[idx])
88 } else if strings.Contains(item, "*") {
89 nextList = append(nextList, strings.ReplaceAll(item, "*", "(.*)"))
90 } else if item == origList[idx] {
91 nextList = append(nextList, origList[idx])
92 } else {
93 nextList = append(nextList, item)
94 // if we are on the last pattern item then we need to ensure
95 // it matches the end of string so partial matches are not counted
96 if idx == len(patternList)-1 {
97 // regex end of string matcher
98 nextList = append(nextList, "$")
99 }
100 }
101 }
102
103 _type := "none"
104 if len(nextList) > 0 && len(nextList) == len(patternList) {
105 _type = "match"
106 } else if strings.Contains(pattern, "*") {
107 _type = "wildcard"
108 if pattern == "/*" {
109 nextList = append(nextList, ".*")
110 }
111 } else if strings.Contains(pattern, ":") {
112 _type = "variable"
113 }
114
115 return filepath.Join(nextList...), _type
116}
117
118func splitFp(str string) []string {
119 ls := strings.Split(str, "/")
120 fin := []string{}
121 for _, l := range ls {
122 if l == "" {
123 continue
124 }
125 fin = append(fin, l)
126 }
127 return fin
128}
129
130func genRedirectRoute(actual string, fromStr string, to string) string {
131 if to == "/" {
132 return to
133 }
134 actualList := splitFp(actual)
135 fromList := splitFp(fromStr)
136 prefix := ""
137 var toList []string
138 if hasProtocol(to) {
139 u, _ := url.Parse(to)
140 if u.Path == "" {
141 return to
142 }
143 toList = splitFp(u.Path)
144 prefix = u.Scheme + "://" + u.Host
145 } else {
146 toList = splitFp(to)
147 }
148
149 mapper := map[string]string{}
150 for idx, item := range fromList {
151 if len(actualList) < idx {
152 continue
153 }
154
155 if strings.HasPrefix(item, ":") {
156 mapper[item] = actualList[idx]
157 }
158 if strings.HasSuffix(item, "*") {
159 ls := actualList[idx:]
160 // if the * is part of other text in the segment (e.g. `/files*`)
161 // then we don't want to include "files" in the destination
162 if len(item) > 1 && len(actualList) > idx+1 {
163 ls = actualList[idx+1:]
164 }
165 // standalone splat
166 splat := strings.Join(ls, "/")
167 mapper[":splat"] = splat
168
169 // splat as a suffix to a string
170 place := strings.ReplaceAll(item, "*", ":splat")
171 mapper[place] = strings.Join(actualList[idx:], "/")
172 break
173 }
174 }
175
176 fin := []string{"/"}
177
178 for _, item := range toList {
179 if strings.HasSuffix(item, ":splat") {
180 fin = append(fin, mapper[item])
181 } else if mapper[item] != "" {
182 fin = append(fin, mapper[item])
183 } else {
184 fin = append(fin, item)
185 }
186 }
187
188 result := prefix + filepath.Join(fin...)
189 if !strings.HasSuffix(result, "/") && (strings.HasSuffix(to, "/") || strings.HasSuffix(actual, "/")) {
190 result += "/"
191 }
192 return result
193}
194
195func calcRoutes(projectName, fp string, userRedirects []*RedirectRule) []*HttpReply {
196 rts := []*HttpReply{}
197 if !strings.HasPrefix(fp, "/") {
198 fp = "/" + fp
199 }
200 // add route as-is without expansion
201 if !strings.HasSuffix(fp, "/") {
202 defRoute := shared.GetAssetFileName(&utils.FileEntry{
203 Filepath: filepath.Join(projectName, fp),
204 })
205 rts = append(rts, &HttpReply{Filepath: defRoute, Status: http.StatusOK})
206 }
207 expts := expandRoute(projectName, fp, http.StatusOK)
208 rts = append(rts, expts...)
209
210 // user routes
211 for _, redirect := range userRedirects {
212 // this doesn't make sense so it is forbidden
213 if redirect.From == redirect.To {
214 continue
215 }
216
217 // hack: make suffix `/` optional when matching
218 from := filepath.Clean(redirect.From)
219 match := []string{}
220 fromMatcher, matcherType := correlatePlaceholder(fp, from)
221 switch matcherType {
222 case "match":
223 fallthrough
224 case "wildcard":
225 fallthrough
226 case "variable":
227 rr := regexp.MustCompile(fromMatcher)
228 match = rr.FindStringSubmatch(fp)
229 case "none":
230 fallthrough
231 default:
232 }
233
234 if len(match) > 0 && match[0] != "" {
235 isRedirect := checkIsRedirect(redirect.Status)
236 if !isRedirect && !hasProtocol(redirect.To) {
237 route := genRedirectRoute(fp, from, redirect.To)
238 // wipe redirect rules to prevent infinite loops
239 // as such we only support a single hop for user defined redirects
240 redirectRoutes := calcRoutes(projectName, route, []*RedirectRule{})
241 rts = append(rts, redirectRoutes...)
242 return rts
243 }
244
245 route := genRedirectRoute(fp, from, redirect.To)
246 userReply := []*HttpReply{}
247 var rule *HttpReply
248 if redirect.To != "" {
249 rule = &HttpReply{
250 Filepath: route,
251 Status: redirect.Status,
252 Query: redirect.Query,
253 }
254 userReply = append(userReply, rule)
255 }
256
257 if redirect.Force {
258 rts = userReply
259 } else {
260 rts = append(rts, userReply...)
261 }
262
263 if hasProtocol(redirect.To) {
264 // redirecting to another site so we should bail early
265 return rts
266 } else {
267 // quit after first match
268 break
269 }
270 }
271 }
272
273 // we might have a directory so add a trailing slash with a 301
274 // we can't check for file extention because route could have a dot
275 // and ext parsing gets confused
276 if !strings.HasSuffix(fp, "/") {
277 redirectRoute := shared.GetAssetFileName(&utils.FileEntry{
278 Filepath: fp + "/",
279 })
280 rts = append(
281 rts,
282 &HttpReply{Filepath: redirectRoute, Status: http.StatusMovedPermanently},
283 )
284 }
285
286 notFound := &HttpReply{
287 Filepath: filepath.Join(projectName, "404.html"),
288 Status: http.StatusNotFound,
289 }
290
291 rts = append(rts,
292 notFound,
293 )
294
295 return rts
296}