Browse Source

clean up frontend.go, split out into categories

master
forest 1 month ago
parent
commit
b17964d757
4 changed files with 817 additions and 761 deletions
  1. +3
    -761
      frontend.go
  2. +325
    -0
      frontend_admin_panel.go
  3. +233
    -0
      frontend_login.go
  4. +256
    -0
      frontend_profile.go

+ 3
- 761
frontend.go View File

@ -2,34 +2,22 @@ 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 {
@ -98,685 +86,11 @@ func initFrontend(workingDirectory string, config *Config, model *DBModel, backe
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"))
registerLoginRoutes(&app, emailService)
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)
registerProfileRoutes(&app)
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
}
// email is verified, continue:
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
}
newAPIToken := (*session.Flash)["api-token"]
newAPITokenName := (*session.Flash)["api-token-name"]
if request.Method == "POST" {
postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
if postedHashOfSessionId != hashOfSessionId {
app.setFlash(responseWriter, session, "error", "anti-CSRF (cross site request forgery) validation failed. Please try again.\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
action := request.PostFormValue("action")
if action == "create_api_token" {
keyName := strings.TrimSpace(request.PostFormValue("key_name"))
if len(keyName) == 0 {
app.setFlash(responseWriter, session, "error", "key name is required\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
if !regexp.MustCompile("[A-Za-z0-9_-]+").MatchString(keyName) {
app.setFlash(responseWriter, session, "error", "key name may only contain letters, numbers, dashes, and underscores\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
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.CreateAPIToken(session.TenantId, keyName, hashedAPIToken)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.setFlash(responseWriter, session, "api-token", apiToken)
app.setFlash(responseWriter, session, "api-token-name", keyName)
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! Your new '%s' API Token is %s. It will not be displayed again, so make sure to copy and paste it or write it down now!\n", keyName, apiToken))
} else {
app.setFlash(responseWriter, session, "error", "unknown action\n")
}
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
apiTokens := []APIToken{}
for _, token := range tenant.APITokens {
//fmt.Printf("%s %s", token.Name, newAPITokenName)
if token.Name != newAPITokenName {
apiTokens = append(apiTokens, token)
}
}
data := struct {
Subdomain string
APITokens []APIToken
NewAPIToken string
NewAPITokenName string
BytesSoFar string
BillingAlarmSMS string
BillingAlarmEmail string
BillingAlarmThreshold string
BillingLimit string
HashOfSessionId string
}{
Subdomain: tenant.Subdomain,
APITokens: apiTokens,
NewAPIToken: newAPIToken,
NewAPITokenName: newAPITokenName,
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)
if len(usageMetricObjects) > 0 {
xValues[1] = float64(usageMetricObjects[0].Time.UnixNano() - int64(time.Second*10))
} else {
xValues[1] = float64(start.UnixNano())
}
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 (cross site request forgery) validation failed. Please try again.\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 == "add-to-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.CreateVPSInstance(&VPSInstance{
ServiceProvider: split[0],
ProviderInstanceId: split[1],
IPV4: request.PostFormValue("ipv4"),
BytesMonthly: DEFAULT_INSTANCE_MONTHLY_BYTES,
})
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 == "start_daemon" {
if app.Ingress.DaemonIsRunning {
app.setFlash(responseWriter, session, "error", "daemon is already running\n")
log.Println("daemon is already running")
} else {
err := app.Ingress.StartGreenhouseDaemon()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("start_daemon failed: %+v\n", err))
log.Printf("start_daemon failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "daemon appears to be running!\n")
log.Println("daemon appears to be running!")
}
}
} else if action == "configure_daemon" {
if !app.Ingress.DaemonIsRunning {
app.setFlash(responseWriter, session, "error", "daemon isn't running\n")
log.Println("daemon isn't running")
} else {
err := app.Ingress.ConfigureGreenhouseDaemon()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_daemon failed: %+v\n", err))
log.Printf("configure_daemon failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "daemon appears to be running!\n")
log.Println("daemon appears to be running!")
}
}
} else if action == "daemon_status" {
responseString, err := app.Ingress.GetGreenhouseDaemonStatus()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("daemon_status failed: %+v\n", err))
log.Printf("daemon_status failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", fmt.Sprintf("daemon status: %s\n", responseString))
log.Printf("daemon status: %s\n", responseString)
}
} 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)
})
registerAdminPanelRoutes(&app)
app.reloadTemplates()
@ -1094,75 +408,3 @@ func (app *FrontendApp) reloadTemplates() {
// }
// 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])
}

+ 325
- 0
frontend_admin_panel.go View File

@ -0,0 +1,325 @@
package main
import (
"bytes"
"crypto/md5"
"crypto/sha256"
"encoding/binary"
"fmt"
"html/template"
"log"
"math"
mathRand "math/rand"
"net/http"
"strconv"
"strings"
"time"
)
func registerAdminPanelRoutes(app *FrontendApp) {
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 (cross site request forgery) validation failed. Please try again.\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 == "add-to-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.CreateVPSInstance(&VPSInstance{
ServiceProvider: split[0],
ProviderInstanceId: split[1],
IPV4: request.PostFormValue("ipv4"),
BytesMonthly: DEFAULT_INSTANCE_MONTHLY_BYTES,
})
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 == "start_daemon" {
if app.Ingress.DaemonIsRunning {
app.setFlash(responseWriter, session, "error", "daemon is already running\n")
log.Println("daemon is already running")
} else {
err := app.Ingress.StartGreenhouseDaemon()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("start_daemon failed: %+v\n", err))
log.Printf("start_daemon failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "daemon appears to be running!\n")
log.Println("daemon appears to be running!")
}
}
} else if action == "configure_daemon" {
if !app.Ingress.DaemonIsRunning {
app.setFlash(responseWriter, session, "error", "daemon isn't running\n")
log.Println("daemon isn't running")
} else {
err := app.Ingress.ConfigureGreenhouseDaemon()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_daemon failed: %+v\n", err))
log.Printf("configure_daemon failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "daemon appears to be running!\n")
log.Println("daemon appears to be running!")
}
}
} else if action == "daemon_status" {
responseString, err := app.Ingress.GetGreenhouseDaemonStatus()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("daemon_status failed: %+v\n", err))
log.Printf("daemon_status failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", fmt.Sprintf("daemon status: %s\n", responseString))
log.Printf("daemon status: %s\n", responseString)
}
} 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)
})
}
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)))
}

+ 233
- 0
frontend_login.go View File

@ -0,0 +1,233 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
)
func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
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.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)
})
}

+ 256
- 0
frontend_profile.go View File

@ -0,0 +1,256 @@
package main
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"fmt"
"log"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
base58 "github.com/shengdoushi/base58"
chart "github.com/wcharczuk/go-chart/v2"
chartdrawing "github.com/wcharczuk/go-chart/v2/drawing"
)
func registerProfileRoutes(app *FrontendApp) {
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
}
// email is verified, continue:
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
}
newAPIToken := (*session.Flash)["api-token"]
newAPITokenName := (*session.Flash)["api-token-name"]
if request.Method == "POST" {
postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
if postedHashOfSessionId != hashOfSessionId {
app.setFlash(responseWriter, session, "error", "anti-CSRF (cross site request forgery) validation failed. Please try again.\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
action := request.PostFormValue("action")
if action == "create_api_token" {
keyName := strings.TrimSpace(request.PostFormValue("key_name"))
if len(keyName) == 0 {
app.setFlash(responseWriter, session, "error", "key name is required\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
if !regexp.MustCompile("[A-Za-z0-9_-]+").MatchString(keyName) {
app.setFlash(responseWriter, session, "error", "key name may only contain letters, numbers, dashes, and underscores\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
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.CreateAPIToken(session.TenantId, keyName, hashedAPIToken)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
app.setFlash(responseWriter, session, "api-token", apiToken)
app.setFlash(responseWriter, session, "api-token-name", keyName)
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! Your new '%s' API Token is %s. It will not be displayed again, so make sure to copy and paste it or write it down now!\n", keyName, apiToken))
} else {
app.setFlash(responseWriter, session, "error", "unknown action\n")
}
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
apiTokens := []APIToken{}
for _, token := range tenant.APITokens {
//fmt.Printf("%s %s", token.Name, newAPITokenName)
if token.Name != newAPITokenName {
apiTokens = append(apiTokens, token)
}
}
data := struct {
Subdomain string
APITokens []APIToken
NewAPIToken string
NewAPITokenName string
BytesSoFar string
BillingAlarmSMS string
BillingAlarmEmail string
BillingAlarmThreshold string
BillingLimit string
HashOfSessionId string
}{
Subdomain: tenant.Subdomain,
APITokens: apiTokens,
NewAPIToken: newAPIToken,
NewAPITokenName: newAPITokenName,
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)
if len(usageMetricObjects) > 0 {
xValues[1] = float64(usageMetricObjects[0].Time.UnixNano() - int64(time.Second*10))
} else {
xValues[1] = float64(start.UnixNano())
}
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{