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:
2026-10-02 18:51:56 +02:00
co-authored by Claude Opus 5.5
parent ec8a844441
commit 7ec9976b80
6 changed files with 489 additions and 22 deletions
+175 -14
View File
@@ -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) {