Upload statements from the web app
The Accounts screen gets an Add statements panel: pick an account, drop files on it or choose them, and they are saved into that account's folder and imported. The folder stays the source of truth -- an upload only puts a file where `money import` looks, then runs the same import as the Import button, so deleting index.db and re-importing still loses nothing. Files travel base64 inside JSON rather than as multipart. There is no auth, and the JSON-only rule is what keeps another site's form from posting here; multipart is exactly what such a form can send. An upload never replaces a statement: identical contents are a no-op and different ones are refused with 409. Names import would not read back -- not a plain file name, dotfiles, account.toml, outside the account's include patterns -- are refused, and a batch is checked whole before any of it is written. Files are written through a dotfile and renamed, so a concurrent import never reads half of one. The overview now lists the account folders on disk, read fresh so one created after startup is a valid target; it replaces the configured count the empty accounts screen used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+175
-14
@@ -6,12 +6,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -84,6 +87,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/transfers/delete", s.write(s.deleteTransfers))
|
||||
|
||||
mux.HandleFunc("POST /api/import", s.write(s.runImport))
|
||||
mux.HandleFunc("POST /api/upload", s.write(s.upload))
|
||||
mux.HandleFunc("POST /api/retag", s.write(s.retag))
|
||||
return mux
|
||||
}
|
||||
@@ -159,7 +163,11 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
return decodeLimit(r, v, 1<<20)
|
||||
}
|
||||
|
||||
func decodeLimit(r *http.Request, v any, limit int64) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, limit))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("bad request body: %v", err)
|
||||
@@ -167,8 +175,8 @@ func decode(r *http.Request, v any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// status is the reply to every write: one line saying what happened, as the
|
||||
// TUI's status bar would.
|
||||
// status is the reply to every write: one line saying what happened, shown
|
||||
// in the page's status banner.
|
||||
type status struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -195,14 +203,18 @@ type accountRow struct {
|
||||
}
|
||||
|
||||
type overview struct {
|
||||
Root string `json:"root"`
|
||||
// Configured is how many account folders are on disk, which the empty
|
||||
// accounts screen needs: an account.toml is not enough until an import.
|
||||
Configured int `json:"configured"`
|
||||
Accounts []accountRow `json:"accounts"`
|
||||
Root string `json:"root"`
|
||||
Accounts []accountRow `json:"accounts"`
|
||||
// AccountSlugs and Tags are what the builders' fields complete against.
|
||||
AccountSlugs []string `json:"accountSlugs"`
|
||||
Tags []string `json:"tags"`
|
||||
// Folders are the account folders on disk now — what an upload can go
|
||||
// into, and what the empty accounts screen counts, since an account.toml
|
||||
// is not enough to appear until an import. Read fresh, since a folder made
|
||||
// after startup is a valid target.
|
||||
// A broken account.toml is reported rather than failing the whole page.
|
||||
Folders []string `json:"folders"`
|
||||
FoldersErr string `json:"foldersError,omitempty"`
|
||||
// Stale reports that rules.toml on disk no longer says what the index was
|
||||
// derived from — it was edited by hand — so the page can offer a retag
|
||||
// instead of quietly describing rules that are not the ones in force.
|
||||
@@ -215,7 +227,7 @@ func (s *Server) overview(*http.Request) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := overview{Root: s.root, Configured: len(s.accounts), Accounts: []accountRow{}}
|
||||
out := overview{Root: s.root, Accounts: []accountRow{}}
|
||||
for _, a := range accounts {
|
||||
bal, err := s.db.Balance(a.ID)
|
||||
if err != nil {
|
||||
@@ -236,6 +248,14 @@ func (s *Server) overview(*http.Request) (any, error) {
|
||||
if out.Tags, err = s.knownTags(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Folders = []string{}
|
||||
if folders, err := config.LoadAccounts(s.root); err != nil {
|
||||
out.FoldersErr = err.Error()
|
||||
} else {
|
||||
for _, f := range folders {
|
||||
out.Folders = append(out.Folders, f.Slug)
|
||||
}
|
||||
}
|
||||
onDisk, err := config.LoadRules(s.root)
|
||||
if err != nil {
|
||||
out.Stale, out.RulesErr = true, err.Error()
|
||||
@@ -1119,22 +1139,26 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
||||
if err := decode(r, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.importAll(req.Force)
|
||||
}
|
||||
|
||||
func (s *Server) importAll(force bool) (importJSON, error) {
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
return importJSON{}, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
s.root, config.AccountFile)
|
||||
}
|
||||
if err := s.reloadRules(); err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
s.accounts = accounts
|
||||
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: req.Force})
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: force})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
|
||||
_, added, skipped := res.Total()
|
||||
@@ -1158,6 +1182,143 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadLimit caps an upload request. Statements are small — a year of PDF is
|
||||
// a few hundred kilobytes — and the body is base64, a third larger than the
|
||||
// files it carries.
|
||||
const uploadLimit = 64 << 20
|
||||
|
||||
type uploadFile struct {
|
||||
Name string `json:"name"`
|
||||
Data []byte `json:"data"` // base64 on the wire
|
||||
}
|
||||
|
||||
type uploadReq struct {
|
||||
Account string `json:"account"`
|
||||
Files []uploadFile `json:"files"`
|
||||
}
|
||||
|
||||
// upload saves statements into an account folder and imports them. The files
|
||||
// on disk are the source of truth and the index is derived from them, so an
|
||||
// upload is nothing more than putting a file where `money import` looks; the
|
||||
// import that follows is the one the Import button runs.
|
||||
//
|
||||
// Files arrive base64 inside JSON rather than as multipart: a multipart body
|
||||
// is exactly what a cross-site form can send, and the JSON-only rule is the
|
||||
// only thing standing between another page and this server.
|
||||
func (s *Server) upload(r *http.Request) (any, error) {
|
||||
var req uploadReq
|
||||
if err := decodeLimit(r, &req, uploadLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return nil, badRequest("no files to upload")
|
||||
}
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var acc *config.Account
|
||||
for _, a := range accounts {
|
||||
if a.Slug == req.Account {
|
||||
acc = a
|
||||
}
|
||||
}
|
||||
if acc == nil {
|
||||
return nil, badRequest("no account folder %q in %s (an account is a folder containing %s)",
|
||||
req.Account, s.root, config.AccountFile)
|
||||
}
|
||||
|
||||
// Everything is checked before anything is written, so a bad file in a
|
||||
// batch leaves the folder as it was rather than half uploaded.
|
||||
var write []uploadFile
|
||||
var same []string
|
||||
seen := map[string]bool{}
|
||||
for _, f := range req.Files {
|
||||
if err := checkStatementName(acc, f.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return nil, badRequest("%s is in the upload twice", f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
existing, err := os.ReadFile(filepath.Join(acc.Dir, f.Name))
|
||||
switch {
|
||||
case err == nil && bytes.Equal(existing, f.Data):
|
||||
same = append(same, f.Name)
|
||||
case err == nil:
|
||||
// A statement is the source of truth for what it already
|
||||
// imported; replacing it under the same name is not an upload's
|
||||
// decision to make.
|
||||
return nil, &apiError{http.StatusConflict, fmt.Sprintf(
|
||||
"%s/%s already exists with different contents; rename the file or remove the old one first",
|
||||
acc.Slug, f.Name)}
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
default:
|
||||
write = append(write, f)
|
||||
}
|
||||
}
|
||||
for _, f := range write {
|
||||
if err := writeStatement(acc.Dir, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out, err := s.importAll(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := fmt.Sprintf("uploaded %d file(s) to %s", len(write), acc.Slug)
|
||||
if len(same) > 0 {
|
||||
msg += fmt.Sprintf(" (%d already there)", len(same))
|
||||
}
|
||||
out.Status = msg + " · " + out.Status
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkStatementName refuses any name the importer would not read back as a
|
||||
// statement of this account, and anything that is not a plain file name.
|
||||
func checkStatementName(acc *config.Account, name string) error {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\\x00") ||
|
||||
name == "." || name == ".." {
|
||||
return badRequest("%q is not a plain file name", name)
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || name == config.AccountFile {
|
||||
return badRequest("%s would be ignored by import (dotfiles and %s are not statements)",
|
||||
name, config.AccountFile)
|
||||
}
|
||||
if len(acc.Include) > 0 && !slices.ContainsFunc(acc.Include, func(p string) bool { return glob.Match(p, name) }) {
|
||||
return badRequest("%s does not match %s's include patterns (%s), so import would ignore it",
|
||||
name, acc.Slug, strings.Join(acc.Include, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStatement writes through a dotfile and renames it into place, so a
|
||||
// concurrent `money import` never reads half a statement.
|
||||
func writeStatement(dir string, f uploadFile) error {
|
||||
path := filepath.Join(dir, f.Name)
|
||||
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||
if _, err := tmp.Write(f.Data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// retag is `money retag`: both halves of what rules.toml decides, re-derived
|
||||
// from the file as it is on disk now.
|
||||
func (s *Server) retag(*http.Request) (any, error) {
|
||||
|
||||
Reference in New Issue
Block a user