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"
)
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-04-06 03:30:11 +00:00
mainRoutes . GET ( "/" , func ( c * RequestContext ) ResponseData {
if c . CurrentProject . ID == models . HMNProjectID {
return routes . 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-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-04-06 03:30:11 +00:00
routes . HttpRouter . NotFound = mainRoutes . WrapHandler ( routes . FourOhFour )
2021-03-31 04:20:50 +00:00
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
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-03-18 01:25:06 +00:00
Project : templates . Project {
2021-04-06 03:30:11 +00:00
Name : * c . CurrentProject . Name ,
Subdomain : * c . CurrentProject . Slug ,
Color : c . CurrentProject . Color1 ,
2021-03-18 01:25:06 +00:00
2021-04-06 03:30:11 +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-04-06 03:30:11 +00:00
func ( s * websiteRoutes ) Project ( c * RequestContext ) ResponseData {
id := c . PathParams . ByName ( "id" )
row := s . 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 03:30:11 +00:00
func ( s * websiteRoutes ) 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 03:30:11 +00:00
func ( s * websiteRoutes ) 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-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 ) {
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 {
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 {
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 03:30:11 +00:00
func ( s * websiteRoutes ) 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
err := auth . DeleteSession ( c . Context ( ) , s . conn , sessionCookie . Value )
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 03:30:11 +00:00
func ( s * websiteRoutes ) FourOhFour ( c * RequestContext ) ResponseData {
return ResponseData {
StatusCode : http . StatusNotFound ,
Body : bytes . NewBuffer ( [ ] byte ( "go away\n" ) ) ,
}
2021-03-31 04:20:50 +00:00
}
2021-03-22 03:07:18 +00:00
func ErrorLoggingWrapper ( h HMNHandler ) HMNHandler {
2021-04-06 03:30:11 +00:00
return func ( c * RequestContext ) ResponseData {
res := h ( c )
2021-03-22 03:07:18 +00:00
2021-04-06 03:30:11 +00:00
for _ , err := range res . Errors {
2021-03-22 03:07:18 +00:00
c . Logger . Error ( ) . Err ( err ) . Msg ( "error occurred during request" )
}
2021-04-06 03:30:11 +00:00
return res
2021-03-22 03:07:18 +00:00
}
}
2021-03-21 20:38:37 +00:00
func ( s * websiteRoutes ) CommonWebsiteDataWrapper ( h HMNHandler ) HMNHandler {
2021-04-06 03:30:11 +00:00
return func ( c * RequestContext ) ResponseData {
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 {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to fetch current project" ) )
2021-03-27 21:10:11 +00:00
}
2021-04-06 03:30:11 +00:00
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 {
2021-04-06 03:30:11 +00:00
return ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to get current user and member" ) )
2021-03-27 21:10:11 +00:00
}
2021-03-21 20:38:37 +00:00
2021-04-06 03:30:11 +00:00
c . CurrentUser = user
2021-03-27 21:10:11 +00:00
}
// 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 03:30:11 +00:00
return h ( c )
2021-03-21 20:38:37 +00:00
}
}
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
}