package api import ( "fmt" "html/template" "io" "io/fs" "net" "net/http" "net/http/fcgi" "os" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" "git.csclub.uwaterloo.ca/public/pyceo/web/internal" "git.csclub.uwaterloo.ca/public/pyceo/web/internal/app" "git.csclub.uwaterloo.ca/public/pyceo/web/internal/config" "git.csclub.uwaterloo.ca/public/pyceo/web/internal/service" "git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model" ) type TemplateManager struct { templates map[string]*template.Template } func newTemplateManager(isDev bool) *TemplateManager { var viewsFS fs.FS if isDev { viewsFS = os.DirFS("internal/views") } else { var err error viewsFS, err = fs.Sub(internal.EmbeddedViews, "views") if err != nil { panic(err) } } base := template.Must(template.ParseFS(viewsFS, "base.html")) // Adapted from https://stackoverflow.com/a/24120195 parse := func(filenames ...string) *template.Template { clone := template.Must(base.Clone()) return template.Must(clone.ParseFS(viewsFS, filenames...)) } return &TemplateManager{templates: map[string]*template.Template{ "root": parse("root.html"), "pwreset": parse("pwreset.html"), "pwreset_confirmation": parse("pwreset_confirmation.html"), "app_error": parse("app_error.html"), "4xx": parse("4xx.html"), "500": parse("500.html"), }} } func (t *TemplateManager) Render(w io.Writer, name string, data any, c echo.Context) error { tmpl, ok := t.templates[name] if !ok { c.Logger().Errorf("No such template '%s'", name) return echo.NewHTTPError(http.StatusInternalServerError, "Internal server error") } return tmpl.Execute(w, data) } func addStaticAssets(isDev bool, e *echo.Echo) { var staticFS fs.FS if isDev { staticFS = os.DirFS("internal") } else { staticFS = internal.EmbeddedAssets } e.Group("static").Use(middleware.StaticWithConfig(middleware.StaticConfig{ Root: "static", Filesystem: http.FS(staticFS), })) } func newListener(sockPath string) net.Listener { if _, err := os.Stat(sockPath); err == nil { err = os.Remove(sockPath) if err != nil { panic(fmt.Errorf("Could not remove %s: %w", sockPath, err)) } } l, err := net.Listen("unix", sockPath) if err != nil { panic(err) } // Only root should be allowed to write to the socket, otherwise // users could forge the X-CSC-ADFS-* headers and reset other people's // passwords err = os.Chmod(sockPath, 0600) if err != nil { panic(err) } return l } func useFCGI(cfg *config.Config) bool { return !cfg.IsDev } func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo { e := echo.New() app := app.NewApp(cfg, mailSrv) e.Logger.SetLevel(log.DEBUG) e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.RequestID()) e.Use(helmet(cfg.IsDev)) addStaticAssets(cfg.IsDev, e) e.Use(appContextMiddleware(useFCGI(cfg), app)) e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ TokenLookup: "cookie:" + cfg.CookieName, CookieName: "ceod-web-csrf", CookiePath: cfg.GetAppPath(), CookieDomain: cfg.GetAppHostname(), CookieSecure: !cfg.IsDev, CookieHTTPOnly: true, CookieSameSite: http.SameSiteStrictMode, })) e.HTTPErrorHandler = httpErrorHandler e.Renderer = newTemplateManager(cfg.IsDev) addRoutes(e) return e } func Start(cfg *config.Config) { e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg)) listener := newListener(cfg.SocketPath) e.Logger.Info("Listening on " + cfg.SocketPath) if useFCGI(cfg) { e.Logger.Fatal(fcgi.Serve(listener, e)) } else { e.Logger.Fatal(http.Serve(listener, e)) } } func addRoutes(e *echo.Echo) { e.GET("/", getRoot) e.GET("/pwreset", getPwreset) e.POST("/pwreset", postPwreset) } func getRoot(c echo.Context) error { ac := c.(*appContext) _, err := ac.app.GetReqUser(ac) if err != nil { return err } return c.Render(http.StatusOK, "root", map[string]any{"firstName": ac.req.GivenName}) } func getPwreset(c echo.Context) error { ac := c.(*appContext) emailAddress, err := ac.app.PwresetCheck(ac) if err != nil { return err } // Double-submit cookie renderData := map[string]any{ "emailAddress": emailAddress, "csrf": c.Get("csrf"), } return c.Render(http.StatusOK, "pwreset", renderData) } func postPwreset(c echo.Context) error { if c.Get("csrf") != c.FormValue("_csrf") { return newApiError(ERROR_INVALID_CSRF_TOKEN) } ac := c.(*appContext) emailAddress, err := ac.app.Pwreset(ac) if err != nil { return err } renderData := map[string]any{"emailAddress": emailAddress} return c.Render(http.StatusOK, "pwreset_confirmation", renderData) }