Three defects made new connections silently vanish while removed accounts returned: - A single shared account the journal cannot represent (securities or card entries without IBAN, stable identification or currency) aborted the entire consent. Usable accounts are now linked and the rest counted and reported. - A consent that linked nothing was stored, redirected as success and later reaped by session recovery. It now fails with the reason. - Callback failures rendered a bare JSON error page and were never logged. They now log and redirect into the app with the reason shown. - Deleting an account left its session binding, so the next connect or sync recovered the binding and re-added the account. Account deletion now releases bindings, consents and cursors before committing.
459 lines
15 KiB
Go
459 lines
15 KiB
Go
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/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("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/transactions/{id}", s.transaction)
|
|
s.mux.HandleFunc("POST /api/manage", s.manage)
|
|
s.mux.HandleFunc("POST /api/import", s.importCSV)
|
|
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/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("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/apply", s.apply)
|
|
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
|
|
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" && 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) 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
|
|
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) importCSV(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.ImportCSV(r.Context(), r.FormValue("revision"), r.FormValue("account_id"), f)
|
|
respond(w, v, e)
|
|
}
|
|
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"`
|
|
HistoryMonths int `json:"history_months"`
|
|
}
|
|
if !decode(w, r, &b) {
|
|
return
|
|
}
|
|
v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, 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.Preview(r.Context(), b)
|
|
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)
|
|
}
|