package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
chart "github.com/wcharczuk/go-chart/v2"
chartdrawing "github.com/wcharczuk/go-chart/v2/drawing"
os_from_user_agent_header "zgo.at/gadget"
)
type LokiResponse struct {
Status string ` json:"status" `
Data LokiData ` json:"data" `
}
type LokiData struct {
Result [ ] LokiStream ` json:"result" `
}
type LokiStream struct {
Stream map [ string ] string ` json:"stream" `
Values lokiValues ` json:"values" `
}
type lokiValues [ ] [ ] string
func ( values lokiValues ) Len ( ) int {
return len ( values )
}
func ( values lokiValues ) Less ( i , j int ) bool {
return values [ i ] [ 0 ] > values [ j ] [ 1 ]
}
func ( values lokiValues ) Swap ( i , j int ) {
values [ i ] , values [ j ] = values [ j ] , values [ i ]
}
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 , request , err )
return
}
billingYear , billingMonth , _ , _ , _ := getBillingTimeInfo ( )
usageTotal , err := app . Model . GetTenantUsageTotal ( session . TenantId , billingYear , billingMonth )
if err != nil {
app . unhandledError ( responseWriter , request , 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 == "update_free_subdomain" {
postedFreeSubdomain := strings . ToLower ( request . PostFormValue ( "subdomain" ) )
subdomainRegex := regexp . MustCompile ( "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" )
if ! subdomainRegex . MatchString ( postedFreeSubdomain ) {
app . setFlash (
responseWriter , session , "error" ,
"the provided subdomain was invalid; subdomains consist only of letters, numbers, and dashes, ranging from 1 to 61 characters long" ,
)
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
alreadyTaken , err := app . Model . SetFreeSubdomain ( session . TenantId , postedFreeSubdomain )
if err != nil {
go postTelemetryFromRequest ( "update_free_subdomain" , request , app , fmt . Sprintf (
"'%s': db error: %s" , postedFreeSubdomain , err ,
) )
errorMessage := "unable to update your subdomain: internal server error"
log . Printf ( "%s: %+v" , errorMessage , err )
app . setFlash ( responseWriter , session , "error" , errorMessage )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
if alreadyTaken {
app . setFlash ( responseWriter , session , "error" , fmt . Sprintf ( "the subdomain '%s' is already taken" , postedFreeSubdomain ) )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
err = app . Backend . Reallocate ( false , false )
if err != nil {
go postTelemetryFromRequest ( "update_free_subdomain" , request , app , fmt . Sprintf (
"'%s': reallocate error: %s" , postedFreeSubdomain , err ,
) )
errorMessage := "unable to update your subdomain: internal server error"
log . Printf ( "%s: %+v" , errorMessage , err )
app . setFlash ( responseWriter , session , "error" , errorMessage )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
go postTelemetryFromRequest ( "update_free_subdomain" , request , app , fmt . Sprintf (
"'%s': success!!" , postedFreeSubdomain ,
) )
successMessage := fmt . Sprintf ( "Success! Your personal subdomain is now '%s.%s'\n" , postedFreeSubdomain , freeSubdomainDomain )
app . setFlash ( responseWriter , session , "info" , successMessage )
} else if action == "add_external_domain" {
postedExternalDomain := strings . ToLower ( request . PostFormValue ( "external-domain" ) )
// https://mkyong.com/regular-expressions/domain-name-regular-expression-example/
domainRegex := regexp . MustCompile ( "^([A-Za-z0-9][A-Za-z0-9-]{0,63}[A-Za-z0-9]?\\.)+[A-Za-z]{2,6}$" )
if ! domainRegex . MatchString ( postedExternalDomain ) {
app . setFlash (
responseWriter , session , "error" ,
fmt . Sprintf ( "the domain '%s' appeared to be invalid" , postedExternalDomain ) ,
)
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
usersPersonalSubdomain := fmt . Sprintf ( "%s.%s" , tenant . Subdomain , freeSubdomainDomain )
valid , err := app . Backend . ValidateExternalDomain ( postedExternalDomain , usersPersonalSubdomain , false )
if err != nil {
go postTelemetryFromRequest ( "add_external_domain" , request , app , fmt . Sprintf (
"'%s' -> '%s': validate: %s" , postedExternalDomain , usersPersonalSubdomain , err ,
) )
errorMessage := fmt . Sprintf ( "unable to update your subdomain: %s" , err )
log . Printf ( "%s: %+v" , errorMessage , err )
app . setFlash ( responseWriter , session , "error" , errorMessage )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
if ! valid {
go postTelemetryFromRequest ( "add_external_domain" , request , app , fmt . Sprintf (
"'%s' -> '%s': CNAME is missing" , postedExternalDomain , usersPersonalSubdomain ,
) )
app . setFlash ( responseWriter , session , "error" , fmt . Sprintf (
"the domain '%s' does not appear to have a CNAME record pointing to '%s'. Either you have not created the CNAME record set yet, it was not created correctly, or not enough time has elapsed for the DNS record change to propagate." ,
postedExternalDomain , usersPersonalSubdomain ,
) )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
err = app . Model . AddExternalDomain ( session . TenantId , postedExternalDomain )
if err != nil {
go postTelemetryFromRequest ( "add_external_domain" , request , app , fmt . Sprintf (
"'%s' -> '%s': model error: %s" , postedExternalDomain , usersPersonalSubdomain , err ,
) )
errorMessage := "unable to update your subdomain: internal server error"
log . Printf ( "%s: %+v" , errorMessage , err )
app . setFlash ( responseWriter , session , "error" , errorMessage )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
go postTelemetryFromRequest ( "add_external_domain" , request , app , fmt . Sprintf (
"'%s' -> '%s': success!!" , postedExternalDomain , usersPersonalSubdomain ,
) )
app . setFlash ( responseWriter , session , "info" , fmt . Sprintf ( "Success! '%s' has been added as an external domain. You may now create tunnels from '%s' using any of your greenhouse clients. \n" , postedExternalDomain , postedExternalDomain ) )
} else if action == "create_api_token" {
keyName := strings . TrimSpace ( request . PostFormValue ( "key_name" ) )
if len ( keyName ) == 0 {
app . setFlash ( responseWriter , session , "error" , "API Token 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" , "API Token Name may only contain letters, numbers, dashes, and underscores\n" )
http . Redirect ( responseWriter , request , "/profile" , http . StatusFound )
return
}
apiToken , name , err := app . Backend . CreateAPIToken ( session . TenantId , keyName )
if err != nil {
app . unhandledError ( responseWriter , request , err )
return
}
go postTelemetryFromRequest ( "create_api_token" , request , app , fmt . Sprintf ( "'%s': success!!" , keyName ) )
app . setFlash ( responseWriter , session , "api-token" , apiToken )
app . setFlash ( responseWriter , session , "api-token-name" , name )
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" , fmt . Sprintf ( "unknown action '%s'\n" , action ) )
}
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 )
}
}
goatCounterOSName := os_from_user_agent_header . Parse ( request . Header . Get ( "User-Agent" ) ) . OSName
simplifiedOSName := map [ string ] string {
"iOS" : "macos" ,
"macOS" : "macos" ,
"Windows Phone" : "windows" ,
"Windows" : "windows" ,
} [ goatCounterOSName ]
if simplifiedOSName == "" {
simplifiedOSName = "linux"
}
lokiResponseString := getTelemetryFromLokiForUser ( app , session . Email )
data := struct {
Subdomain string
SubdomainDomain string
ExternalDomains [ ] ExternalDomain
APITokens [ ] APIToken
NewAPIToken string
NewAPITokenName string
BytesSoFar string
BillingAlarmSMS string
BillingAlarmEmail string
BillingAlarmThreshold string
BillingLimit string
HashOfSessionId string
OperatingSystem string
LokiResponseString string
} {
Subdomain : tenant . Subdomain ,
SubdomainDomain : freeSubdomainDomain ,
ExternalDomains : tenant . ExternalDomains ,
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 ,
OperatingSystem : simplifiedOSName ,
LokiResponseString : lokiResponseString ,
}
app . buildPageFromTemplateWithClass ( responseWriter , request , session , "alpha-profile.html" , data , "no-horizontal-margin" )
} )
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, request, err)
// return
// }
_ , _ , start , end , _ := getBillingTimeInfo ( )
usageMetrics , err := app . Model . GetTenantUsageMetrics ( session . TenantId , start , end )
if err != nil {
app . unhandledError ( responseWriter , request , 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 )
} )
// fmt.Println(start.Format(time.RFC1123), end.Format(time.RFC1123))
// fmt.Println(usageMetricObjects[0].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)/2].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)-1].Time.Format(time.RFC1123))
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
}
// split xValues and yValues into two separate series, one of the past and one of the future
upToNowCount := 0
nowNano := float64 ( end . UnixNano ( ) )
for _ , t := range xValues {
if nowNano > t {
upToNowCount ++
}
}
upToNowX := make ( [ ] float64 , upToNowCount )
upToNowY := make ( [ ] float64 , upToNowCount )
// this -1 thing makes the most recent datapoint included in both series.
futureX := make ( [ ] float64 , len ( xValues ) - ( upToNowCount - 1 ) )
futureY := make ( [ ] float64 , len ( xValues ) - ( upToNowCount - 1 ) )
for i , t := range xValues {
if i < upToNowCount {
upToNowX [ i ] = t
if i == upToNowCount - 1 {
futureX [ i - ( upToNowCount - 1 ) ] = t
}
} else {
futureX [ i - ( upToNowCount - 1 ) ] = t
}
}
for i , v := range yValues {
if i < upToNowCount {
upToNowY [ i ] = v
if i == upToNowCount - 1 {
futureY [ i - ( upToNowCount - 1 ) ] = v
}
} else {
futureY [ i - ( upToNowCount - 1 ) ] = v
}
}
graph := chart . Chart {
Width : 450 ,
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 {
StrokeColor : chartdrawing . Color { R : 0 , G : 100 , B : 255 , A : 255 } ,
FillColor : chartdrawing . Color { R : 0 , G : 100 , B : 255 , A : 100 } ,
} ,
YAxis : chart . YAxisPrimary ,
XValues : upToNowX ,
YValues : upToNowY ,
} ,
chart . ContinuousSeries {
Name : "Future" ,
Style : chart . Style {
StrokeColor : chartdrawing . Color { R : 0 , G : 100 , B : 255 , A : 255 } ,
FillColor : chartdrawing . Color { R : 0 , G : 100 , B : 255 , A : 70 } ,
StrokeDashArray : [ ] float64 { 5.0 , 5.0 } ,
} ,
YAxis : chart . YAxisPrimary ,
XValues : futureX ,
YValues : futureY ,
} ,
} ,
}
//log.Println(graph.YAxis.Range.GetDelta())
buffer := bytes . NewBuffer ( [ ] byte { } )
err = graph . Render ( chart . PNG , buffer )
if err != nil {
app . unhandledError ( responseWriter , request , 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 )
}
} )
}
func getTelemetryFromLokiForUser ( app * FrontendApp , email string ) string {
lokiQuery := fmt . Sprintf ( "{delay=\"user\", account=~\"^%s$\"}" , email )
lokiQueryURL := fmt . Sprintf (
"%s/loki/api/v1/query_range?direction=BACKWARD&limit=1000&query=%s&start=%d&end=%d&step=900" ,
app . LokiURL , url . QueryEscape ( lokiQuery ) , time . Now ( ) . Add ( - 1 * time . Hour * 24 * 29 ) . UnixNano ( ) , time . Now ( ) . Add ( time . Hour ) . UnixNano ( ) ,
)
lokiResponseString := "HTTP Dial Error getting telemetry data\n"
lokiResponse , err := app . httpClient . Get ( lokiQueryURL )
if err == nil {
lokiResponseString = fmt . Sprintf ( "HTTP %d error getting telemetry data" , lokiResponse . StatusCode )
if lokiResponse . StatusCode < 300 {
lokiResponseString = "HTTP read error getting telemetry data"
lokiResponseBytes , err := ioutil . ReadAll ( lokiResponse . Body )
if err == nil {
lokiResponseString = "JSON parse error getting telemetry data"
var lokiResponseObject LokiResponse
err = json . Unmarshal ( lokiResponseBytes , & lokiResponseObject )
if err == nil {
lokiResponseString = "Loki response error getting telemetry data"
if lokiResponseObject . Status == "success" {
allValues := lokiValues { }
for _ , stream := range lokiResponseObject . Data . Result {
allValues = append ( allValues , stream . Values ... )
}
sort . Sort ( allValues )
responseStringBytes := [ ] byte { }
for _ , value := range allValues {
toAppend := "malformed data from loki\n"
if len ( value ) == 2 {
timestampUnixNanos , err := strconv . ParseInt ( value [ 0 ] , 10 , 64 )
if err == nil {
toAppend = fmt . Sprintf ( fmt . Sprintf ( "%s %s\n" , time . Unix ( 0 , timestampUnixNanos ) . Format ( time . RFC3339 ) , value [ 1 ] ) )
}
}
responseStringBytes = append ( responseStringBytes , [ ] byte ( toAppend ) ... )
}
lokiResponseString = string ( responseStringBytes )
}
}
}
} else {
errorString := "read error"
bytez , err := ioutil . ReadAll ( lokiResponse . Body )
if err == nil {
errorString = string ( bytez )
}
log . Printf ( "HTTP %d getting telemetry data for %s:\n%s\n%s\n" , lokiResponse . StatusCode , email , lokiQueryURL , errorString )
}
} else {
log . Printf ( "error getting telemetry data for %s:\n%s\n%s\n" , email , lokiQueryURL , err )
}
return lokiResponseString
}
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 ] )
}