This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
package server
import (
"encoding/json"
"errors"
"io"
"io/fs"
"mime"
"net"
"net/http"
"net/url"
"strings"
"time"
"finance-duck/internal/analytics"
"finance-duck/internal/app"
"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/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(r.Context()); respond(w, v, e) })
s.mux.HandleFunc("POST /api/settings", s.settings)
s.mux.HandleFunc("POST /api/banking/authorize", s.authorize)
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(r.Context(), 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:; 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)
}
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.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.Manage(d, 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) 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) authorize(w http.ResponseWriter, r *http.Request) {
var b struct {
Institution string `json:"institution"`
Country string `json:"country"`
}
if !decode(w, r, &b) {
return
}
v, e := s.app.Authorize(r.Context(), b.Institution, b.Country)
respond(w, map[string]string{"url": v}, e)
}
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
e := s.app.Callback(r.Context(), r.URL.Query().Get("code"), r.URL.Query().Get("state"))
if e != nil {
respond(w, nil, e)
return
}
http.Redirect(w, r, "/?connected=1", 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)
}
+63
View File
@@ -0,0 +1,63 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"finance-duck/internal/app"
)
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
h, err := New(a, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>Finance</title>")}}, "")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name, host, origin, content string
want int
}{{"rebound host", "attacker.example", "", "application/json", 403}, {"cross origin", "localhost:8080", "https://attacker.example", "application/json", 403}, {"simple form CSRF", "localhost:8080", "", "text/plain", 415}, {"valid local mutation", "localhost:8080", "http://localhost:8080", "application/json", 200}}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/settings", strings.NewReader(`{"model":"example/model","include_amount":false}`))
r.Host = tt.host
r.Header.Set("Content-Type", tt.content)
r.Header.Set("Origin", tt.origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tt.want {
t.Fatalf("got %d: %s", w.Code, w.Body.String())
}
})
}
r := httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var s app.State
if err = json.NewDecoder(w.Body).Decode(&s); err != nil {
t.Fatal(err)
}
if s.Settings.Model != "example/model" {
t.Fatal("same-origin edit not persisted")
}
r = httptest.NewRequest(http.MethodGet, "http://localhost:8080/", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
b, _ := io.ReadAll(w.Body)
if w.Code != 200 || !strings.Contains(string(b), "<!doctype html>") {
t.Fatalf("UI not served: %d %s", w.Code, b)
}
}