🌱🏠 a cloud service to enable your own server (owned by you and running on your computer) to be accessible on the internet in seconds, no credit card required https://greenhouse.server.garden/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

426 lines
15 KiB

  1. package main
  2. import (
  3. "bytes"
  4. "crypto/rand"
  5. "crypto/sha256"
  6. "fmt"
  7. "log"
  8. "net/http"
  9. "regexp"
  10. "sort"
  11. "strconv"
  12. "strings"
  13. "time"
  14. base58 "github.com/shengdoushi/base58"
  15. chart "github.com/wcharczuk/go-chart/v2"
  16. chartdrawing "github.com/wcharczuk/go-chart/v2/drawing"
  17. os_from_user_agent_header "zgo.at/gadget"
  18. )
  19. func registerProfileRoutes(app *FrontendApp) {
  20. // TODO remove this?
  21. app.handleWithSessionNotRequired("/ostest", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
  22. goatCounterOSName := os_from_user_agent_header.Parse(request.Header.Get("User-Agent")).OSName
  23. simplifiedOSName := map[string]string{
  24. "iOS": "macos",
  25. "macOS": "macos",
  26. "Windows Phone": "windows",
  27. "Windows": "windows",
  28. }[goatCounterOSName]
  29. if simplifiedOSName == "" {
  30. simplifiedOSName = "linux"
  31. }
  32. responseWriter.Header().Set("Content-Type", "text/plain")
  33. responseWriter.Write([]byte(simplifiedOSName))
  34. })
  35. app.handleWithSession("/profile", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
  36. if !session.EmailVerified {
  37. // anti-XSS: only set path into the flash cookie if it matches a basic url pattern
  38. if app.basicURLPathRegex.MatchString(request.URL.Path) {
  39. msg := fmt.Sprintf("Please verify your email address in order to access %s%s", app.Domain, request.URL.Path)
  40. app.setFlash(responseWriter, session, "info", msg)
  41. }
  42. http.Redirect(responseWriter, request, "/verify-email", http.StatusFound)
  43. return
  44. }
  45. // email is verified, continue:
  46. rawHash := sha256.Sum256([]byte(session.SessionId))
  47. hashOfSessionId := fmt.Sprintf("%x", rawHash[:8])
  48. tenant, err := app.Model.GetTenant(session.TenantId)
  49. if err != nil {
  50. app.unhandledError(responseWriter, err)
  51. return
  52. }
  53. billingYear, billingMonth, _, _, _ := getBillingTimeInfo()
  54. usageTotal, err := app.Model.GetTenantUsageTotal(session.TenantId, billingYear, billingMonth)
  55. if err != nil {
  56. app.unhandledError(responseWriter, err)
  57. return
  58. }
  59. newAPIToken := (*session.Flash)["api-token"]
  60. newAPITokenName := (*session.Flash)["api-token-name"]
  61. if request.Method == "POST" {
  62. postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
  63. if postedHashOfSessionId != hashOfSessionId {
  64. app.setFlash(responseWriter, session, "error", "anti-CSRF (cross site request forgery) validation failed. Please try again.\n")
  65. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  66. return
  67. }
  68. action := request.PostFormValue("action")
  69. if action == "update_free_subdomain" {
  70. postedFreeSubdomain := strings.ToLower(request.PostFormValue("subdomain"))
  71. subdomainRegex := regexp.MustCompile("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
  72. if !subdomainRegex.MatchString(postedFreeSubdomain) {
  73. app.setFlash(
  74. responseWriter, session, "error",
  75. "the provided subdomain was invalid; subdomains consist only of letters, numbers, and dashes, ranging from 1 to 61 characters long",
  76. )
  77. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  78. return
  79. }
  80. alreadyTaken, err := app.Model.SetFreeSubdomain(session.TenantId, postedFreeSubdomain)
  81. if err != nil {
  82. errorMessage := "unable to update your subdomain: internal server error"
  83. log.Printf("%s: %+v", errorMessage, err)
  84. app.setFlash(responseWriter, session, "error", errorMessage)
  85. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  86. return
  87. }
  88. if alreadyTaken {
  89. app.setFlash(responseWriter, session, "error", fmt.Sprintf("the subdomain '%s' is already taken", postedFreeSubdomain))
  90. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  91. return
  92. }
  93. err = app.Backend.Reallocate(false, false)
  94. if err != nil {
  95. errorMessage := "unable to update your subdomain: internal server error"
  96. log.Printf("%s: %+v", errorMessage, err)
  97. app.setFlash(responseWriter, session, "error", errorMessage)
  98. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  99. return
  100. }
  101. successMessage := fmt.Sprintf("Success! Your personal subdomain is now '%s.%s'\n", postedFreeSubdomain, freeSubdomainDomain)
  102. app.setFlash(responseWriter, session, "info", successMessage)
  103. } else if action == "add_external_domain" {
  104. postedExternalDomain := strings.ToLower(request.PostFormValue("external-domain"))
  105. // https://mkyong.com/regular-expressions/domain-name-regular-expression-example/
  106. domainRegex := regexp.MustCompile("^([A-Za-z0-9][A-Za-z0-9-]{0,63}[A-Za-z0-9]?\\.)+[A-Za-z]{2,6}$")
  107. if !domainRegex.MatchString(postedExternalDomain) {
  108. app.setFlash(
  109. responseWriter, session, "error",
  110. fmt.Sprintf("the domain '%s' appeared to be invalid", postedExternalDomain),
  111. )
  112. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  113. return
  114. }
  115. usersPersonalSubdomain := fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain)
  116. valid, err := app.Backend.ValidateExternalDomain(postedExternalDomain, usersPersonalSubdomain, false)
  117. if err != nil {
  118. errorMessage := fmt.Sprintf("unable to update your subdomain: %s", err)
  119. log.Printf("%s: %+v", errorMessage, err)
  120. app.setFlash(responseWriter, session, "error", errorMessage)
  121. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  122. return
  123. }
  124. if !valid {
  125. app.setFlash(responseWriter, session, "error", fmt.Sprintf(
  126. "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.",
  127. postedExternalDomain, usersPersonalSubdomain,
  128. ))
  129. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  130. return
  131. }
  132. err = app.Model.AddExternalDomain(session.TenantId, postedExternalDomain)
  133. if err != nil {
  134. errorMessage := "unable to update your subdomain: internal server error"
  135. log.Printf("%s: %+v", errorMessage, err)
  136. app.setFlash(responseWriter, session, "error", errorMessage)
  137. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  138. return
  139. }
  140. 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))
  141. } else if action == "create_api_token" {
  142. keyName := strings.TrimSpace(request.PostFormValue("key_name"))
  143. if len(keyName) == 0 {
  144. app.setFlash(responseWriter, session, "error", "key name is required\n")
  145. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  146. return
  147. }
  148. if !regexp.MustCompile("[A-Za-z0-9_-]+").MatchString(keyName) {
  149. app.setFlash(responseWriter, session, "error", "key name may only contain letters, numbers, dashes, and underscores\n")
  150. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  151. return
  152. }
  153. apiTokenBuffer := make([]byte, 16)
  154. rand.Read(apiTokenBuffer)
  155. apiToken := base58.Encode(apiTokenBuffer, base58.BitcoinAlphabet)
  156. rawHash := sha256.Sum256([]byte(apiToken))
  157. hashedAPIToken := fmt.Sprintf("%x", rawHash)
  158. err := app.Model.CreateAPIToken(session.TenantId, keyName, hashedAPIToken)
  159. if err != nil {
  160. app.unhandledError(responseWriter, err)
  161. return
  162. }
  163. app.setFlash(responseWriter, session, "api-token", apiToken)
  164. app.setFlash(responseWriter, session, "api-token-name", keyName)
  165. 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))
  166. } else {
  167. app.setFlash(responseWriter, session, "error", fmt.Sprintf("unknown action '%s'\n", action))
  168. }
  169. http.Redirect(responseWriter, request, "/profile", http.StatusFound)
  170. return
  171. }
  172. apiTokens := []APIToken{}
  173. for _, token := range tenant.APITokens {
  174. //fmt.Printf("%s %s", token.Name, newAPITokenName)
  175. if token.Name != newAPITokenName {
  176. apiTokens = append(apiTokens, token)
  177. }
  178. }
  179. goatCounterOSName := os_from_user_agent_header.Parse(request.Header.Get("User-Agent")).OSName
  180. simplifiedOSName := map[string]string{
  181. "iOS": "macos",
  182. "macOS": "macos",
  183. "Windows Phone": "windows",
  184. "Windows": "windows",
  185. }[goatCounterOSName]
  186. if simplifiedOSName == "" {
  187. simplifiedOSName = "linux"
  188. }
  189. data := struct {
  190. Subdomain string
  191. SubdomainDomain string
  192. ExternalDomains []ExternalDomain
  193. APITokens []APIToken
  194. NewAPIToken string
  195. NewAPITokenName string
  196. BytesSoFar string
  197. BillingAlarmSMS string
  198. BillingAlarmEmail string
  199. BillingAlarmThreshold string
  200. BillingLimit string
  201. HashOfSessionId string
  202. OperatingSystem string
  203. }{
  204. Subdomain: tenant.Subdomain,
  205. SubdomainDomain: freeSubdomainDomain,
  206. ExternalDomains: tenant.ExternalDomains,
  207. APITokens: apiTokens,
  208. NewAPIToken: newAPIToken,
  209. NewAPITokenName: newAPITokenName,
  210. BytesSoFar: ByteCountSI(usageTotal),
  211. BillingAlarmSMS: tenant.SMSAlarmNumber,
  212. BillingAlarmEmail: tenant.Email,
  213. BillingAlarmThreshold: fmt.Sprintf("%.2f", float64(tenant.BillingAlarmCents)/float64(100)),
  214. BillingLimit: fmt.Sprintf("%.2f", float64(tenant.ServiceLimitCents)/float64(100)),
  215. HashOfSessionId: hashOfSessionId,
  216. OperatingSystem: simplifiedOSName,
  217. }
  218. app.buildPageFromTemplateWithClass(responseWriter, session, "alpha-profile.html", data, "no-horizontal-margin")
  219. })
  220. app.handleWithSession("/profile/usage_graph.png", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
  221. // tenant, err := app.Model.GetTenant(session.TenantId)
  222. // if err != nil {
  223. // app.unhandledError(responseWriter, err)
  224. // return
  225. // }
  226. _, _, start, end, _ := getBillingTimeInfo()
  227. usageMetrics, err := app.Model.GetTenantUsageMetrics(session.TenantId, start, end)
  228. if err != nil {
  229. app.unhandledError(responseWriter, err)
  230. return
  231. }
  232. type datapoint struct {
  233. Time time.Time
  234. Bytes int64
  235. }
  236. i := 0
  237. usageMetricObjects := make([]datapoint, len(usageMetrics))
  238. for time, bytez := range usageMetrics {
  239. usageMetricObjects[i] = datapoint{Time: time, Bytes: bytez}
  240. i++
  241. }
  242. sort.Slice(usageMetricObjects, func(i, j int) bool {
  243. return usageMetricObjects[j].Time.After(usageMetricObjects[i].Time)
  244. })
  245. // fmt.Println(start.Format(time.RFC1123), end.Format(time.RFC1123))
  246. // fmt.Println(usageMetricObjects[0].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)/2].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)-1].Time.Format(time.RFC1123))
  247. xValues := make([]float64, len(usageMetricObjects)+3)
  248. yValues := make([]float64, len(usageMetricObjects)+3)
  249. var total int64 = 0
  250. xValues[0] = float64(start.UnixNano())
  251. yValues[0] = float64(0)
  252. if len(usageMetricObjects) > 0 {
  253. xValues[1] = float64(usageMetricObjects[0].Time.UnixNano() - int64(time.Second*10))
  254. } else {
  255. xValues[1] = float64(start.UnixNano())
  256. }
  257. yValues[1] = float64(0)
  258. for i, obj := range usageMetricObjects {
  259. total = total + obj.Bytes
  260. xValues[i+2] = float64(obj.Time.UnixNano())
  261. yValues[i+2] = float64(total)
  262. }
  263. xValues[len(xValues)-1] = float64(end.UnixNano())
  264. yValues[len(yValues)-1] = float64(total)
  265. // it will err if the y scale is from 0 to 0
  266. if total == 0 {
  267. total = 1000
  268. }
  269. // split xValues and yValues into two separate series, one of the past and one of the future
  270. upToNowCount := 0
  271. nowNano := float64(end.UnixNano())
  272. for _, t := range xValues {
  273. if nowNano > t {
  274. upToNowCount++
  275. }
  276. }
  277. upToNowX := make([]float64, upToNowCount)
  278. upToNowY := make([]float64, upToNowCount)
  279. // this -1 thing makes the most recent datapoint included in both series.
  280. futureX := make([]float64, len(xValues)-(upToNowCount-1))
  281. futureY := make([]float64, len(xValues)-(upToNowCount-1))
  282. for i, t := range xValues {
  283. if i < upToNowCount {
  284. upToNowX[i] = t
  285. if i == upToNowCount-1 {
  286. futureX[i-(upToNowCount-1)] = t
  287. }
  288. } else {
  289. futureX[i-(upToNowCount-1)] = t
  290. }
  291. }
  292. for i, v := range yValues {
  293. if i < upToNowCount {
  294. upToNowY[i] = v
  295. if i == upToNowCount-1 {
  296. futureY[i-(upToNowCount-1)] = v
  297. }
  298. } else {
  299. futureY[i-(upToNowCount-1)] = v
  300. }
  301. }
  302. graph := chart.Chart{
  303. Width: 450,
  304. Height: 200,
  305. XAxis: chart.XAxis{
  306. ValueFormatter: func(v interface{}) string {
  307. if typed, isTyped := v.(float64); isTyped {
  308. timeInstance := time.Unix(0, int64(typed)).UTC()
  309. if timeInstance.After(end) {
  310. timeInstance = end
  311. }
  312. return timeInstance.Format("Jan 2")
  313. }
  314. return ""
  315. },
  316. Range: &chart.ContinuousRange{
  317. Min: float64(start.UnixNano()),
  318. Max: float64(end.UnixNano()),
  319. },
  320. },
  321. YAxis: chart.YAxis{
  322. ValueFormatter: func(v interface{}) string {
  323. if typed, isTyped := v.(float64); isTyped {
  324. return ByteCountSI(int64(typed))
  325. }
  326. return ""
  327. },
  328. Range: &chart.ContinuousRange{
  329. Min: float64(0),
  330. Max: float64(total) * float64(1.2),
  331. },
  332. },
  333. Series: []chart.Series{
  334. chart.ContinuousSeries{
  335. Name: "Bytes",
  336. Style: chart.Style{
  337. StrokeColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 255},
  338. FillColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 100},
  339. },
  340. YAxis: chart.YAxisPrimary,
  341. XValues: upToNowX,
  342. YValues: upToNowY,
  343. },
  344. chart.ContinuousSeries{
  345. Name: "Future",
  346. Style: chart.Style{
  347. StrokeColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 255},
  348. FillColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 70},
  349. StrokeDashArray: []float64{5.0, 5.0},
  350. },
  351. YAxis: chart.YAxisPrimary,
  352. XValues: futureX,
  353. YValues: futureY,
  354. },
  355. },
  356. }
  357. //log.Println(graph.YAxis.Range.GetDelta())
  358. buffer := bytes.NewBuffer([]byte{})
  359. err = graph.Render(chart.PNG, buffer)
  360. if err != nil {
  361. app.unhandledError(responseWriter, err)
  362. return
  363. }
  364. responseWriter.Header().Set("Content-Type", "image/png")
  365. responseWriter.Header().Set("Content-Length", strconv.Itoa(buffer.Len()))
  366. _, err = responseWriter.Write(buffer.Bytes())
  367. if err != nil {
  368. log.Printf("http Write error on usage_graph png: %+v", err)
  369. }
  370. })
  371. }
  372. func ByteCountSI(b int64) string {
  373. const unit = 1000
  374. if b < unit {
  375. return fmt.Sprintf("%d B", b)
  376. }
  377. div, exp := int64(unit), 0
  378. for n := b / unit; n >= unit; n /= unit {
  379. div *= unit
  380. exp++
  381. }
  382. return fmt.Sprintf("%.1f %cB",
  383. float64(b)/float64(div), "kMGTPE"[exp])
  384. }