// // lurkcoin admin pages // Copyright © 2020 by luk3yx // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // package api import ( "crypto/sha512" "encoding/hex" "github.com/julienschmidt/httprouter" "github.com/luk3yx/lurkcoin-core/lurkcoin" "html" "html/template" "io" "log" "net/http" "regexp" "strings" ) const adminPagesHeader = ` lurkcoin admin pages
` const adminPagesFooter = `
` const popOutCode = ` btn.style.display = "inline"; btn.addEventListener("click", () => { form.style.display = "block"; form.style.transition = "ease-in-out 250ms transform"; window.setTimeout(() => { form.style.transform = "scaleY(1)"; form.style.maxHeight = form.scrollHeight.toString() + "px"; btn.style.opacity = "0.5"; btn.style.pointerEvents = "none"; window.location.hash = "#" + form.id; }, 25); btn.blur(); }); function hideForm() { form.style.transition = "ease-in-out 250ms"; form.style.transform = "scaleY(0)"; form.style.maxHeight = "0"; btn.style.opacity = "1"; btn.style.pointerEvents = ""; } ` const serverListTemplate = adminPagesHeader + `

Server list

Total: {{len .Summaries}} server(s). {{range $summary := .Summaries}} {{end}}
Name Balance Target balance Pending transactions ...
{{$summary.Name}} {{$summary.Balance}} {{$summary.TargetBalance}} {{$summary.PendingTransactionCount}} Edit
{{if .AllowEditing}} {{if .AllowDatabaseDownload}} Download database backup {{end}}

Create new server

Username

{{else}} You may not edit the database. {{end}} ` + adminPagesFooter const currencyInput = `type="text" pattern="¤?[0-9,_]+(\.[0-9,_]+)?"` const infoTemplate = adminPagesHeader + ` Go back

Server: {{.Server.Name}}

{{if .Message}}
{{.Message}}
{{end}}

Basic information

{{if .AllowEditing}} {{end}}

Balance

Target balance

Webhook URL
{{if .AllowEditing}}

Cancel {{end}}

History

{{range $transaction := .Server.GetHistory}} {{end}}
ID Source Source server Target Target server Sent amount Amount Received amount Time Revertable
{{$transaction.ID}} {{$transaction.Source}} {{$transaction.SourceServer}} {{$transaction.Target}} {{$transaction.TargetServer}} {{$transaction.SentAmount.RawString}} {{$transaction.Amount}} {{$transaction.ReceivedAmount.RawString}} {{$transaction.GetTime}} {{$transaction.Revertable | YesNo}}
{{if .AllowEditing}}

Delete server

This action cannot be undone.
To confirm the server deletion, please type the server's name ({{.Server.Name}}) below.


{{end}} ` + adminPagesFooter type adminPagesSummary struct { UID string Name string Balance lurkcoin.Currency TargetBalance lurkcoin.Currency PendingTransactionCount int } func parseNumbers(n1, n2 string) (lurkcoin.Currency, lurkcoin.Currency, bool) { n1 = strings.ReplaceAll(n1, ",", "") n2 = strings.ReplaceAll(n2, ",", "") var res1, res2 lurkcoin.Currency var err error res1, err = lurkcoin.ParseCurrency(n1) if err != nil { return c0, c0, false } res2, err = lurkcoin.ParseCurrency(n2) if err != nil { return c0, c0, false } return res1, res2, true } type AdminLoginDetails map[string]struct { PasswordHash string `yaml:"password_hash"` HashAlgorithm string `yaml:"hash_algorithm"` PasswordSalt string `yaml:"password_salt"` AllowEditing bool `yaml:"allow_editing"` AllowDatabaseDownload bool `yaml:"allow_database_download"` } // TODO: Provide a more secure hashing function. func (self AdminLoginDetails) Validate(username, password string) bool { account, exists := self[username] if !exists { return false } password += account.PasswordSalt switch account.HashAlgorithm { case "sha512", "": rawHash := sha512.Sum512([]byte(password)) return lurkcoin.ConstantTimeCompare( hex.EncodeToString(rawHash[:]), account.PasswordHash, ) default: return false } } type csrfTokenManager map[string]string // Generate one CSRF token per user // TODO: Expiry func (self csrfTokenManager) Get(username string) string { token, ok := self[username] if !ok { token = lurkcoin.GenerateToken() self[username] = token } return token } func writeAdminErrorPage(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(500) io.WriteString(w, adminPagesHeader+ `

An error has occurred!

`+ `
`+html.EscapeString(msg)+`
`+ `You can hurry back to the previous page, or learn to like`+ ` this error and then eventually grow old and die.`+ `

`+ `Go back`+ adminPagesFooter) } func addAdminPages(router *httprouter.Router, db lurkcoin.Database, loginDetails AdminLoginDetails) { // TODO: Regenerate this often csrfTokens := make(csrfTokenManager) re, _ := regexp.Compile(`\s+`) var summaryTmpl, infoTmpl *template.Template var err error summaryTmpl, err = template.New("summary").Parse( re.ReplaceAllLiteralString(serverListTemplate, " "), ) if err != nil { panic(err) } infoTmpl, err = template.New("info").Funcs(template.FuncMap{ "YesNo": func(boolean bool) string { if boolean { return "Yes" } else { return "No" } }, }).Parse(re.ReplaceAllLiteralString(infoTemplate, " ")) if err != nil { panic(err) } accessDeniedPage := re.ReplaceAllLiteralString( adminPagesHeader+ `

`+ `Sorry, you do not have access to this resource at `+ `this time.`+ `

`+ adminPagesFooter, " ", ) authenticate := func(w http.ResponseWriter, r *http.Request) (string, bool) { w.Header().Set("Cache-Control", "no-store") username, password, ok := r.BasicAuth() if ok && loginDetails.Validate(username, password) { return username, true } w.Header().Set( "WWW-Authenticate", `Basic realm="lurkcoin admin pages", charset="UTF-8"`, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) io.WriteString(w, accessDeniedPage) return "", false } authenticateWithCSRF := func(w http.ResponseWriter, r *http.Request) (string, bool) { username, ok := authenticate(w, r) if !ok { return username, ok } if !loginDetails[username].AllowEditing { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) io.WriteString(w, accessDeniedPage) return username, false } r.ParseForm() t, ok := csrfTokens[username] if !ok || !lurkcoin.ConstantTimeCompare(r.Form.Get("csrfToken"), t) { w.WriteHeader(500) io.WriteString(w, "Please try again.") return username, false } return username, true } router.GET("/admin", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { username, ok := authenticate(w, r) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) var summaries []*adminPagesSummary var totalPendingTransactions int lurkcoin.ForEach(db, func(server *lurkcoin.Server) error { pendingTransactionCount := len(server.GetPendingTransactions()) totalPendingTransactions += pendingTransactionCount summaries = append(summaries, &adminPagesSummary{ server.UID, server.Name, server.GetBalance(), server.GetTargetBalance(), pendingTransactionCount, }) return nil }, false) var data struct { Summaries []*adminPagesSummary AllowEditing bool AllowDatabaseDownload bool CSRFToken string } data.Summaries = summaries d := loginDetails[username] data.AllowEditing = d.AllowEditing data.AllowDatabaseDownload = d.AllowDatabaseDownload if d.AllowEditing { data.CSRFToken = csrfTokens.Get(username) } err := summaryTmpl.Execute(w, data) if err != nil { panic(err) } }) serverInfo := func(w http.ResponseWriter, r *http.Request, serverName, username, msg string) { servers, ok, _ := db.GetServers([]string{serverName}) if !ok { w.WriteHeader(404) return } server := servers[0] defer db.FreeServers([]*lurkcoin.Server{server}, false) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) var data struct { Server *lurkcoin.Server CSRFToken string Message string AllowEditing bool } data.Server = server data.CSRFToken = csrfTokens.Get(username) data.Message = msg data.AllowEditing = loginDetails[username].AllowEditing err := infoTmpl.Execute(w, data) if err != nil { panic(err) } } router.GET("/admin/edit/:server", func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { username, ok := authenticate(w, r) if !ok { return } serverInfo(w, r, params.ByName("server"), username, "") }) router.POST("/admin/edit/:server", func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { adminUser, authenticated := authenticateWithCSRF(w, r) if !authenticated { return } // Get the server tr := lurkcoin.BeginDbTransaction(db) defer tr.Abort() servers, ok, _ := tr.GetServers(params.ByName("server")) if !ok { w.WriteHeader(404) return } server := servers[0] var msgs []string // Update the balance // This preserves any transactions after the initial page load. balance, oldBalance, ok := parseNumbers( r.Form.Get("balance"), r.Form.Get("oldBalance"), ) if !ok { msgs = append(msgs, "Invalid balance specified!") } else if !balance.Eq(oldBalance) { if !server.ChangeBal(balance.Sub(oldBalance)) { server.ChangeBal(server.GetBalance()) } msgs = append(msgs, "Balance updated!") log.Printf( "[Admin] User %#v changes balance of server %#v to %s", adminUser, server.Name, server.GetBalance(), ) } // Update the target balance targetBalance, oldTargetBalance, ok := parseNumbers( r.Form.Get("targetBalance"), r.Form.Get("oldTargetBalance"), ) if !ok { msgs = append(msgs, "Invalid target balance specified!") } else if !targetBalance.Eq(oldTargetBalance) { server.SetTargetBalance(targetBalance) msgs = append(msgs, "Target balance updated!") log.Printf( "[Admin] User %#v changes target balance of server %#v to %s", adminUser, server.Name, targetBalance, ) } // Update the webhook URL webhookURL := r.Form.Get("webhookURL") if webhookURL != r.Form.Get("oldWebhookURL") { ok := server.SetWebhookURL(webhookURL) if ok { msgs = append(msgs, "Webhook URL updated!") } else { msgs = append(msgs, "Invalid webhook URL!") } log.Printf( "[Admin] User %#v changes webhook URL of server %#v to %#v", adminUser, server.Name, server.WebhookURL, ) } if r.Form.Get("regenerateToken") == "on" { if len(msgs) == 0 { msgs = append(msgs, "New token: "+server.RegenerateToken()) log.Printf( "[Admin] User %#v regenerates the token of server %#v", adminUser, server.Name, ) } else { msgs = append(msgs, "Refusing to regenerate token as other"+ " settings were changed.") } } // Finish the transaction uid := server.UID tr.Finish() serverInfo(w, r, uid, adminUser, strings.Join(msgs, "\n")) }) router.POST("/admin/delete", func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { adminUser, authenticated := authenticateWithCSRF(w, r) if !authenticated { return } serverUID := r.Form.Get("server-uid") if lurkcoin.HomogeniseUsername(r.Form.Get("delete-uid")) != serverUID { writeAdminErrorPage(w, "You didn't type the correct server UID!") return } if db.DeleteServer(serverUID) { log.Printf( "[Admin] User %#v deleted server %#v", adminUser, serverUID, ) http.Redirect(w, r, "/admin", http.StatusSeeOther) } else { writeAdminErrorPage(w, "Could not delete "+serverUID+"!") } }) router.POST("/admin/create-server", func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { adminUser, authenticated := authenticateWithCSRF(w, r) if !authenticated { return } serverName := strings.TrimSpace(r.Form.Get("username")) var msg string if len(serverName) < 3 || len(serverName) > 32 { msg = "The server name must be between 3 and 32 characters." } else { tr := lurkcoin.BeginDbTransaction(db) defer tr.Abort() server, ok := tr.CreateServer(serverName) if ok { log.Printf( "[Admin] User %#v created server %#v", adminUser, server.Name, ) msg = "Token: " + server.Encode().Token tr.Finish() serverInfo(w, r, serverName, adminUser, msg) return } msg = "The specified server already exists!" } writeAdminErrorPage(w, msg) }) router.GET("/admin/backup", func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { username, ok := authenticate(w, r) if !ok { return } d := loginDetails[username] if !d.AllowEditing || !d.AllowDatabaseDownload { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) io.WriteString(w, accessDeniedPage) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set( "Content-Disposition", `attachment; filename="lurkcoin backup.json"`, ) w.WriteHeader(http.StatusOK) err := lurkcoin.BackupDatabase(db, w) if err != nil { panic(err) } }) }