repos / pico

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

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