pyceo/web/internal/api/api.go

181 lines
4.8 KiB
Go

package api
import (
"fmt"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"os/exec"
"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(cfg *config.Config) net.Listener {
sockPath := cfg.SocketPath
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)
}
if !cfg.IsDev && !cfg.NoSocketAuth {
// Only www-data 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, 0)
if err != nil {
panic(err)
}
cmd := exec.Command("/bin/setfacl", "-m", "u:www-data:rw", sockPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
panic(err)
}
}
return l
}
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))
addStaticAssets(cfg.IsDev, e)
e.Use(appContextMiddleware(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)
e.Logger.Info("Listening on " + cfg.SocketPath)
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)
}