hmn/src/website/routes.go

208 lines
5.2 KiB
Go

package website
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/julienschmidt/httprouter"
)
type websiteRoutes struct {
*HMNRouter
conn *pgxpool.Pool
}
func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
routes := &websiteRoutes{
HMNRouter: &HMNRouter{
HttpRouter: httprouter.New(),
Wrappers: []HMNHandlerWrapper{ErrorLoggingWrapper},
},
conn: conn,
}
mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper)
mainRoutes.GET("/", routes.Index)
mainRoutes.GET("/project/:id", routes.Project)
mainRoutes.GET("/assets/project.css", routes.ProjectCSS)
routes.POST("/login", routes.Login)
routes.ServeFiles("/public/*filepath", http.Dir("public"))
return routes
}
func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
return templates.BaseData{
Project: templates.Project{
Name: c.currentProject.Name,
Subdomain: c.currentProject.Slug,
Color: c.currentProject.Color1,
IsHMN: c.currentProject.IsHMN(),
HasBlog: true,
HasForum: true,
HasWiki: true,
HasLibrary: true,
},
Theme: "dark",
}
}
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
var subdomainProject models.Project
err := db.QueryOneToStruct(ctx, conn, &subdomainProject, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
if err == nil {
return &subdomainProject, nil
} else if !errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(err, "failed to get projects by slug")
}
var defaultProject models.Project
err = db.QueryOneToStruct(ctx, conn, &defaultProject, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(nil, "default project didn't exist in the database")
} else {
return nil, oops.New(err, "failed to get default project")
}
}
return &defaultProject, nil
}
func (s *websiteRoutes) Index(c *RequestContext, p httprouter.Params) {
err := c.WriteTemplate("index.html", s.getBaseData(c))
if err != nil {
panic(err)
}
}
func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) {
id := p.ByName("id")
row := s.conn.QueryRow(context.Background(), "SELECT name FROM handmade_project WHERE id = $1", p.ByName("id"))
var name string
err := row.Scan(&name)
if err != nil {
panic(err)
}
c.Body.Write([]byte(fmt.Sprintf("(%s) %s\n", id, name)))
}
func (s *websiteRoutes) ProjectCSS(c *RequestContext, p httprouter.Params) {
color := c.URL().Query().Get("color")
if color == "" {
c.StatusCode = http.StatusBadRequest
c.Body.Write([]byte("You must provide a 'color' parameter.\n"))
return
}
templateData := struct {
Color string
Theme string
}{
Color: color,
Theme: "dark",
}
c.Headers().Add("Content-Type", "text/css")
err := c.WriteTemplate("project.css", templateData)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to generate project CSS")
return
}
}
func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
bodyBytes, _ := io.ReadAll(c.Req.Body)
// TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
var body struct {
Username string
Password string
}
err := json.Unmarshal(bodyBytes, &body)
if err != nil {
panic(err)
}
var user models.User
err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", body.Username)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
c.StatusCode = http.StatusUnauthorized
} else {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
return
}
logging.Debug().Interface("user", user).Msg("the user to check")
hashed, err := auth.ParseDjangoPasswordString(user.Password)
if err != nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
return
}
passwordsMatch, err := auth.CheckPassword(body.Password, hashed)
if err != nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
return
}
if passwordsMatch {
c.Body.WriteString("ur good")
} else {
c.StatusCode = http.StatusUnauthorized
c.Body.WriteString("nope")
}
}
func ErrorLoggingWrapper(h HMNHandler) HMNHandler {
return func(c *RequestContext, p httprouter.Params) {
h(c, p)
for _, err := range c.Errors {
c.Logger.Error().Err(err).Msg("error occurred during request")
}
}
}
func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler {
return func(c *RequestContext, p httprouter.Params) {
slug := ""
hostParts := strings.SplitN(c.Req.Host, ".", 3)
if len(hostParts) >= 3 {
slug = hostParts[0]
}
dbProject, err := FetchProjectBySlug(c.Context(), s.conn, slug)
if err != nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
return
}
c.currentProject = dbProject
h(c, p)
}
}