repos / pico

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

pico / pkg / apps / pgs
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}