339 lines
11 KiB
Go
339 lines
11 KiB
Go
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)
|
|
}
|