2024-01-28 20:20:54 -05:00
|
|
|
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"
|
2024-02-03 21:43:03 -05:00
|
|
|
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
|
|
|
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
2024-01-28 20:20:54 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-02-03 21:43:03 -05:00
|
|
|
func useFCGI(cfg *config.Config) bool {
|
|
|
|
return !cfg.IsDev
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
|
2024-01-28 20:20:54 -05:00
|
|
|
e := echo.New()
|
2024-02-03 21:43:03 -05:00
|
|
|
app := app.NewApp(cfg, mailSrv)
|
2024-01-28 20:20:54 -05:00
|
|
|
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)
|
2024-02-03 21:43:03 -05:00
|
|
|
e.Use(appContextMiddleware(useFCGI(cfg), app))
|
2024-01-28 20:20:54 -05:00
|
|
|
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
2024-03-23 18:47:44 -04:00
|
|
|
TokenLookup: "cookie:" + cfg.CookieName,
|
2024-02-03 21:43:03 -05:00
|
|
|
CookieName: "ceod-web-csrf",
|
2024-01-28 20:20:54 -05:00
|
|
|
CookiePath: cfg.GetAppPath(),
|
|
|
|
CookieDomain: cfg.GetAppHostname(),
|
2024-02-03 21:43:03 -05:00
|
|
|
CookieSecure: !cfg.IsDev,
|
2024-01-28 20:20:54 -05:00
|
|
|
CookieHTTPOnly: true,
|
|
|
|
CookieSameSite: http.SameSiteStrictMode,
|
|
|
|
}))
|
|
|
|
e.HTTPErrorHandler = httpErrorHandler
|
|
|
|
e.Renderer = newTemplateManager(cfg.IsDev)
|
|
|
|
addRoutes(e)
|
2024-02-03 21:43:03 -05:00
|
|
|
return e
|
|
|
|
}
|
|
|
|
|
|
|
|
func Start(cfg *config.Config) {
|
|
|
|
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
|
2024-01-28 20:20:54 -05:00
|
|
|
listener := newListener(cfg.SocketPath)
|
|
|
|
e.Logger.Info("Listening on " + cfg.SocketPath)
|
2024-02-03 21:43:03 -05:00
|
|
|
if useFCGI(cfg) {
|
2024-01-28 20:20:54 -05:00
|
|
|
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)
|
|
|
|
}
|