init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user