🌱🏠 instant least-authority port-forwarding (with automatic HTTPS) for anyone, anywhere! We **really** don't want your TLS private keys, you can keep them 😃 https://greenhouse.server.garden/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

569 lines
18 KiB

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))
// }