hmn/src/website/routes.go

457 lines
15 KiB
Go
Raw Normal View History

2021-03-14 20:49:58 +00:00
package website
import (
"context"
"errors"
2021-04-26 06:56:49 +00:00
"fmt"
2021-03-14 20:49:58 +00:00
"net/http"
2021-07-08 07:40:30 +00:00
"net/url"
"strings"
2021-04-26 06:56:49 +00:00
"time"
2021-03-14 20:49:58 +00:00
"git.handmade.network/hmn/hmn/src/auth"
2021-07-08 07:40:30 +00:00
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
2021-05-04 14:40:40 +00:00
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
2021-04-26 06:56:49 +00:00
"git.handmade.network/hmn/hmn/src/perf"
2021-03-14 20:49:58 +00:00
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/teacat/noire"
2021-03-14 20:49:58 +00:00
)
2021-04-26 06:56:49 +00:00
func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) http.Handler {
2021-04-29 03:07:14 +00:00
router := &Router{}
routes := RouteBuilder{
Router: router,
2021-04-29 03:07:14 +00:00
Middleware: func(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) {
c.Conn = conn
2021-04-29 03:07:14 +00:00
logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf()
2021-05-05 18:44:19 +00:00
defer LogContextErrors(c, &res)
2021-04-29 03:07:14 +00:00
return h(c)
}
2021-04-26 06:56:49 +00:00
},
2021-03-14 20:49:58 +00:00
}
mainRoutes := routes
2021-04-29 03:07:14 +00:00
mainRoutes.Middleware = func(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) {
c.Conn = conn
logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf()
2021-05-05 18:44:19 +00:00
defer LogContextErrors(c, &res)
2021-04-29 03:07:14 +00:00
ok, errRes := LoadCommonWebsiteData(c)
if !ok {
return errRes
}
return h(c)
}
}
2021-05-04 14:40:40 +00:00
staticPages := routes
staticPages.Middleware = func(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) {
c.Conn = conn
logPerf := TrackRequestPerf(c, perfCollector)
defer logPerf()
2021-05-05 18:44:19 +00:00
defer LogContextErrors(c, &res)
2021-05-04 14:40:40 +00:00
ok, errRes := LoadCommonWebsiteData(c)
if !ok {
return errRes
}
if !c.CurrentProject.IsHMN() {
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
2021-05-04 14:40:40 +00:00
}
return h(c)
}
}
authMiddleware := func(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) {
if c.CurrentUser == nil {
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
}
return h(c)
}
}
2021-07-02 05:11:58 +00:00
csrfMiddleware := func(h Handler) Handler {
// CSRF mitigation actions per the OWASP cheat sheet:
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
return func(c *RequestContext) ResponseData {
c.Req.ParseForm()
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
if csrfToken != c.CurrentSession.CSRFToken {
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
err := auth.DeleteSession(c.Context(), c.Conn, c.CurrentSession.ID)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to delete session on CSRF failure")
}
res := c.Redirect("/", http.StatusSeeOther)
res.SetCookie(auth.DeleteSessionCookie)
return res
}
return h(c)
}
}
2021-05-05 20:34:32 +00:00
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
2021-05-11 22:53:23 +00:00
routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogoutAction, Logout)
2021-07-08 07:40:30 +00:00
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
var res ResponseData
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
AddCORSHeaders(c, &res)
return res
})
2021-05-05 20:34:32 +00:00
mainRoutes.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
2021-04-22 23:02:50 +00:00
if c.CurrentProject.IsHMN() {
return Index(c)
} else {
2021-07-08 07:40:30 +00:00
return ProjectHomepage(c)
}
})
2021-05-05 20:34:32 +00:00
staticPages.GET(hmnurl.RegexManifesto, Manifesto)
staticPages.GET(hmnurl.RegexAbout, About)
staticPages.GET(hmnurl.RegexCodeOfConduct, CodeOfConduct)
staticPages.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines)
staticPages.GET(hmnurl.RegexContactPage, ContactPage)
staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
2021-05-04 14:40:40 +00:00
2021-06-22 09:50:40 +00:00
// TODO(asaf): Have separate middleware for HMN-only routes and any-project routes
// NOTE(asaf): HMN-only routes:
2021-05-05 20:34:32 +00:00
mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
2021-06-22 17:08:05 +00:00
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
2021-06-23 20:13:22 +00:00
mainRoutes.GET(hmnurl.RegexSnippet, Snippet)
2021-06-06 23:48:43 +00:00
mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
2021-06-22 09:50:40 +00:00
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
2021-07-08 07:40:30 +00:00
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
2021-06-06 23:48:43 +00:00
2021-06-22 09:50:40 +00:00
// NOTE(asaf): Any-project routes:
2021-07-20 02:35:22 +00:00
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
2021-07-02 05:11:58 +00:00
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
2021-05-05 20:34:32 +00:00
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForum, Forum)
mainRoutes.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
2021-07-20 02:35:22 +00:00
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
2021-07-23 19:00:37 +00:00
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
2021-07-22 01:41:23 +00:00
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
2021-07-23 19:00:37 +00:00
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
2021-07-22 04:42:34 +00:00
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
2021-07-23 19:00:37 +00:00
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
2021-07-30 19:59:48 +00:00
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
2021-07-23 03:09:46 +00:00
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeNew)
mainRoutes.POST(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeEdit)
mainRoutes.POST(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
2021-05-05 20:34:32 +00:00
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
2021-03-14 20:49:58 +00:00
2021-06-22 09:50:40 +00:00
// Other
2021-05-05 20:34:32 +00:00
mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
2021-03-31 04:20:50 +00:00
return router
2021-03-14 20:49:58 +00:00
}
func getBaseData(c *RequestContext) templates.BaseData {
2021-03-27 21:10:11 +00:00
var templateUser *templates.User
var templateSession *templates.Session
if c.CurrentUser != nil {
u := templates.UserToTemplate(c.CurrentUser, c.Theme)
s := templates.SessionToTemplate(c.CurrentSession)
templateUser = &u
templateSession = &s
2021-03-27 21:10:11 +00:00
}
return templates.BaseData{
Theme: c.Theme,
CurrentUrl: c.FullUrl(),
2021-05-11 22:53:23 +00:00
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
User: templateUser,
Session: templateSession,
2021-06-06 23:48:43 +00:00
IsProjectPage: !c.CurrentProject.IsHMN(),
2021-05-11 22:53:23 +00:00
Header: templates.Header{
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
2021-06-22 09:50:40 +00:00
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
2021-05-11 22:53:23 +00:00
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
2021-05-11 22:53:23 +00:00
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
2021-06-22 10:12:17 +00:00
HMNHomepageUrl: hmnurl.BuildHomepage(),
2021-05-11 22:53:23 +00:00
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
2021-06-06 23:48:43 +00:00
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
2021-05-11 22:53:23 +00:00
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
2021-05-11 22:53:23 +00:00
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
2021-07-08 07:40:30 +00:00
EditUrl: "",
2021-05-11 22:53:23 +00:00
SearchActionUrl: hmnurl.BuildHomepage(), // TODO(asaf)
},
Footer: templates.Footer{
HomepageUrl: hmnurl.BuildHomepage(),
AboutUrl: hmnurl.BuildAbout(),
ManifestoUrl: hmnurl.BuildManifesto(),
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
2021-06-06 23:48:43 +00:00
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
2021-05-11 22:53:23 +00:00
ContactUrl: hmnurl.BuildContactPage(),
SitemapUrl: hmnurl.BuildSiteMap(),
},
}
}
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
2021-05-25 13:26:12 +00:00
if len(slug) > 0 && slug != models.HMNProjectSlug {
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
if err == nil {
subdomainProject := subdomainProjectRow.(*models.Project)
return subdomainProject, nil
} else if !errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(err, "failed to get projects by slug")
} else {
2021-05-25 13:26:12 +00:00
return nil, nil
}
} else {
defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "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")
}
}
2021-05-25 13:26:12 +00:00
defaultProject := defaultProjectRow.(*models.Project)
return defaultProject, nil
}
2021-03-14 20:49:58 +00:00
}
func ProjectCSS(c *RequestContext) ResponseData {
2021-03-18 02:14:06 +00:00
color := c.URL().Query().Get("color")
2021-03-14 20:49:58 +00:00
if color == "" {
return ErrorResponse(http.StatusBadRequest, NewSafeError(nil, "You must provide a 'color' parameter.\n"))
2021-03-14 20:49:58 +00:00
}
baseData := getBaseData(c)
bgColor := noire.NewHex(color)
h, s, l := bgColor.HSL()
if baseData.Theme == "dark" {
l = 15
} else {
l = 95
}
if s > 20 {
s = 20
}
bgColor = noire.NewHSL(h, s, l)
2021-03-14 20:49:58 +00:00
templateData := struct {
templates.BaseData
Color string
PostBgColor string
2021-03-14 20:49:58 +00:00
}{
BaseData: baseData,
Color: color,
PostBgColor: bgColor.HTML(),
2021-03-14 20:49:58 +00:00
}
var res ResponseData
2021-04-29 03:07:14 +00:00
res.Header().Add("Content-Type", "text/css")
2021-04-26 06:56:49 +00:00
err := res.WriteTemplate("project.css", templateData, c.Perf)
2021-03-14 20:49:58 +00:00
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to generate project CSS"))
2021-03-14 20:49:58 +00:00
}
return res
2021-03-14 20:49:58 +00:00
}
func FourOhFour(c *RequestContext) ResponseData {
2021-05-04 13:23:02 +00:00
var res ResponseData
res.StatusCode = http.StatusNotFound
if c.Req.Header["Accept"] != nil && strings.Contains(c.Req.Header["Accept"][0], "text/html") {
templateData := struct {
templates.BaseData
Wanted string
}{
BaseData: getBaseData(c),
Wanted: c.FullUrl(),
}
res.MustWriteTemplate("404.html", templateData, c.Perf)
} else {
res.Write([]byte("Not Found"))
}
2021-05-04 13:23:02 +00:00
return res
2021-03-31 04:20:50 +00:00
}
2021-07-23 03:22:31 +00:00
type RejectData struct {
templates.BaseData
RejectReason string
}
func RejectRequest(c *RequestContext, reason string) ResponseData {
var res ResponseData
err := res.WriteTemplate("reject.html", RejectData{
BaseData: getBaseData(c),
RejectReason: reason,
}, c.Perf)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to render reject template"))
}
return res
}
2021-04-29 03:07:14 +00:00
func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
2021-04-26 06:56:49 +00:00
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
defer c.Perf.EndBlock()
2021-06-22 09:50:40 +00:00
// get project
{
slug := ""
hostParts := strings.SplitN(c.Req.Host, ".", 3)
if len(hostParts) >= 3 {
slug = hostParts[0]
}
2021-03-27 21:10:11 +00:00
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, slug)
if err != nil {
return false, ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
}
2021-05-25 13:26:12 +00:00
if dbProject == nil {
return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
c.CurrentProject = dbProject
}
2021-04-26 06:56:49 +00:00
{
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
if err == nil {
user, session, err := getCurrentUserAndSession(c, sessionCookie.Value)
2021-04-26 06:56:49 +00:00
if err != nil {
return false, ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
}
2021-04-26 06:56:49 +00:00
c.CurrentUser = user
c.CurrentSession = session
2021-04-26 06:56:49 +00:00
}
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
}
theme := "light"
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
theme = "dark"
}
c.Theme = theme
return true, ResponseData{}
}
2021-03-27 21:10:11 +00:00
2021-07-08 07:40:30 +00:00
func AddCORSHeaders(c *RequestContext, res *ResponseData) {
parsed, err := url.Parse(config.Config.BaseUrl)
if err != nil {
c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
return
}
origin := ""
origins, found := c.Req.Header["Origin"]
if found {
origin = origins[0]
}
if strings.HasSuffix(origin, parsed.Host) {
res.Header().Add("Access-Control-Allow-Origin", origin)
2021-07-23 03:09:46 +00:00
res.Header().Add("Vary", "Origin")
2021-07-08 07:40:30 +00:00
}
}
2021-04-17 00:01:13 +00:00
// Given a session id, fetches user data from the database. Will return nil if
// the user cannot be found, and will only return an error if it's serious.
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {
session, err := auth.GetSession(c.Context(), c.Conn, sessionId)
2021-03-27 21:10:11 +00:00
if err != nil {
if errors.Is(err, auth.ErrNoSession) {
return nil, nil, nil
2021-03-27 21:10:11 +00:00
} else {
return nil, nil, oops.New(err, "failed to get current session")
2021-03-27 21:10:11 +00:00
}
}
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
2021-03-27 21:10:11 +00:00
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
return nil, nil, nil // user was deleted or something
2021-03-27 21:10:11 +00:00
} else {
return nil, nil, oops.New(err, "failed to get user for session")
2021-03-27 21:10:11 +00:00
}
}
user := userRow.(*models.User)
2021-03-27 21:10:11 +00:00
return user, session, nil
2021-03-27 21:10:11 +00:00
}
2021-04-29 03:07:14 +00:00
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
2021-07-23 03:09:46 +00:00
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
2021-04-29 03:07:14 +00:00
return func() {
c.Perf.EndRequest()
log := logging.Info()
blockStack := make([]time.Time, 0)
2021-05-11 22:53:23 +00:00
for i, block := range c.Perf.Blocks {
2021-04-29 03:07:14 +00:00
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
blockStack = blockStack[:len(blockStack)-1]
}
2021-05-11 22:53:23 +00:00
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
2021-04-29 03:07:14 +00:00
blockStack = append(blockStack, block.End)
}
2021-07-23 03:09:46 +00:00
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
2021-04-29 03:07:14 +00:00
perfCollector.SubmitRun(c.Perf)
}
}
2021-05-05 18:44:19 +00:00
func LogContextErrors(c *RequestContext, res *ResponseData) {
2021-04-29 03:07:14 +00:00
for _, err := range res.Errors {
2021-05-11 22:53:23 +00:00
c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
2021-04-29 03:07:14 +00:00
}
}