a cloud service to enable your own web server (owned by you and running on your computer) to be accessible on the internet in seconds, no credit card required
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.
 
 
 
 

1093 lines
36 KiB

package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"math"
mathRand "math/rand"
"net/http"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/mux"
base58 "github.com/shengdoushi/base58"
chart "github.com/wcharczuk/go-chart/v2"
chartdrawing "github.com/wcharczuk/go-chart/v2/drawing"
)
type Session struct {
SessionId string
TenantId int
Email string
EmailVerified bool
LaxCookie bool
Expires time.Time
Flash *map[string]string
}
type FrontendApp struct {
Port int
TLSCertificate string
TLSKey string
Domain string
WorkingDirectory string
Router *mux.Router
EmailService *EmailService
Model *DBModel
Backend *BackendApp
HTMLTemplates map[string]*template.Template
PasswordHashSalt string
SessionCache map[string]*Session
SessionIdByTenantId map[int]string
SessionCacheMutex *sync.Mutex
basicURLPathRegex *regexp.Regexp
AdminTenantId int
}
func initFrontend(workingDirectory string, config *Config, model *DBModel, backend *BackendApp, emailService *EmailService) FrontendApp {
app := FrontendApp{
Port: config.FrontendPort,
TLSCertificate: config.FrontendTLSCertificate,
TLSKey: config.FrontendTLSKey,
Domain: config.FrontendDomain,
WorkingDirectory: workingDirectory,
Router: mux.NewRouter(),
EmailService: emailService,
Model: model,
Backend: backend,
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/?&_+-]+"),
}
app.handleWithSessionNotRequired("/", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
pageContent, err := app.renderTemplateToHTML("index.html", nil)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
highlightContent, err := app.renderTemplateToHTML("index-highlight.html", nil)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.buildPage(responseWriter, session, highlightContent, pageContent)
})
app.handleWithSessionNotRequired("/login", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
if request.Method == "POST" {
remoteUsersHMAC := request.PostFormValue("hmac")
loginSuccess := false
tenantId, databasesHashedPassword, emailVerified := app.Model.GetLoginInfo(request.PostFormValue("email"))
if remoteUsersHMAC == "" {
saltedPassword := fmt.Sprintf("%s%s", app.PasswordHashSalt, request.PostFormValue("password"))
hashedPasswordByteArray := sha256.Sum256([]byte(saltedPassword))
remoteUsersHashedPassword := base64.StdEncoding.EncodeToString(hashedPasswordByteArray[:])
loginSuccess = (remoteUsersHashedPassword == databasesHashedPassword)
} else {
timestampString := request.PostFormValue("timestamp")
timestamp, err := strconv.ParseInt(timestampString, 10, 64)
elapsedSeconds := time.Now().Unix() - timestamp
if err != nil || elapsedSeconds < 0 || elapsedSeconds > 5*60 {
(*session.Flash)["error"] += "Invalid timestamp\n"
} else {
databasesHashedPasswordBytes, err := base64.StdEncoding.DecodeString(databasesHashedPassword)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
hmacFromDB := hmac.New(sha256.New, databasesHashedPasswordBytes)
hmacFromDB.Write([]byte(timestampString))
hmacFromDBResultBytes := hmacFromDB.Sum(nil)
loginSuccess = (remoteUsersHMAC == base64.StdEncoding.EncodeToString(hmacFromDBResultBytes))
}
}
if loginSuccess {
err := app.setSession(
responseWriter,
&Session{
TenantId: tenantId,
Email: strings.ToLower(request.PostFormValue("email")),
EmailVerified: emailVerified,
// we will use the SameSite Lax cookie policy until the first time that the user re-logs-in AFTER confirming thier
// email address. After that we will use the SameSite Strict cookie policy
// this is a "have your cake and eat it too" heuristic solution to
// https://security.stackexchange.com/questions/220292/preventing-csrf-with-samesite-strict-without-degrading-user-experience
// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#SameSite_attribute
LaxCookie: !emailVerified,
Expires: time.Now().Add(time.Hour),
Flash: &(map[string]string{}),
},
)
if err != nil {
app.unhandledError(responseWriter, err)
} else {
returnTo := "/profile"
if request.PostFormValue("returnTo") != "" && app.basicURLPathRegex.MatchString(request.PostFormValue("returnTo")) {
returnTo = request.PostFormValue("returnTo")
}
http.Redirect(responseWriter, request, returnTo, http.StatusFound)
}
return
} // else
(*session.Flash)["error"] += "Invalid login credentials. Passwords are case-sensitive\n"
}
returnTo := ""
if app.basicURLPathRegex.MatchString((*session.Flash)["returnTo"]) {
returnTo = (*session.Flash)["returnTo"]
}
data := struct {
PasswordHashSalt string
Timestamp string
ReturnTo string
}{
PasswordHashSalt: app.PasswordHashSalt,
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
ReturnTo: returnTo,
}
app.buildPageFromTemplate(responseWriter, session, "login.html", data)
})
app.handleWithSessionNotRequired("/register", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
data := struct {
Email string
PasswordHashSalt string
}{
PasswordHashSalt: app.PasswordHashSalt,
}
if request.Method == "POST" {
data.Email = request.PostFormValue("email")
emailError := emailService.ValidateEmailAddress(data.Email)
if emailError != "" {
(*session.Flash)["error"] += fmt.Sprintln(emailError)
}
hashedPassword := request.PostFormValue("hashedPassword")
if hashedPassword == "" {
password := request.PostFormValue("password")
if len(password) < 6 {
(*session.Flash)["error"] += "password must be at least 6 characters\n"
}
saltedPassword := fmt.Sprintf("%s%s", app.PasswordHashSalt, password)
hashedPasswordByteArray := sha256.Sum256([]byte(saltedPassword))
hashedPassword = base64.StdEncoding.EncodeToString(hashedPasswordByteArray[:])
}
if (*session.Flash)["error"] == "" {
tenantId, err := app.Model.Register(data.Email, hashedPassword)
if err != nil {
(*session.Flash)["error"] += fmt.Sprintln(err)
} else {
emailVerificationTokenBuffer := make([]byte, 4)
rand.Read(emailVerificationTokenBuffer)
emailVerificationToken := hex.EncodeToString(emailVerificationTokenBuffer)
err := app.Model.CreateEmailVerificationToken(emailVerificationToken, tenantId, time.Now().Add(time.Hour))
if err != nil {
app.unhandledError(responseWriter, err)
return
}
err = app.setSession(
responseWriter,
&Session{
TenantId: tenantId,
Email: strings.ToLower(data.Email),
LaxCookie: true,
Expires: time.Now().Add(time.Hour),
Flash: &(map[string]string{}),
},
)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
protocol := "https"
if app.Domain == "localhost" {
protocol = "http"
}
specialPort := ""
if app.Port != 80 && app.Port != 443 {
specialPort = fmt.Sprintf(":%d", app.Port)
}
err = emailService.SendEmail(
fmt.Sprintf("Please verify your account on %s", app.Domain),
data.Email,
fmt.Sprintf(
"Please click the following link to verify your account: %s://%s%s/verify-email/%s",
protocol, app.Domain, specialPort, emailVerificationToken,
),
)
if err != nil {
(*session.Flash)["error"] += fmt.Sprintln(err)
} else {
http.Redirect(responseWriter, request, "/verify-email", http.StatusFound)
return
}
}
}
}
app.buildPageFromTemplate(responseWriter, session, "register.html", data)
})
verifyEmailHandler := func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
if session.EmailVerified {
app.setFlash(responseWriter, session, "info", "Your email address has already been verified")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
log.Printf("verify email handler: [%s] [%d]\n", mux.Vars(request)["token"], session.TenantId)
if mux.Vars(request)["token"] != "" && session.TenantId != 0 {
err := app.Model.VerifyEmail(mux.Vars(request)["token"], session.TenantId)
if err != nil {
(*session.Flash)["error"] += fmt.Sprintln(err)
} else {
err = app.Backend.InitializeTenant(session.TenantId, session.Email)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
session.EmailVerified = true
newSession := &Session{
TenantId: session.TenantId,
Email: session.Email,
EmailVerified: true,
LaxCookie: true,
Expires: time.Now().Add(time.Hour),
Flash: &(map[string]string{}),
}
err = app.setSession(responseWriter, newSession)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.setFlash(responseWriter, session, "info", "Your email address has been verified!")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
}
app.buildPageFromTemplate(responseWriter, session, "verify-email.html", nil)
}
app.handleWithSession("/verify-email", verifyEmailHandler)
app.handleWithSession("/verify-email/{token}", verifyEmailHandler)
app.handleWithSession("/profile", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
if !session.EmailVerified {
// anti-XSS: only set path into the flash cookie if it matches a basic url pattern
if app.basicURLPathRegex.MatchString(request.URL.Path) {
msg := fmt.Sprintf("Please verify your email address in order to access %s%s", app.Domain, request.URL.Path)
app.setFlash(responseWriter, session, "info", msg)
}
http.Redirect(responseWriter, request, "/verify-email", http.StatusFound)
return
}
rawHash := sha256.Sum256([]byte(session.SessionId))
hashOfSessionId := fmt.Sprintf("%x", rawHash[:8])
tenant, err := app.Model.GetTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
billingYear, billingMonth, _, _, _ := getBillingTimeInfo()
usageTotal, err := app.Model.GetTenantUsageTotal(session.TenantId, billingYear, billingMonth)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
apiToken := (*session.Flash)["api-token"]
if request.Method == "POST" {
postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
if postedHashOfSessionId != hashOfSessionId {
app.setFlash(responseWriter, session, "error", "anti-CSRF validation failed\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
action := request.PostFormValue("action")
if action == "reload_api_token" {
apiTokenBuffer := make([]byte, 16)
rand.Read(apiTokenBuffer)
apiToken := base58.Encode(apiTokenBuffer, base58.BitcoinAlphabet)
rawHash := sha256.Sum256([]byte(apiToken))
hashedAPIToken := fmt.Sprintf("%x", rawHash)
err := app.Model.SetHashedAPIToken(session.TenantId, hashedAPIToken)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.setFlash(responseWriter, session, "api-token", apiToken)
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! Your new API Token is %s. It will not be displayed again, so make sure to copy and paste it or write it down now!\n", apiToken))
} else {
app.setFlash(responseWriter, session, "error", "unknown action\n")
}
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
data := struct {
Subdomain string
APIToken string
BytesSoFar string
BillingAlarmSMS string
BillingAlarmEmail string
BillingAlarmThreshold string
BillingLimit string
HashOfSessionId string
}{
Subdomain: tenant.Subdomain,
APIToken: apiToken,
BytesSoFar: ByteCountSI(usageTotal),
BillingAlarmSMS: tenant.SMSAlarmNumber,
BillingAlarmEmail: tenant.Email,
BillingAlarmThreshold: fmt.Sprintf("%.2f", float64(tenant.BillingAlarmCents)/float64(100)),
BillingLimit: fmt.Sprintf("%.2f", float64(tenant.ServiceLimitCents)/float64(100)),
HashOfSessionId: hashOfSessionId,
}
app.buildPageFromTemplate(responseWriter, session, "profile.html", data)
})
app.handleWithSession("/profile/usage_graph.png", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
// tenant, err := app.Model.GetTenant(session.TenantId)
// if err != nil {
// app.unhandledError(responseWriter, err)
// return
// }
_, _, start, end, _ := getBillingTimeInfo()
usageMetrics, err := app.Model.GetTenantUsageMetrics(session.TenantId, start, end)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
type datapoint struct {
Time time.Time
Bytes int64
}
i := 0
usageMetricObjects := make([]datapoint, len(usageMetrics))
for time, bytez := range usageMetrics {
usageMetricObjects[i] = datapoint{Time: time, Bytes: bytez}
i++
}
sort.Slice(usageMetricObjects, func(i, j int) bool {
return usageMetricObjects[j].Time.After(usageMetricObjects[i].Time)
})
xValues := make([]float64, len(usageMetricObjects)+3)
yValues := make([]float64, len(usageMetricObjects)+3)
var total int64 = 0
xValues[0] = float64(start.UnixNano())
yValues[0] = float64(0)
xValues[1] = float64(usageMetricObjects[0].Time.UnixNano() - int64(time.Second*10))
yValues[1] = float64(0)
for i, obj := range usageMetricObjects {
total = total + obj.Bytes
xValues[i+2] = float64(obj.Time.UnixNano())
yValues[i+2] = float64(total)
}
xValues[len(xValues)-1] = float64(end.UnixNano())
yValues[len(yValues)-1] = float64(total)
// it will err if the y scale is from 0 to 0
if total == 0 {
total = 1000
}
graph := chart.Chart{
Width: 400,
Height: 200,
XAxis: chart.XAxis{
ValueFormatter: func(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
timeInstance := time.Unix(0, int64(typed)).UTC()
if timeInstance.After(end) {
timeInstance = end
}
return timeInstance.Format("Jan 2")
}
return ""
},
Range: &chart.ContinuousRange{
Min: float64(start.UnixNano()),
Max: float64(end.UnixNano()),
},
},
YAxis: chart.YAxis{
ValueFormatter: func(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
return ByteCountSI(int64(typed))
}
return ""
},
Range: &chart.ContinuousRange{
Min: float64(0),
Max: float64(total) * float64(1.2),
},
},
Series: []chart.Series{
chart.ContinuousSeries{
Name: "Bytes",
Style: chart.Style{
FillColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 70},
},
YAxis: chart.YAxisPrimary,
XValues: xValues,
YValues: yValues,
},
},
}
//log.Println(graph.YAxis.Range.GetDelta())
buffer := bytes.NewBuffer([]byte{})
err = graph.Render(chart.PNG, buffer)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
responseWriter.Header().Set("Content-Type", "image/png")
responseWriter.Header().Set("Content-Length", strconv.Itoa(buffer.Len()))
_, err = responseWriter.Write(buffer.Bytes())
if err != nil {
log.Printf("http Write error on usage_graph png: %+v", err)
}
})
app.handleWithSessionNotRequired("/logout", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
err := app.Model.LogoutTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.deleteCookie(responseWriter, "sessionId")
app.deleteCookie(responseWriter, "sessionIdLax")
http.Redirect(responseWriter, request, "/", http.StatusFound)
})
app.handleWithSpecificUser("/admin", app.AdminTenantId, func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
rawHash := sha256.Sum256([]byte(session.SessionId))
hashOfSessionId := fmt.Sprintf("%x", rawHash[:8])
if request.Method == "POST" {
postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
log.Println(hashOfSessionId, postedHashOfSessionId)
if postedHashOfSessionId != hashOfSessionId {
app.setFlash(responseWriter, session, "error", "anti-CSRF validation failed\n")
http.Redirect(responseWriter, request, "/admin", http.StatusFound)
return
}
action := request.PostFormValue("action")
if action == "delete-from-db" {
instance := request.PostFormValue("instance")
split := strings.Split(instance, "-")
if len(split) != 2 {
(*session.Flash)["error"] += fmt.Sprintf("invalid instance '%s'. expected <provider>-<provider_id>\n", instance)
} else {
err := app.Model.DeleteVPSInstance(split[0], split[1])
if err != nil {
app.unhandledError(responseWriter, err)
return
}
}
} else if action == "rebalance" {
app.setFlash(responseWriter, session, "info", "rebalance has been kicked off in the background\n")
go (func() {
log.Println("Starting backendApp.Rebalance()")
completed, err := app.Backend.Rebalance()
if err != nil {
log.Printf("Rebalance failed: %+v\n", err)
} else if !completed {
log.Println("Rebalance not complete yet. Running backendApp.Rebalance() again")
_, err := app.Backend.Rebalance()
if err != nil {
log.Printf("Rebalance failed: %+v\n", err)
}
}
})()
} else if action == "configure_threshold_client" {
err := app.Backend.WriteAdminTenantThresholdConfig()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_threshold_client failed: %+v\n", err))
log.Printf("configure_threshold_client failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "wrote threshold client config!\n")
log.Println("wrote threshold client config!")
}
} else if action == "configure_threshold_server" {
err := app.Backend.ConfigureAdminTenantOnThresholdServer()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_threshold_server failed: %+v\n", err))
log.Printf("configure_threshold_server failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "configured threshold server!\n")
log.Println("configured threshold server!")
}
} else {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("Unknown action '%s'\n", action))
}
http.Redirect(responseWriter, request, "/admin", http.StatusFound)
return
}
//TODO
desiredInstancesPerTenant := 2
tenantPinDuration := 6 * time.Hour
billingYear, billingMonth, _, _, amountOfMonthElapsed := getBillingTimeInfo()
validVpsInstances, dbOnlyInstances, cloudOnlyInstances, err := app.Backend.GetInstances()
if err != nil {
app.unhandledError(responseWriter, err)
return
}
tenants, err := app.Model.GetTenants()
if err != nil {
app.unhandledError(responseWriter, err)
return
}
tenantVpsInstanceRows, err := app.Model.GetTenantVPSInstanceRows(billingYear, billingMonth)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
// if you update the following loop, consider updating the similar one in backend.go 😬
allocations := map[string]map[int]bool{}
pinned := map[string]map[int]bool{}
for _, row := range tenantVpsInstanceRows {
vpsInstanceId := row.GetVPSInstanceId()
vpsInstance, hasVPSInstance := validVpsInstances[vpsInstanceId]
if hasVPSInstance {
vpsInstance.Bytes += row.Bytes
_, hasAllocationMap := allocations[vpsInstanceId]
if !hasAllocationMap {
allocations[vpsInstanceId] = map[int]bool{}
pinned[vpsInstanceId] = map[int]bool{}
}
if row.Active {
allocations[vpsInstanceId][row.TenantId] = true
}
if row.DeactivatedAt != nil && row.DeactivatedAt.Add(tenantPinDuration).After(time.Now()) {
pinned[vpsInstanceId][row.TenantId] = true
}
}
}
healthStatus := app.Backend.HealthcheckInstances(validVpsInstances)
type TenantDisplay struct {
Name string
Color template.CSS
}
type Thermometer struct {
Height1 int
Color1 template.CSS
Height2 int
Color2 template.CSS
}
type VPSInstanceForDisplay struct {
VPSInstance
CurrentTenants []TenantDisplay
PinnedTenants []TenantDisplay
ThermometerReal Thermometer
ThermometerProjected Thermometer
Healthy string
}
display := map[string]*VPSInstanceForDisplay{}
for k, v := range validVpsInstances {
healthy := "healthy"
if !healthStatus[k] {
healthy = "unhealthy"
}
mapToTenantDisplay := func(vpsInstances *map[string]map[int]bool, vpsInstanceId string) []TenantDisplay {
toReturn := []TenantDisplay{}
tenantIds, hasTenantIds := (*vpsInstances)[vpsInstanceId]
if hasTenantIds {
for id := range tenantIds {
toReturn = append(toReturn, TenantDisplay{
Name: strconv.Itoa(id),
Color: template.CSS(getTenantColor(id)),
})
}
}
return toReturn
}
display[k] = &VPSInstanceForDisplay{
VPSInstance: *v,
CurrentTenants: mapToTenantDisplay(&allocations, k),
PinnedTenants: mapToTenantDisplay(&pinned, k),
Healthy: healthy,
}
// TODO handle BytesMonthly when node is created in the middle of the month / near end of month
projectedUsage := getInstanceProjectedUsage(v, &allocations, &tenants, desiredInstancesPerTenant, amountOfMonthElapsed)
allowanceBytes := int64(amountOfMonthElapsed * float64(v.BytesMonthly))
usage := (float64(projectedUsage) / float64(v.BytesMonthly)) * float64(0.5)
display[k].ThermometerReal.Color1 = template.CSS("cyan")
if allowanceBytes > v.Bytes {
display[k].ThermometerReal.Color2 = template.CSS("#ddd")
display[k].ThermometerReal.Height1 = int((float64(v.Bytes) / float64(v.BytesMonthly)) * float64(100))
display[k].ThermometerReal.Height2 = int((float64(allowanceBytes-v.Bytes) / float64(v.BytesMonthly)) * float64(100))
} else {
display[k].ThermometerReal.Color2 = template.CSS("orange")
display[k].ThermometerReal.Height1 = int((float64(allowanceBytes) / float64(v.BytesMonthly)) * float64(100))
display[k].ThermometerReal.Height2 = int((float64(allowanceBytes-v.Bytes) / float64(v.BytesMonthly)) * float64(100))
}
display[k].ThermometerProjected.Color1 = template.CSS("green")
if usage < 0.5 {
display[k].ThermometerProjected.Color2 = template.CSS("#ddd")
display[k].ThermometerProjected.Height1 = int(usage * float64(100))
display[k].ThermometerProjected.Height2 = int((float64(0.5) - usage) * float64(100))
} else {
display[k].ThermometerProjected.Color2 = template.CSS("orange")
display[k].ThermometerProjected.Height1 = 50
display[k].ThermometerProjected.Height2 = int((usage - float64(0.5)) * float64(100))
}
}
data := struct {
ValidVPSInstances map[string]*VPSInstanceForDisplay
DBOnlyVPSInstances map[string]*VPSInstance
CloudOnlyVPSInstances map[string]*VPSInstance
HashOfSessionId string
}{
ValidVPSInstances: display,
DBOnlyVPSInstances: dbOnlyInstances,
CloudOnlyVPSInstances: cloudOnlyInstances,
HashOfSessionId: hashOfSessionId,
}
app.buildPageFromTemplate(responseWriter, session, "admin.html", data)
})
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))))
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) {
toSet := &http.Cookie{
Name: name,
Domain: app.Domain,
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,
Domain: app.Domain,
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, err error) {
log.Printf("500 internal server error: %+v\n", 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)) {
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, err)
} else {
if (required && session.TenantId == 0) || (requireUserId != 0 && requireUserId != session.TenantId) {
// anti-XSS: only set returnTo if it matches a basic url pattern
if app.basicURLPathRegex.MatchString(request.URL.Path) {
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", request.URL.Path)
}
http.Redirect(responseWriter, request, "/login", http.StatusFound)
return
}
handler(responseWriter, request, session)
}
})
}
func (app *FrontendApp) buildPage(responseWriter http.ResponseWriter, session Session, highlight, page template.HTML) {
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
}{session, highlight, page},
)
app.deleteCookie(responseWriter, "flash")
if err != nil {
app.unhandledError(responseWriter, 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, session Session, templateName string, data interface{}) {
content, err := app.renderTemplateToHTML(templateName, data)
if err != nil {
app.unhandledError(responseWriter, err)
} else {
app.buildPage(responseWriter, session, template.HTML(""), content)
}
}
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()))
}
}
}
// 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))
// }
var tenantColors = map[int]string{}
func getTenantColor(tenantId int) string {
if _, has := tenantColors[tenantId]; has {
return tenantColors[tenantId]
}
var randomInt int64
colorHashArray := md5.Sum([]byte(strconv.Itoa(tenantId)))
colorHash := bytes.NewReader(colorHashArray[0:16])
err := binary.Read(colorHash, binary.LittleEndian, &randomInt)
if err != nil {
panic(err)
}
randomSource := mathRand.NewSource(randomInt)
toReturn := hsvColor(
float64(randomSource.Int63()%360),
float64(0.68)+(float64(randomSource.Int63()%80)/float64(255)),
float64(0.68)+(float64(randomSource.Int63()%80)/float64(255)),
)
tenantColors[tenantId] = toReturn
return toReturn
}
func hsvColor(H, S, V float64) string {
Hp := H / 60.0
C := V * S
X := C * (1.0 - math.Abs(math.Mod(Hp, 2.0)-1.0))
m := V - C
r, g, b := 0.0, 0.0, 0.0
switch {
case 0.0 <= Hp && Hp < 1.0:
r = C
g = X
case 1.0 <= Hp && Hp < 2.0:
r = X
g = C
case 2.0 <= Hp && Hp < 3.0:
g = C
b = X
case 3.0 <= Hp && Hp < 4.0:
g = X
b = C
case 4.0 <= Hp && Hp < 5.0:
r = X
b = C
case 5.0 <= Hp && Hp < 6.0:
r = C
b = X
}
return fmt.Sprintf("rgb(%d, %d, %d)", int((m+r)*float64(255)), int((m+g)*float64(255)), int((m+b)*float64(255)))
}
func ByteCountSI(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp])
}