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