2021-03-14 20:49:58 +00:00
package website
import (
2021-04-06 03:30:11 +00:00
"bytes"
2021-03-14 20:49:58 +00:00
"context"
2021-03-21 20:38:37 +00:00
"errors"
2021-03-14 20:49:58 +00:00
"fmt"
"net/http"
2021-03-21 20:38:37 +00:00
"strings"
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-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-03-14 20:49:58 +00:00
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/julienschmidt/httprouter"
)
func NewWebsiteRoutes ( conn * pgxpool . Pool ) http . Handler {
2021-04-06 05:06:19 +00:00
router := httprouter . New ( )
routes := RouteBuilder {
Router : router ,
BeforeHandlers : [ ] HMNBeforeHandler {
func ( c * RequestContext ) ( bool , ResponseData ) {
c . Conn = conn
return true , ResponseData { }
} ,
2021-04-11 21:46:06 +00:00
// TODO: Add a timeout? We don't want routes hanging forever
2021-03-22 03:07:18 +00:00
} ,
2021-04-06 05:06:19 +00:00
AfterHandlers : [ ] HMNAfterHandler { ErrorLoggingHandler } ,
2021-03-14 20:49:58 +00:00
}
2021-04-06 05:06:19 +00:00
routes . POST ( "/login" , Login )
routes . GET ( "/logout" , Logout )
routes . ServeFiles ( "/public/*filepath" , http . Dir ( "public" ) )
mainRoutes := routes
mainRoutes . BeforeHandlers = append ( mainRoutes . BeforeHandlers ,
CommonWebsiteDataWrapper ,
)
2021-04-06 03:30:11 +00:00
mainRoutes . GET ( "/" , func ( c * RequestContext ) ResponseData {
if c . CurrentProject . ID == models . HMNProjectID {
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-04-06 05:06:19 +00:00
mainRoutes . GET ( "/project/:id" , Project )
mainRoutes . GET ( "/assets/project.css" , ProjectCSS )
2021-03-21 20:38:37 +00:00
2021-04-06 05:06:19 +00:00
router . NotFound = mainRoutes . ChainHandlers ( FourOhFour )
2021-03-22 03:07:18 +00:00
2021-04-06 05:06:19 +00:00
adminRoutes := routes
adminRoutes . BeforeHandlers = append ( adminRoutes . BeforeHandlers ,
func ( c * RequestContext ) ( ok bool , res ResponseData ) {
return false , ResponseData {
StatusCode : http . StatusUnauthorized ,
Body : bytes . NewBufferString ( "No one is allowed!\n" ) ,
}
} ,
)
adminRoutes . AfterHandlers = append ( adminRoutes . AfterHandlers ,
func ( c * RequestContext , res ResponseData ) ResponseData {
res . Body . WriteString ( "Now go away. Sincerely, the after handler.\n" )
return res
} ,
)
2021-03-14 20:49:58 +00:00
2021-04-06 05:06:19 +00:00
adminRoutes . GET ( "/admin" , func ( c * RequestContext ) ResponseData {
return ResponseData {
Body : bytes . NewBufferString ( "Here are all the secrets.\n" ) ,
}
} )
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 ,
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-04-11 21:46:06 +00:00
Project : templates . ProjectToTemplate ( c . CurrentProject ) ,
User : templateUser ,
Theme : "dark" ,
2021-03-21 20:38:37 +00:00
}
}
func FetchProjectBySlug ( ctx context . Context , conn * pgxpool . Pool , slug string ) ( * models . Project , error ) {
2021-03-31 03:55:19 +00:00
subdomainProjectRow , err := db . QueryOne ( ctx , conn , models . Project { } , "SELECT $columns FROM handmade_project WHERE slug = $1" , slug )
2021-03-21 20:38:37 +00:00
if err == nil {
2021-03-31 03:55:19 +00:00
subdomainProject := subdomainProjectRow . ( models . Project )
2021-03-21 20:38:37 +00:00
return & subdomainProject , nil
} else if ! errors . Is ( err , db . ErrNoMatchingRows ) {
return nil , oops . New ( err , "failed to get projects by slug" )
}
2021-03-31 03:55:19 +00:00
defaultProjectRow , err := db . QueryOne ( ctx , conn , models . Project { } , "SELECT $columns FROM handmade_project WHERE id = $1" , models . HMNProjectID )
2021-03-21 20:38:37 +00:00
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-31 03:55:19 +00:00
defaultProject := defaultProjectRow . ( * models . Project )
2021-03-21 20:38:37 +00:00
2021-03-31 03:55:19 +00:00
return defaultProject , nil
2021-03-14 20:49:58 +00:00
}
2021-04-06 05:06:19 +00:00
func Project ( c * RequestContext ) ResponseData {
2021-04-06 03:30:11 +00:00
id := c . PathParams . ByName ( "id" )
2021-04-06 05:06:19 +00:00
row := c . Conn . QueryRow ( context . Background ( ) , "SELECT name FROM handmade_project WHERE id = $1" , c . PathParams . ByName ( "id" ) )
2021-03-14 20:49:58 +00:00
var name string
err := row . Scan ( & name )
if err != nil {
panic ( err )
}
2021-04-06 03:30:11 +00:00
var res ResponseData
res . Write ( [ ] byte ( fmt . Sprintf ( "(%s) %s\n" , id , name ) ) )
return res
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
}
templateData := struct {
Color string
Theme string
} {
Color : color ,
Theme : "dark" ,
}
2021-04-06 03:30:11 +00:00
var res ResponseData
res . Headers ( ) . Add ( "Content-Type" , "text/css" )
err := res . WriteTemplate ( "project.css" , templateData )
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 Login ( c * RequestContext ) ResponseData {
2021-03-22 03:07:18 +00:00
// TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
2021-03-26 03:33:00 +00:00
form , err := c . GetFormValues ( )
2021-03-22 03:07:18 +00:00
if err != nil {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusBadRequest , NewSafeError ( err , "request must contain form data" ) )
2021-03-26 03:33:00 +00:00
}
username := form . Get ( "username" )
password := form . Get ( "password" )
if username == "" || password == "" {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusBadRequest , NewSafeError ( err , "you must provide both a username and password" ) )
2021-03-26 03:33:00 +00:00
}
redirect := form . Get ( "redirect" )
if redirect == "" {
redirect = "/"
2021-03-22 03:07:18 +00:00
}
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" , username )
2021-03-22 03:07:18 +00:00
if err != nil {
if errors . Is ( err , db . ErrNoMatchingRows ) {
2021-04-06 03:30:11 +00:00
return ResponseData {
StatusCode : http . StatusUnauthorized ,
}
2021-03-22 03:07:18 +00:00
} else {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to look up user by username" ) )
2021-03-22 03:07:18 +00:00
}
}
2021-04-06 03:30:11 +00:00
user := userRow . ( * models . User )
2021-03-22 03:07:18 +00:00
2021-03-26 03:33:00 +00:00
hashed , err := auth . ParsePasswordString ( user . Password )
2021-03-22 03:07:18 +00:00
if err != nil {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to parse password string" ) )
2021-03-22 03:07:18 +00:00
}
2021-03-26 03:33:00 +00:00
passwordsMatch , err := auth . CheckPassword ( password , hashed )
2021-03-22 03:07:18 +00:00
if err != nil {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to check password against hash" ) )
2021-03-22 03:07:18 +00:00
}
if passwordsMatch {
2021-03-28 15:32:30 +00:00
// re-hash and save the user's password if necessary
if hashed . IsOutdated ( ) {
newHashed , err := auth . HashPassword ( password )
if err == nil {
2021-04-06 05:06:19 +00:00
err := auth . UpdatePassword ( c . Context ( ) , c . Conn , username , newHashed )
2021-03-28 15:32:30 +00:00
if err != nil {
c . Logger . Error ( ) . Err ( err ) . Msg ( "failed to update user's password" )
}
} else {
c . Logger . Error ( ) . Err ( err ) . Msg ( "failed to re-hash password" )
}
// If errors happen here, we can still continue with logging them in
}
2021-04-06 05:06:19 +00:00
session , err := auth . CreateSession ( c . Context ( ) , c . Conn , username )
2021-03-27 21:10:11 +00:00
if err != nil {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to create session" ) )
2021-03-27 21:10:11 +00:00
}
2021-04-06 03:30:11 +00:00
res := c . Redirect ( redirect , http . StatusSeeOther )
res . SetCookie ( auth . NewSessionCookie ( session ) )
return res
2021-03-22 03:07:18 +00:00
} else {
2021-04-06 03:30:11 +00:00
return c . Redirect ( "/" , http . StatusSeeOther ) // TODO: Redirect to standalone login page with error
2021-03-22 03:07:18 +00:00
}
}
2021-04-06 05:06:19 +00:00
func Logout ( c * RequestContext ) ResponseData {
2021-03-27 21:27:40 +00:00
sessionCookie , err := c . Req . Cookie ( auth . SessionCookieName )
if err == nil {
// clear the session from the db immediately, no expiration
2021-04-06 05:06:19 +00:00
err := auth . DeleteSession ( c . Context ( ) , c . Conn , sessionCookie . Value )
2021-03-27 21:27:40 +00:00
if err != nil {
logging . Error ( ) . Err ( err ) . Msg ( "failed to delete session on logout" )
}
}
2021-04-06 03:30:11 +00:00
res := c . Redirect ( "/" , http . StatusSeeOther ) // TODO: Redirect to the page the user was currently on, or if not authorized to view that page, immediately to the home page.
res . SetCookie ( auth . DeleteSessionCookie )
return res
2021-03-27 21:27:40 +00:00
}
2021-04-06 05:06:19 +00:00
func FourOhFour ( c * RequestContext ) ResponseData {
2021-04-06 03:30:11 +00:00
return ResponseData {
StatusCode : http . StatusNotFound ,
2021-04-06 05:06:19 +00:00
Body : bytes . NewBufferString ( "go away\n" ) ,
2021-04-06 03:30:11 +00:00
}
2021-03-31 04:20:50 +00:00
}
2021-04-06 05:06:19 +00:00
func ErrorLoggingHandler ( c * RequestContext , res ResponseData ) ResponseData {
for _ , err := range res . Errors {
c . Logger . Error ( ) . Err ( err ) . Msg ( "error occurred during request" )
2021-03-22 03:07:18 +00:00
}
2021-04-06 05:06:19 +00:00
return res
}
2021-03-27 21:10:11 +00:00
2021-04-06 05:06:19 +00:00
func CommonWebsiteDataWrapper ( c * RequestContext ) ( bool , ResponseData ) {
// 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-04-06 05:06:19 +00:00
c . CurrentProject = dbProject
}
2021-03-21 20:38:37 +00:00
2021-04-06 05:06:19 +00:00
sessionCookie , err := c . Req . Cookie ( auth . SessionCookieName )
if err == nil {
2021-04-17 00:01:13 +00:00
user , err := getCurrentUser ( c , sessionCookie . Value )
2021-04-06 05:06:19 +00:00
if err != nil {
2021-04-17 00:01:13 +00:00
return false , ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to get current user" ) )
2021-03-27 21:10:11 +00:00
}
2021-03-21 20:38:37 +00:00
2021-04-06 05:06:19 +00:00
c . CurrentUser = user
2021-03-21 20:38:37 +00:00
}
2021-04-06 05:06:19 +00:00
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
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
}