2021-03-14 20:49:58 +00:00
package website
import (
"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"
)
type websiteRoutes struct {
2021-03-18 02:14:06 +00:00
* HMNRouter
2021-03-14 20:49:58 +00:00
conn * pgxpool . Pool
}
func NewWebsiteRoutes ( conn * pgxpool . Pool ) http . Handler {
routes := & websiteRoutes {
2021-03-22 03:07:18 +00:00
HMNRouter : & HMNRouter {
HttpRouter : httprouter . New ( ) ,
Wrappers : [ ] HMNHandlerWrapper { ErrorLoggingWrapper } ,
} ,
conn : conn ,
2021-03-14 20:49:58 +00:00
}
2021-03-21 20:38:37 +00:00
mainRoutes := routes . WithWrappers ( routes . CommonWebsiteDataWrapper )
2021-03-31 03:55:19 +00:00
mainRoutes . GET ( "/" , func ( c * RequestContext , p httprouter . Params ) {
if c . currentProject . ID == models . HMNProjectID {
routes . Index ( c , p )
} else {
// TODO: Return the project landing page
}
} )
2021-03-21 20:38:37 +00:00
mainRoutes . GET ( "/project/:id" , routes . Project )
mainRoutes . GET ( "/assets/project.css" , routes . ProjectCSS )
2021-03-22 03:07:18 +00:00
routes . POST ( "/login" , routes . Login )
2021-03-27 21:27:40 +00:00
routes . GET ( "/logout" , routes . Logout )
2021-03-22 03:07:18 +00:00
2021-03-18 01:25:06 +00:00
routes . ServeFiles ( "/public/*filepath" , http . Dir ( "public" ) )
2021-03-14 20:49:58 +00:00
2021-03-31 04:20:50 +00:00
routes . HttpRouter . NotFound = MakeStdHandler ( mainRoutes . WrapHandler ( routes . FourOhFour ) , "404" )
2021-03-14 20:49:58 +00:00
return routes
}
2021-03-21 20:38:37 +00:00
func ( s * websiteRoutes ) getBaseData ( c * RequestContext ) templates . BaseData {
2021-03-27 21:10:11 +00:00
var templateUser * templates . User
if c . currentUser != nil {
templateUser = & templates . User {
Username : c . currentUser . Username ,
Email : c . currentUser . Email ,
IsSuperuser : c . currentUser . IsSuperuser ,
IsStaff : c . currentUser . IsStaff ,
}
}
2021-03-21 20:38:37 +00:00
return templates . BaseData {
2021-03-18 01:25:06 +00:00
Project : templates . Project {
2021-03-31 03:55:19 +00:00
Name : * c . currentProject . Name ,
Subdomain : * c . currentProject . Slug ,
2021-03-21 20:38:37 +00:00
Color : c . currentProject . Color1 ,
2021-03-18 01:25:06 +00:00
2021-03-21 20:38:37 +00:00
IsHMN : c . currentProject . IsHMN ( ) ,
2021-03-18 01:25:06 +00:00
HasBlog : true ,
HasForum : true ,
HasWiki : true ,
HasLibrary : true ,
} ,
2021-03-27 21:10:11 +00:00
User : templateUser ,
2021-03-18 01:25:06 +00:00
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-03-18 02:14:06 +00:00
func ( s * websiteRoutes ) Project ( c * RequestContext , p httprouter . Params ) {
2021-03-14 20:49:58 +00:00
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 )
}
2021-03-18 02:14:06 +00:00
c . Body . Write ( [ ] byte ( fmt . Sprintf ( "(%s) %s\n" , id , name ) ) )
2021-03-14 20:49:58 +00:00
}
2021-03-18 02:14:06 +00:00
func ( s * websiteRoutes ) ProjectCSS ( c * RequestContext , p httprouter . Params ) {
color := c . URL ( ) . Query ( ) . Get ( "color" )
2021-03-14 20:49:58 +00:00
if color == "" {
2021-03-18 02:14:06 +00:00
c . StatusCode = http . StatusBadRequest
c . Body . Write ( [ ] byte ( "You must provide a 'color' parameter.\n" ) )
2021-03-14 20:49:58 +00:00
return
}
templateData := struct {
Color string
Theme string
} {
Color : color ,
Theme : "dark" ,
}
2021-03-18 02:14:06 +00:00
c . Headers ( ) . Add ( "Content-Type" , "text/css" )
err := c . WriteTemplate ( "project.css" , templateData )
2021-03-14 20:49:58 +00:00
if err != nil {
2021-03-21 20:38:37 +00:00
c . Logger . Error ( ) . Err ( err ) . Msg ( "failed to generate project CSS" )
2021-03-14 20:49:58 +00:00
return
}
}
2021-03-21 20:38:37 +00:00
2021-03-22 03:07:18 +00:00
func ( s * websiteRoutes ) Login ( c * RequestContext , p httprouter . Params ) {
// 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-03-26 03:33:00 +00:00
c . Errored ( http . StatusBadRequest , NewSafeError ( err , "request must contain form data" ) )
return
}
username := form . Get ( "username" )
password := form . Get ( "password" )
if username == "" || password == "" {
c . Errored ( http . StatusBadRequest , NewSafeError ( err , "you must provide both a username and password" ) )
}
redirect := form . Get ( "redirect" )
if redirect == "" {
redirect = "/"
2021-03-22 03:07:18 +00:00
}
2021-03-31 03:55:19 +00:00
userRow , err := db . QueryOne ( c . Context ( ) , s . 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 ) {
c . StatusCode = http . StatusUnauthorized
} else {
c . Errored ( http . StatusInternalServerError , oops . New ( err , "failed to look up user by username" ) )
}
return
}
2021-03-31 03:55:19 +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 {
c . Errored ( http . StatusInternalServerError , oops . New ( err , "failed to parse password string" ) )
return
}
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 {
c . Errored ( http . StatusInternalServerError , oops . New ( err , "failed to check password against hash" ) )
return
}
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 {
err := auth . UpdatePassword ( c . Context ( ) , s . conn , username , newHashed )
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-03-27 21:10:11 +00:00
session , err := auth . CreateSession ( c . Context ( ) , s . conn , username )
if err != nil {
c . Errored ( http . StatusInternalServerError , oops . New ( err , "failed to create session" ) )
return
}
c . SetCookie ( auth . NewSessionCookie ( session ) )
2021-03-26 03:33:00 +00:00
c . Redirect ( redirect , http . StatusSeeOther )
return
2021-03-22 03:07:18 +00:00
} else {
2021-03-26 03:33:00 +00:00
c . Redirect ( "/" , http . StatusSeeOther ) // TODO: Redirect to standalone login page with error
return
2021-03-22 03:07:18 +00:00
}
}
2021-03-27 21:27:40 +00:00
func ( s * websiteRoutes ) Logout ( c * RequestContext , p httprouter . Params ) {
sessionCookie , err := c . Req . Cookie ( auth . SessionCookieName )
if err == nil {
// clear the session from the db immediately, no expiration
err := auth . DeleteSession ( c . Context ( ) , s . conn , sessionCookie . Value )
if err != nil {
logging . Error ( ) . Err ( err ) . Msg ( "failed to delete session on logout" )
}
}
c . SetCookie ( auth . DeleteSessionCookie )
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.
}
2021-03-31 04:20:50 +00:00
func ( s * websiteRoutes ) FourOhFour ( c * RequestContext , p httprouter . Params ) {
c . StatusCode = http . StatusNotFound
c . Body . Write ( [ ] byte ( "go away\n" ) )
}
2021-03-22 03:07:18 +00:00
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" )
}
}
}
2021-03-21 20:38:37 +00:00
func ( s * websiteRoutes ) CommonWebsiteDataWrapper ( h HMNHandler ) HMNHandler {
return func ( c * RequestContext , p httprouter . Params ) {
2021-03-27 21:10:11 +00:00
// get project
{
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
2021-03-21 20:38:37 +00:00
}
2021-03-27 21:10:11 +00:00
sessionCookie , err := c . Req . Cookie ( auth . SessionCookieName )
if err == nil {
user , err := s . getCurrentUserAndMember ( c . Context ( ) , sessionCookie . Value )
if err != nil {
c . Errored ( http . StatusInternalServerError , oops . New ( err , "failed to get current user and member" ) )
return
}
2021-03-21 20:38:37 +00:00
2021-03-27 21:10:11 +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
h ( c , p )
}
}
2021-03-27 21:10:11 +00:00
// Given a session id, fetches user and member data from the database. Will return nil for
// both if neither can be found, and will only return an error if it's serious.
//
// TODO: actually return members :)
func ( s * websiteRoutes ) getCurrentUserAndMember ( ctx context . Context , sessionId string ) ( * models . User , error ) {
session , err := auth . GetSession ( ctx , s . conn , sessionId )
if err != nil {
if errors . Is ( err , auth . ErrNoSession ) {
return nil , nil
} else {
return nil , oops . New ( err , "failed to get current session" )
}
}
2021-03-31 03:55:19 +00:00
userRow , err := db . QueryOne ( ctx , s . 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
// TODO: Also get the member model
2021-03-31 03:55:19 +00:00
return user , nil
2021-03-27 21:10:11 +00:00
}