package main
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
markdown "github.com/gomarkdown/markdown"
markdown_to_html "github.com/gomarkdown/markdown/html"
"github.com/gorilla/mux"
"github.com/shengdoushi/base58"
)
type Session struct {
SessionId string
TenantId int
Email string
EmailVerified bool
LaxCookie bool
APIToken string
Expires time . Time
Flash * map [ string ] string
}
type ApplicationAuthSession struct {
TenantId int
Name string
}
type FrontendApp struct {
Port int
TLSCertificate string
TLSKey string
Domain string
WorkingDirectory string
LokiURL string
HomepageMarkdownURL string
EnableRegistration bool
Router * mux . Router
EmailService * EmailService
Model * DBModel
Backend * BackendApp
Ingress * IngressService
HTMLTemplates map [ string ] * template . Template
PasswordHashSalt string
SessionCache map [ string ] * Session
SessionIdByTenantId map [ int ] string
SessionCacheMutex * sync . Mutex
AdminTenantId int
httpClient * http . Client
homepageHTML string
cssHash string
basicURLPathRegex * regexp . Regexp
applicationAuthSessions map [ string ] * ApplicationAuthSession
}
func initFrontend ( workingDirectory string , config * Config , model * DBModel , backend * BackendApp , emailService * EmailService , ingress * IngressService ) FrontendApp {
cssBytes , err := ioutil . ReadFile ( filepath . Join ( "." , "frontend" , "static" , "greenhouse.css" ) )
if err != nil {
panic ( errors . Wrap ( err , "can't initFrontend because can't read cssBytes:" ) )
}
hashArray := sha256 . Sum256 ( cssBytes )
cssHash := base58 . Encode ( hashArray [ : 6 ] , base58 . BitcoinAlphabet )
app := FrontendApp {
Port : config . FrontendPort ,
TLSCertificate : config . FrontendTLSCertificate ,
TLSKey : config . FrontendTLSKey ,
Domain : config . FrontendDomain ,
AdminTenantId : config . AdminTenantId ,
HomepageMarkdownURL : config . HomepageMarkdownURL ,
LokiURL : config . LokiURL ,
EnableRegistration : config . EnableRegistration ,
WorkingDirectory : workingDirectory ,
Router : mux . NewRouter ( ) ,
EmailService : emailService ,
Model : model ,
Backend : backend ,
Ingress : ingress ,
HTMLTemplates : map [ string ] * template . Template { } ,
PasswordHashSalt : "Ko0jOdSCzEyDtK4rmoocfcR9LxwOrIZsaVPBjImkb6AhRW6yNSmgsU122ArU1URBjcJ1EnskZ5r7" ,
SessionCache : map [ string ] * Session { } ,
SessionIdByTenantId : map [ int ] string { } ,
SessionCacheMutex : & sync . Mutex { } ,
basicURLPathRegex : regexp . MustCompile ( "(?i)[a-z0-9/?&_+-]+" ) ,
applicationAuthSessions : map [ string ] * ApplicationAuthSession { } ,
homepageHTML : "<h3>Error: <code>homepageMarkdown</code> is missing</h3>" ,
cssHash : cssHash ,
httpClient : & http . Client {
Timeout : time . Second * 5 ,
} ,
}
// poll the HomepageMarkdownURL and update the homepageHTML
go func ( ) {
defer ( func ( ) {
if r := recover ( ) ; r != nil {
go postTelemetry ( "greenhouse-web" , "" , "admin" , strconv . Itoa ( config . AdminTenantId ) , fmt . Sprintf ( "HomepageMarkdown poller panic: %s" , r ) )
fmt . Printf ( "HomepageMarkdown poller: panic: %+v\n" , r )
debug . PrintStack ( )
}
} ) ( )
markdownRenderer := markdown_to_html . NewRenderer ( markdown_to_html . RendererOptions {
Flags : markdown_to_html . CommonFlags | markdown_to_html . HrefTargetBlank ,
} )
for {
response , err := app . httpClient . Get ( app . HomepageMarkdownURL )
if err == nil && response . StatusCode < 299 {
homepageMarkdownBytes , err := ioutil . ReadAll ( response . Body )
if err == nil {
app . homepageHTML = string ( markdown . ToHTML ( homepageMarkdownBytes , nil , markdownRenderer ) )
}
}
time . Sleep ( time . Minute )
}
} ( )
// serve the homepage
app . handleWithSessionNotRequired ( "/" , func ( responseWriter http . ResponseWriter , request * http . Request , session Session ) {
homepageData := struct {
DynamicContent template . HTML
} { template . HTML ( app . homepageHTML ) }
pageContent , err := app . renderTemplateToHTML ( "index.html" , homepageData )
if err != nil {
app . unhandledError ( responseWriter , request , err )
return
}
highlightContent , err := app . renderTemplateToHTML ( "index-highlight.html" , nil )
if err != nil {
app . unhandledError ( responseWriter , request , err )
return
}
app . buildPage ( responseWriter , request , session , highlightContent , pageContent , "" )
} )
registerHowtoRoutes ( & app )
registerLoginRoutes ( & app , emailService )
registerProfileRoutes ( & app )
registerAdminPanelRoutes ( & app )
app . reloadTemplates ( )
staticFilesDir := filepath . Join ( workingDirectory , "frontend" , "static" )
log . Printf ( "serving static files from %s" , staticFilesDir )
app . Router . PathPrefix ( "/static/" ) . Handler ( http . StripPrefix ( "/static/" , http . FileServer ( http . Dir ( staticFilesDir ) ) ) )
releasesDir := filepath . Join ( workingDirectory , "releases" )
log . Printf ( "serving releases from %s" , releasesDir )
// https://stackoverflow.com/questions/49589685/good-way-to-disable-directory-listing-with-http-fileserver-in-go
noDirectoryListingHTTPDir := justFilesFilesystem { fs : http . Dir ( releasesDir ) , readDirBatchSize : 20 }
releasesStaticFileHandler := http . StripPrefix ( "/releases/" , http . FileServer ( noDirectoryListingHTTPDir ) )
releasesRegexp := regexp . MustCompile ( "greenhouse|threshold|caddy" )
app . Router . PathPrefix ( "/releases/" ) . HandlerFunc ( func ( responseWriter http . ResponseWriter , request * http . Request ) {
if ! releasesRegexp . MatchString ( strings . ToLower ( request . URL . Path ) ) {
http . Error ( responseWriter , "404 not found" , http . StatusNotFound )
return
}
go postTelemetryFromRequest ( "release-download" , request , & app , request . URL . Path )
releasesStaticFileHandler . ServeHTTP ( responseWriter , request )
} )
return app
}
func ( app * FrontendApp ) ListenAndServe ( ) error {
if app . TLSKey != "" && app . TLSCertificate != "" {
return http . ListenAndServeTLS ( fmt . Sprintf ( ":%d" , app . Port ) , app . TLSCertificate , app . TLSKey , app . Router )
} else {
return http . ListenAndServe ( fmt . Sprintf ( ":%d" , app . Port ) , app . Router )
}
}
func ( app * FrontendApp ) setCookie ( responseWriter http . ResponseWriter , name , value string , lifetimeSeconds int , sameSite http . SameSite ) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent
// The Domain attribute specifies which hosts are allowed to receive the cookie.
// If unspecified, it defaults to the same host that set the cookie, excluding subdomains.
// If Domain is specified, then subdomains are always included.
// Therefore, specifying Domain is less restrictive than omitting it.
// However, it can be helpful when subdomains need to share information about a user.
toSet := & http . Cookie {
Name : name ,
HttpOnly : true ,
Secure : true ,
SameSite : sameSite ,
Path : "/" ,
Value : value ,
MaxAge : lifetimeSeconds ,
}
http . SetCookie ( responseWriter , toSet )
}
func ( app * FrontendApp ) deleteCookie ( responseWriter http . ResponseWriter , name string ) {
http . SetCookie ( responseWriter , & http . Cookie {
Name : name ,
HttpOnly : true ,
Secure : true ,
SameSite : http . SameSiteLaxMode ,
Path : "/" ,
Value : "" ,
MaxAge : - 1 ,
} )
}
func ( app * FrontendApp ) getSession ( request * http . Request , domain string ) ( Session , error ) {
toReturn := Session {
Flash : & ( map [ string ] string { } ) ,
}
for _ , cookie := range request . Cookies ( ) {
//log.Printf("getSession %t: %s: %s\n", toReturn.SessionId == "", cookie.Name, cookie.Value)
if cookie . Name == "sessionId" || ( cookie . Name == "sessionIdLax" && toReturn . SessionId == "" ) {
app . SessionCacheMutex . Lock ( )
session , hasSession := app . SessionCache [ cookie . Value ]
app . SessionCacheMutex . Unlock ( )
if hasSession {
if time . Now ( ) . Before ( session . Expires ) && ( cookie . Name != "sessionIdLax" || session . LaxCookie ) {
toReturn . SessionId = cookie . Value
toReturn . TenantId = session . TenantId
toReturn . Email = session . Email
toReturn . EmailVerified = session . EmailVerified
toReturn . LaxCookie = session . LaxCookie
toReturn . Expires = session . Expires
continue
}
}
session , err := app . Model . GetSession ( cookie . Value , cookie . Name == "sessionIdLax" )
if err != nil {
log . Printf ( "can't getSession because can't query session from database: %+v" , err )
return toReturn , err
}
if session != nil {
app . SessionCacheMutex . Lock ( )
existingSession , hasExisting := app . SessionIdByTenantId [ session . TenantId ]
if hasExisting {
delete ( app . SessionCache , existingSession )
}
app . SessionIdByTenantId [ session . TenantId ] = cookie . Value
app . SessionCache [ cookie . Value ] = session
app . SessionCacheMutex . Unlock ( )
toReturn . SessionId = cookie . Value
toReturn . TenantId = session . TenantId
toReturn . Email = session . Email
toReturn . EmailVerified = session . EmailVerified
toReturn . LaxCookie = session . LaxCookie
toReturn . Expires = session . Expires
}
//log.Printf("toReturn.SessionId %s\n", toReturn.SessionId)
} else if cookie . Name == "flash" && cookie . Value != "" {
bytes , err := base64 . RawURLEncoding . DecodeString ( cookie . Value )
if err != nil {
log . Printf ( "can't getSession because can't base64 decode flash cookie: %+v" , err )
return toReturn , err
}
flash := map [ string ] string { }
err = json . Unmarshal ( bytes , & flash )
if err != nil {
log . Printf ( "can't getSession because can't json parse the decoded flash cookie: %+v" , err )
return toReturn , err
}
toReturn . Flash = & flash
}
}
return toReturn , nil
}
func ( app * FrontendApp ) setSession ( responseWriter http . ResponseWriter , session * Session ) error {
sessionIdBuffer := make ( [ ] byte , 32 )
rand . Read ( sessionIdBuffer )
sessionId := base64 . RawURLEncoding . EncodeToString ( sessionIdBuffer )
err := app . Model . SetSession ( sessionId , session )
if err != nil {
return err
}
bytes , _ := json . MarshalIndent ( session , "" , " " )
log . Printf ( "setSession(): %s %s\n" , sessionId , string ( bytes ) )
app . SessionCacheMutex . Lock ( )
existingSession , hasExisting := app . SessionIdByTenantId [ session . TenantId ]
if hasExisting {
delete ( app . SessionCache , existingSession )
}
app . SessionIdByTenantId [ session . TenantId ] = sessionId
app . SessionCache [ sessionId ] = session
app . SessionCacheMutex . Unlock ( )
exipreInSeconds := int ( session . Expires . Sub ( time . Now ( ) ) . Seconds ( ) )
if session . LaxCookie {
app . setCookie ( responseWriter , "sessionIdLax" , sessionId , exipreInSeconds , http . SameSiteLaxMode )
} else {
app . setCookie ( responseWriter , "sessionId" , sessionId , exipreInSeconds , http . SameSiteStrictMode )
}
return nil
}
func ( app * FrontendApp ) unhandledError ( responseWriter http . ResponseWriter , request * http . Request , err error ) {
log . Printf ( "500 internal server error: %+v\n" , err )
go postTelemetryFromRequest ( "unhandled-error" , request , app , fmt . Sprintf ( "%s: %s" , request . URL . Path , err ) )
responseWriter . Header ( ) . Add ( "Content-Type" , "text/plain" )
responseWriter . WriteHeader ( http . StatusInternalServerError )
responseWriter . Write ( [ ] byte ( "500 internal server error" ) )
}
func ( app * FrontendApp ) handleWithSpecificUser ( path string , userId int , handler func ( http . ResponseWriter , * http . Request , Session ) ) {
//log.Printf("handleWithSpecificUser: %d", userId)
if userId == 0 {
panic ( "handleWithSpecificUser called with userId 0: this is a security issue!" )
}
app . handleWithSessionImpl ( path , true , userId , handler )
}
func ( app * FrontendApp ) handleWithSession ( path string , handler func ( http . ResponseWriter , * http . Request , Session ) ) {
app . handleWithSessionImpl ( path , true , 0 , handler )
}
func ( app * FrontendApp ) handleWithSessionNotRequired ( path string , handler func ( http . ResponseWriter , * http . Request , Session ) ) {
app . handleWithSessionImpl ( path , false , 0 , handler )
}
func ( app * FrontendApp ) handleWithSessionImpl ( path string , required bool , requireUserId int , handler func ( http . ResponseWriter , * http . Request , Session ) ) {
app . Router . HandleFunc ( path , func ( responseWriter http . ResponseWriter , request * http . Request ) {
session , err := app . getSession ( request , app . Domain )
bytes , _ := json . MarshalIndent ( session , "" , " " )
log . Printf ( "handleWithSession(): %s\n" , string ( bytes ) )
if err != nil {
app . unhandledError ( responseWriter , request , err )
} else {
log . Printf ( "%d, %d, %t, %t" , requireUserId , session . TenantId , requireUserId != 0 , session . TenantId != requireUserId )
if ( required && session . TenantId == 0 ) || ( requireUserId != 0 && session . TenantId != requireUserId ) {
pathAndQuery := request . URL . Path
query := request . URL . Query ( ) . Encode ( )
if query != "" {
pathAndQuery = fmt . Sprintf ( "%s?%s" , request . URL . Path , query )
}
// anti-XSS: only set returnTo if it matches a basic url pattern
if app . basicURLPathRegex . MatchString ( pathAndQuery ) {
msg := fmt . Sprintf ( "Please log in in order to access %s%s" , app . Domain , request . URL . Path )
app . setFlash ( responseWriter , session , "info" , msg )
app . setFlash ( responseWriter , session , "returnTo" , pathAndQuery )
}
http . Redirect ( responseWriter , request , "/login" , http . StatusFound )
return
}
handler ( responseWriter , request , session )
}
} )
}
func ( app * FrontendApp ) buildPage ( responseWriter http . ResponseWriter , request * http . Request , session Session , highlight , page template . HTML , pageClass string ) {
var buffer bytes . Buffer
templateName := "page.html"
pageTemplate , hasPageTemplate := app . HTMLTemplates [ templateName ]
if ! hasPageTemplate {
panic ( fmt . Errorf ( "template '%s' not found!" , templateName ) )
}
err := pageTemplate . Execute (
& buffer ,
struct {
Session Session
Highlight template . HTML
Page template . HTML
PageClass string
CSSHash string
} { session , highlight , page , pageClass , app . cssHash } ,
)
app . deleteCookie ( responseWriter , "flash" )
if err != nil {
app . unhandledError ( responseWriter , request , err )
} else {
io . Copy ( responseWriter , & buffer )
}
}
func ( app * FrontendApp ) renderTemplateToHTML ( templateName string , data interface { } ) ( template . HTML , error ) {
var buffer bytes . Buffer
desiredTemplate , hasTemplate := app . HTMLTemplates [ templateName ]
if ! hasTemplate {
return "" , fmt . Errorf ( "template '%s' not found!" , templateName )
}
err := desiredTemplate . Execute ( & buffer , data )
if err != nil {
return "" , err
}
return template . HTML ( buffer . String ( ) ) , nil
}
func ( app * FrontendApp ) buildPageFromTemplate ( responseWriter http . ResponseWriter , request * http . Request , session Session , templateName string , data interface { } ) {
app . buildPageFromTemplateWithClass ( responseWriter , request , session , templateName , data , "" )
}
func ( app * FrontendApp ) buildPageFromTemplateWithClass ( responseWriter http . ResponseWriter , request * http . Request , session Session , templateName string , data interface { } , pageClass string ) {
content , err := app . renderTemplateToHTML ( templateName , data )
if err != nil {
app . unhandledError ( responseWriter , request , err )
} else {
app . buildPage ( responseWriter , request , session , template . HTML ( "" ) , content , pageClass )
}
}
func ( app * FrontendApp ) setFlash ( responseWriter http . ResponseWriter , session Session , key , value string ) {
( * session . Flash ) [ key ] += value
bytes , err := json . Marshal ( ( * session . Flash ) )
if err != nil {
log . Printf ( "can't setFlash because can't json marshal the flash map: %+v" , err )
return
}
app . setCookie ( responseWriter , "flash" , base64 . RawURLEncoding . EncodeToString ( bytes ) , 60 , http . SameSiteStrictMode )
}
func ( app * FrontendApp ) reloadTemplates ( ) {
loadTemplate := func ( filename string ) * template . Template {
newTemplateString , err := ioutil . ReadFile ( filename )
if err != nil {
panic ( err )
}
newTemplate , err := template . New ( filename ) . Parse ( string ( newTemplateString ) )
if err != nil {
panic ( err )
}
return newTemplate
}
frontendDirectory := filepath . Join ( app . WorkingDirectory , "frontend" )
//frontendVersion = hashTemplateAndStaticFiles(frontendDirectory)[:6]
fileInfos , err := ioutil . ReadDir ( frontendDirectory )
if err != nil {
panic ( err )
}
for _ , fileInfo := range fileInfos {
if ! fileInfo . IsDir ( ) && strings . Contains ( fileInfo . Name ( ) , ".gotemplate" ) {
app . HTMLTemplates [ strings . Replace ( fileInfo . Name ( ) , ".gotemplate" , "" , 1 ) ] = loadTemplate ( filepath . Join ( frontendDirectory , fileInfo . Name ( ) ) )
}
}
}
type justFilesFilesystem struct {
fs http . FileSystem
// readDirBatchSize - configuration parameter for `Readdir` func
readDirBatchSize int
}
func ( fs justFilesFilesystem ) Open ( name string ) ( http . File , error ) {
f , err := fs . fs . Open ( name )
if err != nil {
return nil , err
}
return neuteredStatFile { File : f , readDirBatchSize : fs . readDirBatchSize } , nil
}
type neuteredStatFile struct {
http . File
readDirBatchSize int
}
func ( e neuteredStatFile ) Stat ( ) ( os . FileInfo , error ) {
s , err := e . File . Stat ( )
if err != nil {
return nil , err
}
if s . IsDir ( ) {
LOOP :
for {
fl , err := e . File . Readdir ( e . readDirBatchSize )
switch err {
case io . EOF :
break LOOP
case nil :
for _ , f := range fl {
if f . Name ( ) == "index.html" {
return s , err
}
}
default :
return nil , err
}
}
return nil , os . ErrNotExist
}
return s , err
}
// func hashTemplateAndStaticFiles(workingDirectory string) string {
// filenameMatch := regexp.MustCompile("(\\.gotemplate)|(\\.html)|(\\.css)|(\\.js)$")
// toHash := map[string]bool{}
// var getFileNamesRecurse func(workingDirectory string, path string, depth int)
// getFileNamesRecurse = func(workingDirectory string, path string, depth int) {
// if depth > 10 {
// panic(errors.New("too much recursion inside hashTemplateAndStaticFiles()"))
// }
// fileInfos, err := ioutil.ReadDir(filepath.Join(workingDirectory, path))
// if err != nil {
// panic(err)
// }
// for _, fileInfo := range fileInfos {
// if fileInfo.IsDir() {
// getFileNamesRecurse(workingDirectory, filepath.Join(path, fileInfo.Name()), depth+1)
// } else if filenameMatch.Match([]byte(fileInfo.Name())) {
// toHash[filepath.Join(path, fileInfo.Name())] = true
// }
// }
// }
// toHashSlice := sort.StringSlice(make([]string, len(toHash)))
// i := 0
// for filename := range toHash {
// toHashSlice[i] = filename
// i++
// }
// toHashSlice.Sort()
// hash := sha256.New()
// for _, filename := range toHashSlice {
// fileContents, err := ioutil.ReadFile(filepath.Join(workingDirectory, filename))
// if err != nil {
// panic(err)
// }
// hash.Write([]byte(fileContents))
// }
// return fmt.Sprintf("%x", hash.Sum(nil))
// }