//
// 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"
"html/template"
"io"
"log"
"lurkcoin"
"net/http"
"regexp"
"strings"
)
const adminPagesHeader = `
lurkcoin admin pages
`
const adminPagesFooter = ``
const serverListTemplate = adminPagesHeader + `
Server list
Total: {{len .Summaries}} server(s).
Name |
Balance |
Target balance |
Pending transactions |
... |
{{range $summary := .Summaries}}
{{$summary.Name}} |
{{$summary.Balance}} |
{{$summary.TargetBalance}} |
{{$summary.PendingTransactionCount}} |
Edit |
{{end}}
{{if .AllowEditing}}
{{if .AllowDatabaseDownload}}
Download database backup
{{end}}
{{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}}
History
ID |
Source |
Source server |
Target |
Target server |
Sent amount |
Amount |
Received amount |
Time |
Revertable |
{{range $transaction := .Server.GetHistory}}
{{$transaction.ID}} |
{{$transaction.Source}} |
{{$transaction.SourceServer}} |
{{$transaction.Target}} |
{{$transaction.TargetServer}} |
{{$transaction.SentAmount.RawString}} |
{{$transaction.Amount}} |
{{$transaction.ReceivedAmount.RawString}} |
{{$transaction.GetTime}} |
{{$transaction.Revertable | YesNo}} |
{{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.Replace(n1, ",", "", -1)
n2 = strings.Replace(n2, ",", "", -1)
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 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,
)
}
// Finish the transaction
uid := server.UID
tr.Finish()
serverInfo(w, r, uid, adminUser, strings.Join(msgs, "\n"))
})
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!"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(500)
io.WriteString(w, adminPagesHeader+
`An error has occurred!
`+
``+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)
})
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)
}
})
}