package site import ( "encoding/json" "fmt" "html/template" "net/http" "net/url" "path" "path/filepath" "strconv" "strings" "github.com/angelbeltran/templater" "ruben/inventory2/internal/domains/accounts" etsy_platform "ruben/inventory2/internal/domains/platforms/etsy" "ruben/inventory2/internal/domains/raw_events" ) func NewSiteHandler( dir string, rawEvents *raw_events.Store, accts *accounts.Store, etsy *etsy_platform.Platform, ) http.Handler { mux := http.NewServeMux() // api routes mux.HandleFunc("POST /accounts", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() email := r.FormValue("email") if email == "" { http.Error(w, "no email provided", http.StatusBadRequest) return } acct, err := accts.CreateAccount(ctx, email) if err != nil { http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError) return } http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther) }) mux.HandleFunc("POST /log-in", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() email := r.FormValue("email") if email == "" { http.Error(w, "no email provided", http.StatusBadRequest) return } acct, err := accts.GetAccountByEmail(ctx, email) if err != nil { http.Error(w, fmt.Sprintf("failed to create account: %w", err), http.StatusInternalServerError) return } http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther) }) // non-html routes scfs := http.FileServer(http.Dir(dir + "/scripts")) mux.Handle("/scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/javascript") if path.Ext(r.URL.Path) == ".gz" { w.Header().Set("Content-Encoding", "gzip") } scfs.ServeHTTP(w, r) }))) mux.Handle("/styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(dir+"/styles")))) // html page routes tmplr := templater.NewTemplater( dir+"/templates", func() template.FuncMap { return template.FuncMap{ "buildSitePath": func(parts ...any) string { strParts := make([]string, len(parts)) for i, p := range parts { strParts[i] = fmt.Sprint(p) } // TODO: make "/site" dynamic somehow return path.Join(append([]string{"/site"}, strParts...)...) }, "splitPath": func(p string) []string { if p == "" { return nil } return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/") }, "prettyPrintJSON": func(j json.RawMessage) string { b, err := json.MarshalIndent(j, " ", "") if err != nil { return string(j) } return string(b) }, "parseInt64": func(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) }, "addInt": func(a, b int) int { return a + b }, "subInt": func(a, b int) int { return a - b }, "multInt": func(a, b int) int { return a * b }, } }, ) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() name, pathParams := getPageTemplateNameForURL(r.URL) b, err := tmplr.ExecutePage( name, "Request", r, // add services here "RawEvents", rawEvents.WithContext(ctx), "URLCalc", newURLCalculator(r.URL), "PathParams", pathParams, "Accounts", accts.WithContext(ctx), "Etsy", etsy.WithContext(ctx), ) if err != nil { // TODO: handle 'not found' as a 404? fmt.Println("[ERROR]: failed to load or parse layout template:", err) http.Redirect(w, r, "/", http.StatusTemporaryRedirect) return } w.Write(b) }) return mux } // TODO: clean this up... // TODO: somehow tell what the path params are and pass them up. // - then consider pushing this functionality into the template library. // // getPageTemplateNameForURL eliminate any trailing .html or /, and checks for any // file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches. func getPageTemplateNameForURL(u *url.URL) (name string, params map[string]string) { fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/") if fp == "" { // "/" maps to "/index" fp = "index" } fpParts := strings.Split(fp, "/") res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts) for _, combs := range res { const pageBodiesPrefix = "internal/site/templates/page_bodies" pattern := path.Join(pageBodiesPrefix, path.Join(combs...)) + ".html.tmpl" matches, _ := filepath.Glob(pattern) if len(matches) == 0 { pattern := path.Join(pageBodiesPrefix, path.Join(combs...), "index") + ".html.tmpl" matches, _ = filepath.Glob(pattern) } if len(matches) > 0 { match := matches[0] name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), pageBodiesPrefix+"/") patternParts := strings.Split(name, "/") params = make(map[string]string) for i, pp := range patternParts { if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") { params[pp[1:len(pp)-1]] = fpParts[i] } } return name, params } } return fp, nil } func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts []string) [][]string { switch len(filepathParts) { case 0: return nil case 1: return [][]string{ []string{filepathParts[0]}, []string{"{*}"}, } default: tailCombs := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts[1:]) combs := make([][]string, 2*len(tailCombs)) for i, c := range tailCombs { combs[i*2] = append([]string{filepathParts[0]}, c...) combs[i*2+1] = append([]string{"{*}"}, c...) } return combs } } type URLCalculator struct { url *url.URL } func newURLCalculator(u *url.URL) URLCalculator { cpy := *u return URLCalculator{ url: &cpy, } } func (c URLCalculator) SetQueryParam(k string, v any) string { u := *c.url q := u.Query() q.Set(k, fmt.Sprint(v)) u.RawQuery = q.Encode() return u.String() }