first commit
This commit is contained in:
@@ -0,0 +1,777 @@
|
||||
// Package web serves the dashboard, position-detail, and settings pages.
|
||||
// The dashboard and position pages also expose "panel" fragment endpoints
|
||||
// (just the inner content, no page chrome) that the client-side tab system
|
||||
// and auto-refresh use to swap content without a full page load.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"cryptomon/internal/authsvc"
|
||||
"cryptomon/internal/kraken"
|
||||
"cryptomon/internal/portfolio"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
const sessionCookieName = "basis_session"
|
||||
|
||||
type Server struct {
|
||||
svc *portfolio.Service
|
||||
auth *authsvc.Service
|
||||
pollSeconds int
|
||||
dashboard *template.Template
|
||||
position *template.Template
|
||||
transfers *template.Template
|
||||
settings *template.Template
|
||||
account *template.Template
|
||||
login *template.Template
|
||||
}
|
||||
|
||||
func NewServer(svc *portfolio.Service, authSvc *authsvc.Service, pollSeconds int) *Server {
|
||||
funcs := template.FuncMap{
|
||||
"money": func(v float64) string { return money(v, svc.BaseCurrency()) },
|
||||
"curSymbol": func() string { return currencySymbol(svc.BaseCurrency()) },
|
||||
"amt": amt,
|
||||
"pctStr": pctStr,
|
||||
"pctClass": pctClass,
|
||||
"signClass": signClass,
|
||||
"dateStr": dateStr,
|
||||
"holdDuration": holdDuration,
|
||||
"capitalize": capitalize,
|
||||
"sortVal": sortVal,
|
||||
"wsSymbol": kraken.WSSymbol,
|
||||
}
|
||||
parse := func(files ...string) *template.Template {
|
||||
all := append([]string{"templates/base.html"}, files...)
|
||||
return template.Must(template.New("base").Funcs(funcs).ParseFS(templatesFS, all...))
|
||||
}
|
||||
return &Server{
|
||||
svc: svc,
|
||||
auth: authSvc,
|
||||
pollSeconds: pollSeconds,
|
||||
dashboard: parse("templates/dashboard.html", "templates/dashboard_rows.html"),
|
||||
position: parse("templates/position.html"),
|
||||
transfers: parse("templates/transfers.html"),
|
||||
settings: parse("templates/settings.html"),
|
||||
account: parse("templates/account.html"),
|
||||
login: template.Must(template.New("login.html").ParseFS(templatesFS, "templates/login.html")),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.handleDashboard)
|
||||
mux.HandleFunc("GET /position/{currency}", s.handlePosition)
|
||||
mux.HandleFunc("GET /transfers", s.handleTransfers)
|
||||
mux.HandleFunc("GET /settings", s.handleSettingsGet)
|
||||
mux.HandleFunc("GET /api/panel/portfolio", s.handlePanelPortfolio)
|
||||
mux.HandleFunc("GET /api/panel/position/{currency}", s.handlePanelPosition)
|
||||
mux.HandleFunc("GET /api/panel/transfers", s.handlePanelTransfers)
|
||||
mux.HandleFunc("GET /api/chart/portfolio", s.handleChartPortfolio)
|
||||
mux.HandleFunc("GET /api/chart/currency/{currency}", s.handleChartCurrency)
|
||||
mux.HandleFunc("GET /api/candles/{currency}", s.handleCandles)
|
||||
mux.HandleFunc("GET /api/summary", s.handleSummary)
|
||||
mux.HandleFunc("POST /api/favourite", s.handleFavourite)
|
||||
mux.HandleFunc("POST /settings/credentials", s.handleSaveCredentials)
|
||||
mux.HandleFunc("POST /settings/purchase", s.handleAddEntry)
|
||||
mux.HandleFunc("POST /settings/hidden", s.handleSetHidden)
|
||||
mux.HandleFunc("POST /settings/base-currency", s.handleSetBaseCurrency)
|
||||
mux.HandleFunc("GET /login", s.handleLoginGet)
|
||||
mux.HandleFunc("POST /login", s.handleLoginPost)
|
||||
mux.HandleFunc("POST /login/mfa", s.handleLoginMFA)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /account", s.handleAccountGet)
|
||||
mux.HandleFunc("POST /account/credentials", s.handleAccountCredentials)
|
||||
mux.HandleFunc("POST /account/mfa/enable", s.handleMFAEnable)
|
||||
mux.HandleFunc("POST /account/mfa/confirm", s.handleMFAConfirm)
|
||||
mux.HandleFunc("POST /account/mfa/disable", s.handleMFADisable)
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
return s.authMiddleware(mux)
|
||||
}
|
||||
|
||||
// authMiddleware gates every route except /login and /static/*: no valid
|
||||
// session cookie redirects to /login (or 401s for /api/* so fetch() calls
|
||||
// fail loudly instead of getting an HTML login page as "JSON"), and a valid
|
||||
// session with a pending forced password change is confined to /account
|
||||
// until it's resolved.
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/login" || path == "/login/mfa" || strings.HasPrefix(path, "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
valid, mustChange := false, false
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
valid, mustChange, _ = s.auth.CheckSession(cookie.Value)
|
||||
}
|
||||
if !valid {
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if mustChange && path != "/account" && path != "/logout" && !strings.HasPrefix(path, "/account/") {
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, username string, version int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: s.auth.NewSession(username, version),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(authsvc.SessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
if valid, _, _ := s.auth.CheckSession(cookie.Value); valid {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.renderLogin(w)
|
||||
}
|
||||
|
||||
// loginStepResponse backs both /login and /login/mfa: the page is a single
|
||||
// form (username+password only) whose JS drives a second step — an in-page
|
||||
// modal asking for the authenticator code — only when the server says MFA
|
||||
// is required, rather than always showing that field up front.
|
||||
type loginStepResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
MFARequired bool `json:"mfa_required,omitempty"`
|
||||
PendingToken string `json:"pending_token,omitempty"`
|
||||
Redirect string `json:"redirect,omitempty"`
|
||||
}
|
||||
|
||||
func loginRedirect(mustChange bool) string {
|
||||
if mustChange {
|
||||
return "/account"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
ok, err := s.auth.VerifyPassword(username, r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
s.writeJSON(w, loginStepResponse{Error: "Invalid username or password."})
|
||||
return
|
||||
}
|
||||
mustChange, version, mfaEnabled, err := s.auth.LoginStatus()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if mfaEnabled {
|
||||
s.writeJSON(w, loginStepResponse{MFARequired: true, PendingToken: s.auth.NewPendingMFAToken(username)})
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, username, version)
|
||||
s.writeJSON(w, loginStepResponse{Redirect: loginRedirect(mustChange)})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginMFA(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
ok, username, mustChange, version, err := s.auth.CompleteMFALogin(r.FormValue("pending_token"), r.FormValue("code"))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
s.writeJSON(w, loginStepResponse{Error: "Invalid authenticator code."})
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, username, version)
|
||||
s.writeJSON(w, loginStepResponse{Redirect: loginRedirect(mustChange)})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type accountData struct {
|
||||
pageMeta
|
||||
Username string
|
||||
MustChangePassword bool
|
||||
Error string
|
||||
MFAEnabled bool
|
||||
MFAPendingSecret string
|
||||
MFAOtpauthURI string
|
||||
MFAQRDataURI template.URL
|
||||
MFAError string
|
||||
}
|
||||
|
||||
func (s *Server) loadAccount() (accountData, error) {
|
||||
meta, _, err := s.newPageMeta("account", "")
|
||||
if err != nil {
|
||||
return accountData{}, err
|
||||
}
|
||||
username, err := s.auth.Username()
|
||||
if err != nil {
|
||||
return accountData{}, err
|
||||
}
|
||||
mustChange, err := s.auth.MustChangePassword()
|
||||
if err != nil {
|
||||
return accountData{}, err
|
||||
}
|
||||
mfaEnabled, err := s.auth.MFAEnabled()
|
||||
if err != nil {
|
||||
return accountData{}, err
|
||||
}
|
||||
d := accountData{pageMeta: meta, Username: username, MustChangePassword: mustChange, MFAEnabled: mfaEnabled}
|
||||
if !mfaEnabled {
|
||||
secret, uri, ok, err := s.auth.PendingMFASecret()
|
||||
if err != nil {
|
||||
return accountData{}, err
|
||||
}
|
||||
if ok {
|
||||
d.MFAPendingSecret = secret
|
||||
d.MFAOtpauthURI = uri
|
||||
if qr, err := qrDataURI(uri); err == nil {
|
||||
d.MFAQRDataURI = template.URL(qr)
|
||||
} else {
|
||||
slog.Error("generate MFA QR code", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadAccount()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, s.account, d)
|
||||
}
|
||||
|
||||
func (s *Server) renderAccountError(w http.ResponseWriter, msg string) {
|
||||
d, err := s.loadAccount()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
d.Error = msg
|
||||
s.render(w, s.account, d)
|
||||
}
|
||||
|
||||
func (s *Server) renderAccountMFAError(w http.ResponseWriter, msg string) {
|
||||
d, err := s.loadAccount()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
d.MFAError = msg
|
||||
s.render(w, s.account, d)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
currentUsername, err := s.auth.Username()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
ok, err := s.auth.VerifyPassword(currentUsername, r.FormValue("current_password"))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
s.renderAccountError(w, "Current password is incorrect.")
|
||||
return
|
||||
}
|
||||
newUsername := r.FormValue("new_username")
|
||||
version, err := s.auth.SetCredentials(newUsername, r.FormValue("new_password"), r.FormValue("confirm_password"))
|
||||
if err != nil {
|
||||
s.renderAccountError(w, err.Error())
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, newUsername, version)
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleMFAEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, err := s.auth.BeginMFA(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleMFAConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.auth.ConfirmMFA(r.FormValue("code")); err != nil {
|
||||
s.renderAccountMFAError(w, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleMFADisable(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.auth.DisableMFA(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// qrDataURI renders content as a PNG QR code, returned as a data: URI ready
|
||||
// for an <img src>, so no extra route/asset is needed to serve it.
|
||||
func qrDataURI(content string) (string, error) {
|
||||
png, err := qrcode.Encode(content, qrcode.Medium, 240)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.login.Execute(w, nil); err != nil {
|
||||
slog.Error("render login", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pageMeta is embedded in every page's data struct so base.html can read
|
||||
// .Page/.CurrentTab/.PollSeconds via Go's promoted-field lookup regardless
|
||||
// of which concrete struct it's rendering.
|
||||
type pageMeta struct {
|
||||
Page string // "portfolio" | "position" | "transfers" | "settings"
|
||||
CurrentTab string // currency code, when Page == "position"
|
||||
PollSeconds int
|
||||
|
||||
// Portfolio totals shown live in the topbar on every page.
|
||||
TotalValue float64
|
||||
TotalPL float64
|
||||
TotalPLPercent float64
|
||||
}
|
||||
|
||||
// newPageMeta builds the pageMeta shared by every page, and returns the
|
||||
// dashboard rows it computed along the way so callers that also need them
|
||||
// (the dashboard page itself) don't run the aggregation twice.
|
||||
func (s *Server) newPageMeta(page, currentTab string) (pageMeta, []portfolio.DashboardRow, error) {
|
||||
rows, err := s.svc.Dashboard(false)
|
||||
if err != nil {
|
||||
return pageMeta{}, nil, err
|
||||
}
|
||||
m := pageMeta{Page: page, CurrentTab: currentTab, PollSeconds: s.pollSeconds}
|
||||
var totalCost float64
|
||||
for _, r := range rows {
|
||||
m.TotalValue += r.CurrentValue
|
||||
totalCost += r.TotalCost
|
||||
}
|
||||
m.TotalPL = m.TotalValue - totalCost
|
||||
if totalCost != 0 {
|
||||
m.TotalPLPercent = m.TotalPL / totalCost * 100
|
||||
}
|
||||
return m, rows, nil
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
pageMeta
|
||||
Rows []portfolio.DashboardRow
|
||||
HasCredentials bool
|
||||
}
|
||||
|
||||
func (s *Server) loadDashboard() (dashboardData, error) {
|
||||
meta, rows, err := s.newPageMeta("portfolio", "")
|
||||
if err != nil {
|
||||
return dashboardData{}, err
|
||||
}
|
||||
return dashboardData{
|
||||
pageMeta: meta,
|
||||
Rows: rows,
|
||||
HasCredentials: s.svc.HasCredentials(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadDashboard()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, s.dashboard, d)
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelPortfolio(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadDashboard()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.renderContent(w, s.dashboard, d)
|
||||
}
|
||||
|
||||
type positionData struct {
|
||||
pageMeta
|
||||
Currency string
|
||||
Entries []portfolio.LedgerRow
|
||||
portfolio.CurrencySummary
|
||||
}
|
||||
|
||||
func (s *Server) loadPosition(currency string) (positionData, error) {
|
||||
entries, err := s.svc.Position(currency)
|
||||
if err != nil {
|
||||
return positionData{}, err
|
||||
}
|
||||
summary, err := s.svc.CurrencySummary(currency)
|
||||
if err != nil {
|
||||
return positionData{}, err
|
||||
}
|
||||
meta, _, err := s.newPageMeta("position", currency)
|
||||
if err != nil {
|
||||
return positionData{}, err
|
||||
}
|
||||
return positionData{
|
||||
pageMeta: meta,
|
||||
Currency: currency,
|
||||
Entries: entries,
|
||||
CurrencySummary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePosition(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadPosition(r.PathValue("currency"))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, s.position, d)
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelPosition(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadPosition(r.PathValue("currency"))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.renderContent(w, s.position, d)
|
||||
}
|
||||
|
||||
type transfersData struct {
|
||||
pageMeta
|
||||
Transfers []portfolio.TransferRow
|
||||
}
|
||||
|
||||
func (s *Server) loadTransfers() (transfersData, error) {
|
||||
rows, err := s.svc.Transfers()
|
||||
if err != nil {
|
||||
return transfersData{}, err
|
||||
}
|
||||
meta, _, err := s.newPageMeta("transfers", "")
|
||||
if err != nil {
|
||||
return transfersData{}, err
|
||||
}
|
||||
return transfersData{
|
||||
pageMeta: meta,
|
||||
Transfers: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleTransfers(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadTransfers()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, s.transfers, d)
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelTransfers(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadTransfers()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.renderContent(w, s.transfers, d)
|
||||
}
|
||||
|
||||
type currencyRow struct {
|
||||
Name string
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
type settingsData struct {
|
||||
pageMeta
|
||||
HasCredentials bool
|
||||
BaseCurrency string
|
||||
Currencies []currencyRow
|
||||
}
|
||||
|
||||
func (s *Server) loadSettings() (settingsData, error) {
|
||||
rows, err := s.svc.Dashboard(true)
|
||||
if err != nil {
|
||||
return settingsData{}, err
|
||||
}
|
||||
meta, _, err := s.newPageMeta("settings", "")
|
||||
if err != nil {
|
||||
return settingsData{}, err
|
||||
}
|
||||
d := settingsData{
|
||||
pageMeta: meta,
|
||||
HasCredentials: s.svc.HasCredentials(),
|
||||
BaseCurrency: s.svc.BaseCurrency(),
|
||||
}
|
||||
for _, r := range rows {
|
||||
d.Currencies = append(d.Currencies, currencyRow{Name: r.Currency, Hidden: r.Hidden})
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := s.loadSettings()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, s.settings, d)
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
apiKey := r.FormValue("api_key")
|
||||
apiSecret := r.FormValue("api_secret")
|
||||
if apiKey != "" && apiSecret != "" {
|
||||
if err := s.svc.SaveCredentials(apiKey, apiSecret); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
var validEntryTypes = map[string]bool{"buy": true, "sell": true, "deposit": true, "withdrawal": true}
|
||||
|
||||
func (s *Server) handleAddEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
entryType := r.FormValue("entry_type")
|
||||
currency := r.FormValue("currency")
|
||||
pair := r.FormValue("pair")
|
||||
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
|
||||
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
|
||||
fee, _ := strconv.ParseFloat(r.FormValue("fee"), 64)
|
||||
occurredAt, err := time.Parse("2006-01-02", r.FormValue("occurred_at"))
|
||||
if err != nil {
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
needsPrice := entryType == "buy" || entryType == "sell"
|
||||
needsPair := !kraken.IsFiat(currency) // a real-money (fiat) deposit/withdrawal has no market to price against
|
||||
if validEntryTypes[entryType] && currency != "" && (!needsPair || pair != "") && amount > 0 && (!needsPrice || price > 0) {
|
||||
if !needsPair {
|
||||
pair = ""
|
||||
}
|
||||
if err := s.svc.AddManualEntry(entryType, currency, pair, amount, price, fee, occurredAt); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetHidden(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
currency := r.FormValue("currency")
|
||||
hidden := r.FormValue("hidden") == "1"
|
||||
if currency != "" {
|
||||
if err := s.svc.SetHidden(currency, hidden); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBaseCurrency(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if v := r.FormValue("base_currency"); v != "" {
|
||||
if err := s.svc.SetBaseCurrency(v); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleFavourite is called via fetch() from inside the tab panels, so it
|
||||
// responds with a status code only rather than redirecting.
|
||||
func (s *Server) handleFavourite(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
currency := r.FormValue("currency")
|
||||
if currency == "" {
|
||||
http.Error(w, "currency required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.svc.SetFavourite(currency, r.FormValue("favourite") == "1"); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type summaryPosition struct {
|
||||
WSSymbol string `json:"ws_symbol"`
|
||||
Holdings float64 `json:"holdings"`
|
||||
Cost float64 `json:"cost"`
|
||||
Value float64 `json:"value"`
|
||||
FXRate float64 `json:"fx_rate"`
|
||||
}
|
||||
|
||||
type summaryResponse struct {
|
||||
TotalValue float64 `json:"total_value"`
|
||||
TotalPL float64 `json:"total_pl"`
|
||||
TotalPLPercent float64 `json:"total_pl_percent"`
|
||||
Positions []summaryPosition `json:"positions"`
|
||||
}
|
||||
|
||||
// handleSummary backs the topbar's live portfolio total, which is shown on
|
||||
// every page independent of which tab/panel is open.
|
||||
func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) {
|
||||
meta, rows, err := s.newPageMeta("", "")
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
resp := summaryResponse{
|
||||
TotalValue: meta.TotalValue,
|
||||
TotalPL: meta.TotalPL,
|
||||
TotalPLPercent: meta.TotalPLPercent,
|
||||
Positions: make([]summaryPosition, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
resp.Positions = append(resp.Positions, summaryPosition{
|
||||
WSSymbol: kraken.WSSymbol(row.Currency, row.Quote),
|
||||
Holdings: row.TotalAmount,
|
||||
Cost: row.TotalCost,
|
||||
Value: row.CurrentValue,
|
||||
FXRate: row.FXRate,
|
||||
})
|
||||
}
|
||||
s.writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleChartPortfolio(w http.ResponseWriter, r *http.Request) {
|
||||
points, err := s.svc.PortfolioHistory(chartRange(r))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if points == nil {
|
||||
points = []portfolio.ChartPoint{}
|
||||
}
|
||||
s.writeJSON(w, points)
|
||||
}
|
||||
|
||||
func (s *Server) handleChartCurrency(w http.ResponseWriter, r *http.Request) {
|
||||
points, err := s.svc.CurrencyHistory(r.PathValue("currency"), chartRange(r))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
if points == nil {
|
||||
points = []portfolio.ChartPoint{}
|
||||
}
|
||||
s.writeJSON(w, points)
|
||||
}
|
||||
|
||||
func (s *Server) handleCandles(w http.ResponseWriter, r *http.Request) {
|
||||
series, err := s.svc.CandleSeries(r.PathValue("currency"), chartRange(r))
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, series)
|
||||
}
|
||||
|
||||
func chartRange(r *http.Request) string {
|
||||
if v := r.URL.Query().Get("range"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("encode json", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
slog.Error("render template", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderContent(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "content", data); err != nil {
|
||||
slog.Error("render content", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, err error) {
|
||||
slog.Error("handler error", "err", err)
|
||||
http.Error(w, "something went wrong: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
Reference in New Issue
Block a user