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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user