2021-03-14 20:49:58 +00:00
package website
import (
"context"
2021-03-21 20:38:37 +00:00
"errors"
2021-04-26 06:56:49 +00:00
"fmt"
2021-03-14 20:49:58 +00:00
"net/http"
2021-03-21 20:38:37 +00:00
"strings"
2021-04-26 06:56:49 +00:00
"time"
2021-03-14 20:49:58 +00:00
2021-03-22 03:07:18 +00:00
"git.handmade.network/hmn/hmn/src/auth"
2021-03-21 20:38:37 +00:00
"git.handmade.network/hmn/hmn/src/db"
2021-05-04 14:40:40 +00:00
"git.handmade.network/hmn/hmn/src/hmnurl"
2021-03-22 03:07:18 +00:00
"git.handmade.network/hmn/hmn/src/logging"
2021-03-21 20:38:37 +00:00
"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"
2021-05-03 22:45:17 +00:00
"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 { }
2021-04-06 05:06:19 +00:00
routes := RouteBuilder {
Router : router ,
2021-04-29 03:07:14 +00:00
Middleware : func ( h Handler ) Handler {
return func ( c * RequestContext ) ( res ResponseData ) {
2021-04-06 05:06:19 +00:00
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
}
2021-04-06 05:06:19 +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 ( ) {
2021-05-30 18:35:01 +00:00
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 )
}
}
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-05-05 20:34:32 +00:00
routes . StdHandler ( hmnurl . RegexPublic ,
2021-04-29 03:07:14 +00:00
http . StripPrefix ( "/public/" , http . FileServer ( http . Dir ( "public" ) ) ) ,
2021-04-06 05:06:19 +00:00
)
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 ( ) {
2021-04-06 05:06:19 +00:00
return Index ( c )
2021-03-31 03:55:19 +00:00
} else {
// TODO: Return the project landing page
2021-04-06 03:30:11 +00:00
panic ( "route not implemented" )
2021-03-31 03:55:19 +00:00
}
} )
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-05-05 20:34:32 +00:00
mainRoutes . GET ( hmnurl . RegexFeed , Feed )
2021-05-30 18:35:01 +00:00
mainRoutes . GET ( hmnurl . RegexAtomFeed , AtomFeed )
2021-03-21 20:38:37 +00:00
2021-05-04 12:02:33 +00:00
// TODO(asaf): Trailing slashes break these
2021-05-05 20:34:32 +00:00
mainRoutes . GET ( hmnurl . RegexForumThread , ForumThread )
2021-04-29 03:34:22 +00:00
// mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost)
2021-05-05 20:34:32 +00:00
mainRoutes . GET ( hmnurl . RegexForumCategory , ForumCategory )
2021-04-29 03:34:22 +00:00
2021-05-05 20:34:32 +00:00
mainRoutes . GET ( hmnurl . RegexProjectCSS , ProjectCSS )
2021-03-14 20:49:58 +00:00
2021-05-05 20:34:32 +00:00
mainRoutes . AnyMethod ( hmnurl . RegexCatchAll , FourOhFour )
2021-03-31 04:20:50 +00:00
2021-04-06 05:06:19 +00:00
return router
2021-03-14 20:49:58 +00:00
}
2021-04-06 05:06:19 +00:00
func getBaseData ( c * RequestContext ) templates . BaseData {
2021-03-27 21:10:11 +00:00
var templateUser * templates . User
2021-04-06 03:30:11 +00:00
if c . CurrentUser != nil {
2021-03-27 21:10:11 +00:00
templateUser = & templates . User {
2021-04-06 03:30:11 +00:00
Username : c . CurrentUser . Username ,
2021-05-04 13:23:02 +00:00
Name : c . CurrentUser . Name ,
2021-04-06 03:30:11 +00:00
Email : c . CurrentUser . Email ,
IsSuperuser : c . CurrentUser . IsSuperuser ,
IsStaff : c . CurrentUser . IsStaff ,
2021-03-27 21:10:11 +00:00
}
}
2021-03-21 20:38:37 +00:00
return templates . BaseData {
2021-05-11 22:53:23 +00:00
Project : templates . ProjectToTemplate ( c . CurrentProject ) ,
LoginPageUrl : hmnurl . BuildLoginPage ( c . FullUrl ( ) ) ,
User : templateUser ,
2021-05-25 13:12:20 +00:00
Theme : c . Theme ,
2021-05-11 22:53:23 +00:00
ProjectCSSUrl : hmnurl . BuildProjectCSS ( c . CurrentProject . Color1 ) ,
Header : templates . Header {
AdminUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
MemberSettingsUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
LoginActionUrl : hmnurl . BuildLoginAction ( c . FullUrl ( ) ) ,
LogoutActionUrl : hmnurl . BuildLogoutAction ( ) ,
RegisterUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
HMNHomepageUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
ProjectHomepageUrl : hmnurl . BuildProjectHomepage ( c . CurrentProject . Slug ) ,
BlogUrl : hmnurl . BuildBlog ( c . CurrentProject . Slug , 1 ) ,
ForumsUrl : hmnurl . BuildForumCategory ( c . CurrentProject . Slug , nil , 1 ) ,
WikiUrl : hmnurl . BuildWiki ( c . CurrentProject . Slug ) ,
LibraryUrl : hmnurl . BuildLibrary ( c . CurrentProject . Slug ) ,
ManifestoUrl : hmnurl . BuildManifesto ( ) ,
EpisodeGuideUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
EditUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
SearchActionUrl : hmnurl . BuildHomepage ( ) , // TODO(asaf)
} ,
Footer : templates . Footer {
HomepageUrl : hmnurl . BuildHomepage ( ) ,
AboutUrl : hmnurl . BuildAbout ( ) ,
ManifestoUrl : hmnurl . BuildManifesto ( ) ,
CodeOfConductUrl : hmnurl . BuildCodeOfConduct ( ) ,
CommunicationGuidelinesUrl : hmnurl . BuildCommunicationGuidelines ( ) ,
ProjectIndexUrl : hmnurl . BuildProjectIndex ( ) ,
ForumsUrl : hmnurl . BuildForumCategory ( models . HMNProjectSlug , nil , 1 ) ,
ContactUrl : hmnurl . BuildContactPage ( ) ,
SitemapUrl : hmnurl . BuildSiteMap ( ) ,
} ,
2021-03-21 20:38:37 +00:00
}
}
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" )
2021-03-21 20:38:37 +00:00
} 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-03-21 20:38:37 +00:00
}
2021-05-25 13:26:12 +00:00
defaultProject := defaultProjectRow . ( * models . Project )
return defaultProject , nil
2021-03-21 20:38:37 +00:00
}
2021-03-14 20:49:58 +00:00
}
2021-04-06 05:06:19 +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 == "" {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusBadRequest , NewSafeError ( nil , "You must provide a 'color' parameter.\n" ) )
2021-03-14 20:49:58 +00:00
}
2021-05-03 22:45:17 +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 {
2021-05-03 22:45:17 +00:00
templates . BaseData
2021-05-04 01:59:45 +00:00
Color string
PostBgColor string
2021-03-14 20:49:58 +00:00
} {
2021-05-04 01:59:45 +00:00
BaseData : baseData ,
Color : color ,
PostBgColor : bgColor . HTML ( ) ,
2021-03-14 20:49:58 +00:00
}
2021-04-06 03:30:11 +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 {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to generate project CSS" ) )
2021-03-14 20:49:58 +00:00
}
2021-04-06 03:30:11 +00:00
return res
2021-03-14 20:49:58 +00:00
}
2021-03-21 20:38:37 +00:00
2021-04-06 05:06:19 +00:00
func FourOhFour ( c * RequestContext ) ResponseData {
2021-05-04 13:23:02 +00:00
var res ResponseData
res . StatusCode = http . StatusNotFound
2021-05-04 13:35:30 +00:00
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 . WriteTemplate ( "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-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-04-06 05:06:19 +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
2021-04-06 05:06:19 +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-03-21 20:38:37 +00:00
}
2021-05-25 13:26:12 +00:00
if dbProject == nil {
return false , c . Redirect ( hmnurl . BuildHomepage ( ) , http . StatusSeeOther )
}
2021-03-21 20:38:37 +00:00
2021-04-06 05:06:19 +00:00
c . CurrentProject = dbProject
}
2021-03-21 20:38:37 +00:00
2021-04-26 06:56:49 +00:00
{
sessionCookie , err := c . Req . Cookie ( auth . SessionCookieName )
if err == nil {
user , err := getCurrentUser ( c , sessionCookie . Value )
if err != nil {
return false , ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to get current user" ) )
}
2021-03-21 20:38:37 +00:00
2021-04-26 06:56:49 +00:00
c . CurrentUser = user
}
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
2021-03-21 20:38:37 +00:00
}
2021-04-06 05:06:19 +00:00
2021-05-25 13:12:20 +00:00
theme := "light"
if c . CurrentUser != nil && c . CurrentUser . DarkTheme {
theme = "dark"
}
c . Theme = theme
2021-04-06 05:06:19 +00:00
return true , ResponseData { }
2021-03-21 20:38:37 +00:00
}
2021-03-27 21:10:11 +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 getCurrentUser ( c * RequestContext , sessionId string ) ( * models . User , error ) {
2021-04-06 05:06:19 +00:00
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
} else {
return nil , oops . New ( err , "failed to get current session" )
}
}
2021-04-06 05:06:19 +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 // user was deleted or something
} else {
return nil , oops . New ( err , "failed to get user for session" )
}
}
2021-03-31 03:55:19 +00:00
user := userRow . ( * models . User )
2021-03-27 21:10:11 +00:00
2021-03-31 03:55:19 +00:00
return user , 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-04-29 03:34:22 +00:00
c . Perf = perf . MakeNewRequestPerf ( c . Route , 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-04-29 03:34:22 +00:00
log . Msg ( fmt . Sprintf ( "Served %s in %.4fms" , 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
}
}