package server import ( "context" "encoding/json" "errors" "io" "io/fs" "log" "mime" "net" "net/http" "net/url" "strconv" "strings" "time" "finance-duck/internal/analytics" "finance-duck/internal/app" "finance-duck/internal/banking" "finance-duck/internal/classification" "finance-duck/internal/domain" ) type Server struct { app *app.App mux *http.ServeMux origin *url.URL } func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) { s := &Server{app: a, mux: http.NewServeMux()} if publicURL != "" { u, e := url.Parse(publicURL) if e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "" && u.Path != "/" { return nil, errors.New("public URL must be an http(s) origin") } s.origin = u } s.mux.HandleFunc("GET /api/state", s.state) s.mux.HandleFunc("GET /api/dashboard", s.dashboard) s.mux.HandleFunc("GET /api/wealth", func(w http.ResponseWriter, r *http.Request) { v, e := a.Wealth(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/accounts", s.account) s.mux.HandleFunc("POST /api/categories", s.category) s.mux.HandleFunc("POST /api/tags", s.tag) s.mux.HandleFunc("POST /api/merchants", s.merchant) s.mux.HandleFunc("POST /api/instruments", s.instrument) s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer) s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction) s.mux.HandleFunc("POST /api/manage", s.manage) s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare) s.mux.HandleFunc("POST /api/import/confirm", s.importConfirm) s.mux.HandleFunc("POST /api/import/cancel", s.importCancel) s.mux.HandleFunc("POST /api/backfill", s.backfill) s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/quotes/refresh", func(w http.ResponseWriter, r *http.Request) { v, e := a.RefreshQuotes(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(s.manualBankContext(r)); respond(w, v, e) }) s.mux.HandleFunc("POST /api/settings", s.settings) s.mux.HandleFunc("GET /api/models", func(w http.ResponseWriter, r *http.Request) { v, e := a.VerifiedModels(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/settings/openrouter", s.openRouterKey) s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings) s.mux.HandleFunc("POST /api/banking/authorize", s.authorize) s.mux.HandleFunc("GET /api/banking/institutions", func(w http.ResponseWriter, r *http.Request) { v, e := a.Institutions(r.Context(), r.URL.Query().Get("country")) respond(w, v, e) }) s.mux.HandleFunc("GET /api/banking/callback", s.callback) s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) { v, e := a.Balances(s.manualBankContext(r), r.URL.Query().Get("account_id")) respond(w, v, e) }) s.mux.HandleFunc("POST /api/reclassify/preview", s.preview) s.mux.HandleFunc("POST /api/reclassify/progress", s.previewProgress) s.mux.HandleFunc("POST /api/reclassify/apply", s.apply) s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel) s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose) s.mux.HandleFunc("POST /api/taxonomy/apply", s.taxonomyApply) s.mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { _, err := a.Snapshot(r.Context()) if err != nil { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{"error": "canonical dataset unavailable"}) return } respond(w, map[string]bool{"ok": true}, nil) }) unknownAPI := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "unknown API endpoint"}) } s.mux.HandleFunc("GET /api/", unknownAPI) s.mux.HandleFunc("POST /api/", unknownAPI) fileServer := http.FileServer(http.FS(assets)) s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Del("Content-Type") name := strings.TrimPrefix(r.URL.Path, "/") if name == "" { name = "index.html" } if _, e := fs.Stat(assets, name); e != nil { if strings.Contains(name, ".") { http.NotFound(w, r) return } r.URL.Path = "/" } fileServer.ServeHTTP(w, r) }) return s, nil } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://enablebanking.com https://*.enablebanking.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") // Host allowlisting prevents DNS rebinding against a no-login private service. host := r.Host if h, _, e := net.SplitHostPort(host); e == nil { host = h } local := host == "localhost" || host == "127.0.0.1" || host == "::1" if s.origin != nil { if !strings.EqualFold(r.Host, s.origin.Host) { http.Error(w, "unexpected Host", http.StatusForbidden) return } } else if !local { http.Error(w, "configure -public-url for this host", http.StatusForbidden) return } if r.Method != "GET" && r.Method != "HEAD" { if r.Header.Get("Sec-Fetch-Site") == "cross-site" { http.Error(w, "cross-site mutation denied", http.StatusForbidden) return } if origin := r.Header.Get("Origin"); origin != "" { u, e := url.Parse(origin) scheme := "http" if r.TLS != nil { scheme = "https" } if s.origin != nil { scheme = s.origin.Scheme } if e != nil || u.Host != r.Host || u.Scheme != scheme { http.Error(w, "origin mismatch", http.StatusForbidden) return } } media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) if r.URL.Path != "/api/import/prepare" && media != "application/json" { http.Error(w, "application/json required", http.StatusUnsupportedMediaType) return } } r.Body = http.MaxBytesReader(w, r.Body, 32<<20) s.mux.ServeHTTP(w, r) } // manualBankContext is used only at manual account-data boundaries, after the // Host/origin guards. The configured public origin trusts the local reverse // proxy to append its observed client IP to X-Forwarded-For. Never trust a // client-supplied leading hop, or forwarding headers from a non-loopback peer. func (s *Server) manualBankContext(r *http.Request) context.Context { peer, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { peer = r.RemoteAddr } ip := net.ParseIP(peer) if s.origin != nil && ip != nil && ip.IsLoopback() { // A local proxy address is not the end user's address. ip = nil forwarded := r.Header.Values("X-Forwarded-For") if len(forwarded) > 0 { last := forwarded[len(forwarded)-1] if index := strings.LastIndexByte(last, ','); index >= 0 { last = last[index+1:] } // A malformed proxy hop is unknown, not the proxy's own PSU IP. ip = net.ParseIP(strings.TrimSpace(last)) } } address := "" if ip != nil { address = ip.String() } return banking.WithPSU(r.Context(), banking.PSU{ IPAddress: address, UserAgent: r.UserAgent(), Accept: r.Header.Get("Accept"), AcceptCharset: r.Header.Get("Accept-Charset"), AcceptEncoding: r.Header.Get("Accept-Encoding"), AcceptLanguage: r.Header.Get("Accept-Language"), }) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { d := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) d.DisallowUnknownFields() if e := d.Decode(v); e != nil { respond(w, nil, e) return false } if e := d.Decode(&struct{}{}); e != io.EOF { respond(w, nil, errors.New("expected one JSON document")) return false } return true } func respond(w http.ResponseWriter, v any, err error) { if err != nil { code := http.StatusBadRequest if strings.Contains(strings.ToLower(err.Error()), "revision") || strings.Contains(strings.ToLower(err.Error()), "conflict") { code = http.StatusConflict } w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } json.NewEncoder(w).Encode(v) } func (s *Server) state(w http.ResponseWriter, r *http.Request) { v, e := s.app.Snapshot(r.Context()) respond(w, v, e) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() from, to := q.Get("from"), q.Get("to") for _, date := range []string{from, to} { if date != "" { if _, e := time.Parse("2006-01-02", date); e != nil { respond(w, nil, errors.New("dates must be YYYY-MM-DD")) return } } } if from != "" && to != "" && from > to { respond(w, nil, errors.New("from must not exceed to")) return } v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagID: q.Get("tag_id"), MerchantID: q.Get("merchant_id")}) respond(w, v, e) } func (s *Server) account(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Account domain.Account `json:"account"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveAccount(d, b.Account) }) respond(w, v, e) } func (s *Server) category(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Category domain.Category `json:"category"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveCategory(d, b.Category) }) respond(w, v, e) } func (s *Server) tag(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Tag domain.Tag `json:"tag"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveTag(d, b.Tag) }) respond(w, v, e) } func (s *Server) merchant(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Merchant domain.Merchant `json:"merchant"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveMerchant(d, b.Merchant) }) respond(w, v, e) } func (s *Server) instrument(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Instrument domain.Instrument `json:"instrument"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveInstrument(d, b.Instrument) }) respond(w, v, e) } // transfer links or unlinks one transaction's own-account counterpart. It is a // separate endpoint because both sides change together: the transaction editor // cannot express it, and validation refuses a half-applied link. func (s *Server) transfer(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` PeerID string `json:"peer_id"` } if !decode(w, r, &b) { return } v, e := s.app.LinkTransfer(r.Context(), b.Revision, r.PathValue("id"), b.PeerID) respond(w, v, e) } func (s *Server) transaction(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Enrichment domain.Enrichment `json:"enrichment"` } if !decode(w, r, &b) { return } v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { for i, t := range d.Transactions { if t.Facts.ID == r.PathValue("id") { if b.Enrichment.Kind != t.Enrichment.Kind || b.Enrichment.TransferPeerID != t.Enrichment.TransferPeerID { return errors.New("transaction kind and transfer links are determined from bank facts") } b.Enrichment.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)} d.Transactions[i].Enrichment = b.Enrichment if b.Enrichment.MerchantID != "" { app.LearnAlias(d, t.Facts, b.Enrichment.MerchantID) } return nil } } return errors.New("unknown transaction") }) respond(w, v, e) } func (s *Server) manage(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` Entity string `json:"entity"` Action string `json:"action"` ID string `json:"id"` TargetID string `json:"target_id"` } if !decode(w, r, &b) { return } v, e := s.app.ManageRegistry(r.Context(), b.Revision, b.Entity, b.Action, b.ID, b.TargetID) respond(w, v, e) } func (s *Server) importPrepare(w http.ResponseWriter, r *http.Request) { if e := r.ParseMultipartForm(2 << 20); e != nil { respond(w, nil, e) return } defer r.MultipartForm.RemoveAll() f, _, e := r.FormFile("file") if e != nil { respond(w, nil, e) return } defer f.Close() v, e := s.app.PrepareCSVImport(r.Context(), r.FormValue("revision"), r.FormValue("account_id"), f) respond(w, v, e) } func (s *Server) importConfirm(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Revision string `json:"revision"` } if !decode(w, r, &b) { return } v, e := s.app.ConfirmCSVImport(r.Context(), b.ID, b.Revision) respond(w, v, e) } func (s *Server) importCancel(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` } if !decode(w, r, &b) { return } s.app.CancelCSVImport(b.ID) respond(w, map[string]bool{"ok": true}, nil) } func (s *Server) backfill(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` AccountID string `json:"account_id"` HistoryMonths int `json:"history_months"` } if !decode(w, r, &b) { return } v, e := s.app.Backfill(s.manualBankContext(r), b.Revision, b.AccountID, b.HistoryMonths) respond(w, v, e) } func (s *Server) settings(w http.ResponseWriter, r *http.Request) { var b app.Settings if !decode(w, r, &b) { return } v, e := s.app.SaveSettings(r.Context(), b) respond(w, v, e) } func (s *Server) openRouterKey(w http.ResponseWriter, r *http.Request) { var b struct { APIKey *string `json:"api_key"` } d := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) d.DisallowUnknownFields() // Decoder errors can quote request values. Never echo credential input. if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF || b.APIKey == nil { respond(w, nil, errors.New("expected one JSON object with an api_key string")) return } v, e := s.app.SaveOpenRouterKey(r.Context(), *b.APIKey) respond(w, v, e) } func (s *Server) bankingSettings(w http.ResponseWriter, r *http.Request) { var b struct { AppID *string `json:"app_id"` PrivateKey *string `json:"private_key"` RedirectURL *string `json:"redirect_url"` Remove bool `json:"remove"` } d := json.NewDecoder(io.LimitReader(r.Body, 256<<10)) d.DisallowUnknownFields() // Parsing failures must not echo uploaded private-key content. if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF { respond(w, nil, errors.New("invalid Enable Banking configuration request")) return } if b.Remove { if b.AppID != nil || b.PrivateKey != nil || b.RedirectURL != nil { respond(w, nil, errors.New("remove cannot be combined with banking credentials")) return } v, e := s.app.RemoveBankingSettings(r.Context()) respond(w, v, e) return } if b.AppID == nil || b.RedirectURL == nil { respond(w, nil, errors.New("Enable Banking application ID and callback URL are required")) return } scheme, host := "http", r.Host if r.TLS != nil { scheme = "https" } if s.origin != nil { scheme, host = s.origin.Scheme, s.origin.Host } if *b.RedirectURL != scheme+"://"+host+"/api/banking/callback" { respond(w, nil, errors.New("Enable Banking callback URL must match this application's origin and /api/banking/callback path")) return } v, e := s.app.SaveBankingSettings(r.Context(), *b.AppID, b.PrivateKey, *b.RedirectURL) respond(w, v, e) } func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { var b struct { Institution string `json:"institution"` Country string `json:"country"` PSUType string `json:"psu_type"` HistoryMonths int `json:"history_months"` } if !decode(w, r, &b) { return } v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, b.PSUType, b.HistoryMonths) respond(w, map[string]string{"url": v}, e) } func (s *Server) callback(w http.ResponseWriter, r *http.Request) { unlinkable, e := s.app.Callback(r.Context(), r.URL.Query().Get("code"), r.URL.Query().Get("state")) if e != nil { // Callback errors are locally generated and sanitized. Landing on the // app with the reason visible beats a bare JSON error page. log.Printf("bank connection callback failed: %v", e) http.Redirect(w, r, "/?connect_error="+url.QueryEscape(e.Error()), http.StatusSeeOther) return } target := "/?connected=1" if unlinkable > 0 { target += "&unlinkable=" + strconv.Itoa(unlinkable) } http.Redirect(w, r, target, http.StatusSeeOther) } func (s *Server) preview(w http.ResponseWriter, r *http.Request) { var b app.PreviewRequest if !decode(w, r, &b) { return } v, e := s.app.StartPreview(r.Context(), b) respond(w, v, e) } func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` } if !decode(w, r, &b) { return } v, e := s.app.PreviewProgress(b.ID) respond(w, v, e) } func (s *Server) apply(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Revision string `json:"revision"` TransactionIDs []string `json:"transaction_ids"` } if !decode(w, r, &b) { return } v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs) respond(w, v, e) } func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` } if !decode(w, r, &b) { return } s.app.CancelPreview(b.ID) respond(w, map[string]bool{"ok": true}, nil) } func (s *Server) taxonomyPropose(w http.ResponseWriter, r *http.Request) { var b app.TaxonomyProposalRequest if !decode(w, r, &b) { return } v, e := s.app.ProposeTaxonomy(r.Context(), b) respond(w, v, e) } func (s *Server) taxonomyApply(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Revision string `json:"revision"` Approved classification.TaxonomyProposal `json:"approved"` } if !decode(w, r, &b) { return } v, e := s.app.ApplyTaxonomy(r.Context(), b.ID, b.Revision, b.Approved) respond(w, v, e) }