repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
Eric Bower  ·  2025-06-05

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	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	addSuffix := false
178
179	for _, item := range toList {
180		if strings.HasSuffix(item, ":splat") {
181			if strings.HasSuffix(actual, "/") {
182				addSuffix = true
183			}
184			fin = append(fin, mapper[item])
185		} else if mapper[item] != "" {
186			fin = append(fin, mapper[item])
187		} else {
188			fin = append(fin, item)
189		}
190	}
191
192	result := prefix + filepath.Join(fin...)
193	if !strings.HasSuffix(result, "/") && (strings.HasSuffix(to, "/") || addSuffix) {
194		result += "/"
195	}
196	return result
197}
198
199func calcRoutes(projectName, fp string, userRedirects []*RedirectRule) []*HttpReply {
200	rts := []*HttpReply{}
201	if !strings.HasPrefix(fp, "/") {
202		fp = "/" + fp
203	}
204	// add route as-is without expansion
205	if !strings.HasSuffix(fp, "/") {
206		defRoute := shared.GetAssetFileName(&utils.FileEntry{
207			Filepath: filepath.Join(projectName, fp),
208		})
209		rts = append(rts, &HttpReply{Filepath: defRoute, Status: http.StatusOK})
210	}
211	expts := expandRoute(projectName, fp, http.StatusOK)
212	rts = append(rts, expts...)
213
214	// user routes
215	for _, redirect := range userRedirects {
216		// this doesn't make sense so it is forbidden
217		if redirect.From == redirect.To {
218			continue
219		}
220
221		// hack: make suffix `/` optional when matching
222		from := filepath.Clean(redirect.From)
223		match := []string{}
224		fromMatcher, matcherType := correlatePlaceholder(fp, from)
225		switch matcherType {
226		case "match":
227			fallthrough
228		case "wildcard":
229			fallthrough
230		case "variable":
231			rr := regexp.MustCompile(fromMatcher)
232			match = rr.FindStringSubmatch(fp)
233		case "none":
234			fallthrough
235		default:
236		}
237
238		if len(match) > 0 && match[0] != "" {
239			isRedirect := checkIsRedirect(redirect.Status)
240			if !isRedirect && !hasProtocol(redirect.To) {
241				route := genRedirectRoute(fp, from, redirect.To)
242				// wipe redirect rules to prevent infinite loops
243				// as such we only support a single hop for user defined redirects
244				redirectRoutes := calcRoutes(projectName, route, []*RedirectRule{})
245				rts = append(rts, redirectRoutes...)
246				return rts
247			}
248
249			route := genRedirectRoute(fp, from, redirect.To)
250			userReply := []*HttpReply{}
251			var rule *HttpReply
252			if redirect.To != "" {
253				rule = &HttpReply{
254					Filepath: route,
255					Status:   redirect.Status,
256					Query:    redirect.Query,
257				}
258				userReply = append(userReply, rule)
259			}
260
261			if redirect.Force {
262				rts = userReply
263			} else {
264				rts = append(rts, userReply...)
265			}
266
267			if hasProtocol(redirect.To) {
268				// redirecting to another site so we should bail early
269				return rts
270			} else {
271				// quit after first match
272				break
273			}
274		}
275	}
276
277	// we might have a directory so add a trailing slash with a 301
278	// we can't check for file extention because route could have a dot
279	// and ext parsing gets confused
280	if !strings.HasSuffix(fp, "/") {
281		redirectRoute := shared.GetAssetFileName(&utils.FileEntry{
282			Filepath: fp + "/",
283		})
284		rts = append(
285			rts,
286			&HttpReply{Filepath: redirectRoute, Status: http.StatusMovedPermanently},
287		)
288	}
289
290	notFound := &HttpReply{
291		Filepath: filepath.Join(projectName, "404.html"),
292		Status:   http.StatusNotFound,
293	}
294
295	rts = append(rts,
296		notFound,
297	)
298
299	return rts
300}