Files
2026-08-25 06:37:14 +01:00

778 lines
22 KiB
Go

// 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)
}