Eric Bower
·
2026-04-04
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/mime"
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 := mime.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 // special case: root path matches root path
116 if orig == "/" && pattern == "/" {
117 return "/", "match"
118 }
119
120 return filepath.Join(nextList...), _type
121}
122
123func splitFp(str string) []string {
124 ls := strings.Split(str, "/")
125 fin := []string{}
126 for _, l := range ls {
127 if l == "" {
128 continue
129 }
130 fin = append(fin, l)
131 }
132 return fin
133}
134
135func genRedirectRoute(actual string, fromStr string, to string) string {
136 if to == "/" {
137 return to
138 }
139 actualList := splitFp(actual)
140 fromList := splitFp(fromStr)
141 prefix := ""
142 var toList []string
143 if hasProtocol(to) {
144 u, _ := url.Parse(to)
145 if u.Path == "" {
146 return to
147 }
148 toList = splitFp(u.Path)
149 prefix = u.Scheme + "://" + u.Host
150 } else {
151 toList = splitFp(to)
152 }
153
154 mapper := map[string]string{}
155 for idx, item := range fromList {
156 if len(actualList) < idx {
157 continue
158 }
159
160 if strings.HasPrefix(item, ":") {
161 mapper[item] = actualList[idx]
162 }
163 if strings.HasSuffix(item, "*") {
164 ls := actualList[idx:]
165 // if the * is part of other text in the segment (e.g. `/files*`)
166 // then we don't want to include "files" in the destination
167 if len(item) > 1 && len(actualList) > idx+1 {
168 ls = actualList[idx+1:]
169 }
170 // standalone splat
171 splat := strings.Join(ls, "/")
172 mapper[":splat"] = splat
173
174 // splat as a suffix to a string
175 place := strings.ReplaceAll(item, "*", ":splat")
176 mapper[place] = strings.Join(actualList[idx:], "/")
177 break
178 }
179 }
180
181 fin := []string{"/"}
182 addSuffix := false
183
184 for _, item := range toList {
185 if strings.HasSuffix(item, ":splat") {
186 if strings.HasSuffix(actual, "/") {
187 addSuffix = true
188 }
189 fin = append(fin, mapper[item])
190 } else if mapper[item] != "" {
191 fin = append(fin, mapper[item])
192 } else {
193 fin = append(fin, item)
194 }
195 }
196
197 result := prefix + filepath.Join(fin...)
198 if !strings.HasSuffix(result, "/") && (strings.HasSuffix(to, "/") || addSuffix) {
199 result += "/"
200 }
201 return result
202}
203
204func calcRoutes(projectName, fp string, userRedirects []*RedirectRule) []*HttpReply {
205 rts := []*HttpReply{}
206 if !strings.HasPrefix(fp, "/") {
207 fp = "/" + fp
208 }
209 // add route as-is without expansion
210 if !strings.HasSuffix(fp, "/") {
211 defRoute := shared.GetAssetFileName(&utils.FileEntry{
212 Filepath: filepath.Join(projectName, fp),
213 })
214 rts = append(rts, &HttpReply{Filepath: defRoute, Status: http.StatusOK})
215 }
216 expts := expandRoute(projectName, fp, http.StatusOK)
217 rts = append(rts, expts...)
218
219 // user routes
220 for _, redirect := range userRedirects {
221 // this doesn't make sense so it is forbidden
222 if redirect.From == redirect.To {
223 continue
224 }
225
226 // hack: make suffix `/` optional when matching
227 from := filepath.Clean(redirect.From)
228 match := []string{}
229 fromMatcher, matcherType := correlatePlaceholder(fp, from)
230 switch matcherType {
231 case "match":
232 fallthrough
233 case "wildcard":
234 fallthrough
235 case "variable":
236 rr := regexp.MustCompile(fromMatcher)
237 match = rr.FindStringSubmatch(fp)
238 case "none":
239 fallthrough
240 default:
241 }
242
243 if len(match) > 0 && match[0] != "" {
244 isRedirect := checkIsRedirect(redirect.Status)
245 if !isRedirect && !hasProtocol(redirect.To) {
246 route := genRedirectRoute(fp, from, redirect.To)
247 // wipe redirect rules to prevent infinite loops
248 // as such we only support a single hop for user defined redirects
249 redirectRoutes := calcRoutes(projectName, route, []*RedirectRule{})
250 rts = append(rts, redirectRoutes...)
251 return rts
252 }
253
254 route := genRedirectRoute(fp, from, redirect.To)
255 userReply := []*HttpReply{}
256 var rule *HttpReply
257 if redirect.To != "" {
258 // expand redirect target to find actual file (e.g., directory -> index.html)
259 // but only if Force is true, it's not a full URL, and it's a directory path (ends with / but not just /)
260 if redirect.Force && !hasProtocol(redirect.To) && strings.HasSuffix(route, "/") && route != "/" {
261 expanded := expandRoute(projectName, route, redirect.Status)
262 if len(expanded) > 0 {
263 rule = expanded[0]
264 } else {
265 rule = &HttpReply{
266 Filepath: route,
267 Status: redirect.Status,
268 Query: redirect.Query,
269 }
270 }
271 } else {
272 rule = &HttpReply{
273 Filepath: route,
274 Status: redirect.Status,
275 Query: redirect.Query,
276 }
277 }
278 userReply = append(userReply, rule)
279 }
280
281 if redirect.Force {
282 rts = userReply
283 } else {
284 rts = append(rts, userReply...)
285 }
286
287 if hasProtocol(redirect.To) {
288 // redirecting to another site so we should bail early
289 return rts
290 } else {
291 // quit after first match
292 break
293 }
294 }
295 }
296
297 // we might have a directory so add a trailing slash with a 301
298 // we can't check for file extention because route could have a dot
299 // and ext parsing gets confused
300 if !strings.HasSuffix(fp, "/") {
301 redirectRoute := shared.GetAssetFileName(&utils.FileEntry{
302 Filepath: fp + "/",
303 })
304 rts = append(
305 rts,
306 &HttpReply{Filepath: redirectRoute, Status: http.StatusMovedPermanently},
307 )
308 }
309
310 notFound := &HttpReply{
311 Filepath: filepath.Join(projectName, "404.html"),
312 Status: http.StatusNotFound,
313 }
314
315 rts = append(rts,
316 notFound,
317 )
318
319 return rts
320}