List each account's statement files, and open them

The Accounts screen gets a Statements list: every file in every account
folder beside what the index made of it -- imported, changed since,
not imported yet (or failed), or gone from disk while its rows remain --
with its size, modified and import times, and how many transactions it
brought in. Clicking a name opens the file.

The list is read from the folders, not the index, through
importer.StatementFiles, so a file shows exactly when import would read
it; store.SourceFiles and importer.Checksum then say how far each has
got. A file the index remembers but the disk lost is listed as missing
rather than vanishing, since its rows would not survive a rebuild.

A file is served only by finding it in that list, never by joining the
requested name onto a path. Statements come from outside and are served
from the app's origin, so none is rendered as a page: text is text/plain
under CSP sandbox, anything not text or PDF is a sandboxed download, and
PDFs -- whose viewers refuse a sandbox -- open in the browser's own
isolated viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 20:00:03 +02:00
co-authored by Claude Opus 5.5
parent 7f5b4846ef
commit 9ad6c05f9a
8 changed files with 373 additions and 7 deletions
+144
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
@@ -88,6 +89,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/import", s.write(s.runImport))
mux.HandleFunc("POST /api/upload", s.write(s.upload))
mux.HandleFunc("GET /api/files", s.read(s.fileList))
mux.HandleFunc("GET /api/files/{account}/{name}", s.serveFile)
mux.HandleFunc("POST /api/retag", s.write(s.retag))
return mux
}
@@ -1388,6 +1391,147 @@ func writeStatement(dir string, f uploadFile) error {
return nil
}
type fileRow struct {
Account string `json:"account"`
Name string `json:"name"`
Size int64 `json:"size"`
// Modified is the file's mtime, YYYY-MM-DD HH:MM in local time; blank for
// a file that is gone from disk.
Modified string `json:"modified"`
// Status is "imported", "changed" (on disk differs from what was
// imported), "new" (not imported yet, or its import failed) or "missing"
// (imported, then removed from disk; its rows stay in the index until it
// is rebuilt).
Status string `json:"status"`
ImportedAt string `json:"importedAt"`
Added int `json:"added"`
}
// fileList is every statement on disk next to what the index recorded about
// it. The folders are the source of truth, so they decide what is listed, and
// a file the index remembers but the disk no longer holds is called out: its
// rows would not survive a rebuild.
func (s *Server) fileList(*http.Request) (any, error) {
recorded, err := s.db.SourceFiles()
if err != nil {
return nil, err
}
byPath := map[string]store.SourceFileInfo{}
for _, f := range recorded {
byPath[f.Path] = f
}
accounts, err := config.LoadAccounts(s.root)
if err != nil {
return nil, err
}
out := []fileRow{}
onDisk := map[string]bool{}
for _, acc := range accounts {
paths, err := importer.StatementFiles(acc)
if err != nil {
return nil, err
}
for _, path := range paths {
rel, err := filepath.Rel(s.root, path)
if err != nil {
return nil, err
}
onDisk[rel] = true
row := fileRow{Account: acc.Slug, Name: filepath.Base(path), Status: "new"}
if info, err := os.Stat(path); err == nil {
row.Size, row.Modified = info.Size(), info.ModTime().Format("2006-01-02 15:04")
}
if rec, ok := byPath[rel]; ok {
row.ImportedAt, row.Added, row.Status = rec.ImportedAt, rec.Added, "imported"
if sum, err := importer.Checksum(path); err != nil {
return nil, err
} else if sum != rec.SHA256 {
row.Status = "changed"
}
}
out = append(out, row)
}
}
for _, rec := range recorded {
if !onDisk[rec.Path] {
out = append(out, fileRow{
Account: rec.AccountSlug, Name: filepath.Base(rec.Path), Status: "missing",
ImportedAt: rec.ImportedAt, Added: rec.Added,
})
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Account != out[j].Account {
return out[i].Account < out[j].Account
}
return out[i].Name < out[j].Name
})
return map[string]any{"files": out}, nil
}
// serveFile opens a statement in the browser. Only a file import would read
// is served — it is looked up in the account's statement list, never joined
// onto a path — so nothing else under the data root can be fetched.
//
// A statement is whatever the bank sent, and it is served from this origin,
// where a script could drive the API; so nothing is ever rendered as a page.
// Text opens inline as text/plain under a sandboxing CSP, and anything that is
// neither text nor PDF downloads. A PDF opens inline without the sandbox: the
// browsers' viewers refuse to run under one, and they render it in their own
// isolated viewer rather than in this origin's DOM.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
fail := func(code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
accounts, err := config.LoadAccounts(s.root)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
account, name := r.PathValue("account"), r.PathValue("name")
var path string
for _, acc := range accounts {
if acc.Slug != account {
continue
}
paths, err := importer.StatementFiles(acc)
if err != nil {
fail(http.StatusInternalServerError, err.Error())
return
}
for _, p := range paths {
if filepath.Base(p) == name {
path = p
}
}
}
if path == "" {
fail(http.StatusNotFound, fmt.Sprintf("no statement %s/%s", account, name))
return
}
disposition := "attachment"
switch strings.ToLower(filepath.Ext(name)) {
case ".pdf":
w.Header().Set("Content-Type", "application/pdf")
disposition = "inline"
case ".csv", ".txt", ".tsv":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Security-Policy", "sandbox")
disposition = "inline"
default:
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Security-Policy", "sandbox")
}
w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": name}))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, path)
}
// 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) {