2026-06-18 01:40:12 +00:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"maisym.com/hsa/internal/receipt"
|
|
|
|
|
"maisym.com/hsa/internal/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const dateLayout = "2006-01-02"
|
|
|
|
|
|
|
|
|
|
type formView struct {
|
|
|
|
|
Subject string
|
|
|
|
|
Categories []storage.Lookup
|
|
|
|
|
People []storage.Lookup
|
|
|
|
|
Amount string
|
|
|
|
|
Date string
|
|
|
|
|
CategoryID string
|
|
|
|
|
PersonID string
|
|
|
|
|
Errors []string
|
2026-06-19 02:04:42 +00:00
|
|
|
|
|
|
|
|
// ClassifyModel is the model that will read the receipt to pre-fill the form,
|
|
|
|
|
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
|
|
|
|
ClassifyModel string
|
|
|
|
|
ClassifyEnabled bool
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFrom(r.Context())
|
|
|
|
|
cats, people, err := s.lookups()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.serverError(w, "load lookups", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.renderForm(w, formView{
|
|
|
|
|
Subject: sess.Subject,
|
|
|
|
|
Categories: cats,
|
|
|
|
|
People: people,
|
|
|
|
|
Date: time.Now().Format(dateLayout),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
|
|
|
|
|
cats, err = s.store.ListCategories()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
people, err = s.store.ListPeople()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
return cats, people, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
2026-06-19 02:04:42 +00:00
|
|
|
v.ClassifyModel = s.cfg.ClassifyModel
|
|
|
|
|
v.ClassifyEnabled = s.classifier != nil
|
2026-06-18 01:40:12 +00:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
|
|
|
|
s.serverError(w, "render form", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFrom(r.Context())
|
|
|
|
|
cats, people, err := s.lookups()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.serverError(w, "load lookups", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
|
|
|
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
|
|
|
s.renderForm(w, formView{
|
|
|
|
|
Subject: sess.Subject, Categories: cats, People: people,
|
|
|
|
|
Errors: []string{"Upload too large or malformed."},
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
amountStr := strings.TrimSpace(r.FormValue("amount"))
|
|
|
|
|
dateStr := strings.TrimSpace(r.FormValue("receipt_date"))
|
|
|
|
|
categoryStr := strings.TrimSpace(r.FormValue("category_id"))
|
|
|
|
|
personStr := strings.TrimSpace(r.FormValue("person_id"))
|
|
|
|
|
|
|
|
|
|
view := formView{
|
|
|
|
|
Subject: sess.Subject, Categories: cats, People: people,
|
|
|
|
|
Amount: amountStr, Date: dateStr, CategoryID: categoryStr, PersonID: personStr,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var errs []string
|
|
|
|
|
amountCents, err := receipt.ParseAmountCents(amountStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Enter a valid amount, e.g. 12.34")
|
|
|
|
|
}
|
|
|
|
|
receiptDate, err := time.Parse(dateLayout, dateStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Enter a valid date.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
categoryID, ok := parseLookupID(categoryStr, cats)
|
|
|
|
|
if !ok {
|
|
|
|
|
errs = append(errs, "Choose a category.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var personID *int64
|
|
|
|
|
if personStr != "" {
|
|
|
|
|
pid, ok := parseLookupID(personStr, people)
|
|
|
|
|
if !ok {
|
|
|
|
|
errs = append(errs, "Choose a valid \"Who\" or leave it blank.")
|
|
|
|
|
} else {
|
|
|
|
|
personID = &pid
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, header, err := r.FormFile("receipt")
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Attach a receipt photo or PDF.")
|
|
|
|
|
}
|
|
|
|
|
var data []byte
|
|
|
|
|
if file != nil {
|
|
|
|
|
defer file.Close()
|
|
|
|
|
data, err = io.ReadAll(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Could not read the uploaded file.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mimeType := ""
|
|
|
|
|
if len(data) > 0 {
|
|
|
|
|
mimeType = detectMime(data)
|
|
|
|
|
if !allowedMime(mimeType) {
|
|
|
|
|
errs = append(errs, "Only images and PDFs are allowed.")
|
2026-06-20 00:26:03 +00:00
|
|
|
} else {
|
|
|
|
|
data = normalizeOrientation(data, mimeType) // bake in EXIF rotation
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
// Optional extra files attached in the same submission (item 10). Read and
|
|
|
|
|
// validate them up front so a bad attachment re-renders the form before any
|
|
|
|
|
// row is written. They are saved after the receipt row exists (see below).
|
|
|
|
|
attachments, attErrs := readAttachments(r)
|
|
|
|
|
errs = append(errs, attErrs...)
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
if len(errs) > 0 {
|
|
|
|
|
view.Errors = errs
|
|
|
|
|
s.renderForm(w, view)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 02:10:41 +00:00
|
|
|
// Dual-write: file on disk (dated, amount-tagged name) + blob in DB.
|
2026-06-18 01:40:12 +00:00
|
|
|
id := newID()
|
|
|
|
|
ext := extFor(header.Filename, mimeType)
|
2026-06-19 02:10:41 +00:00
|
|
|
relPath, err := s.saveReceiptFile(receiptDate, amountCents, ext, data)
|
|
|
|
|
if err != nil {
|
2026-06-18 01:40:12 +00:00
|
|
|
s.serverError(w, "save file", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := receipt.Receipt{
|
|
|
|
|
ID: id,
|
|
|
|
|
UploadedBy: sess.Subject,
|
|
|
|
|
UploadedAt: time.Now().UTC(),
|
|
|
|
|
ReceiptDate: receiptDate,
|
|
|
|
|
AmountCents: amountCents,
|
|
|
|
|
CategoryID: categoryID,
|
|
|
|
|
PersonID: personID,
|
|
|
|
|
FilePath: relPath,
|
|
|
|
|
ImageData: data,
|
|
|
|
|
FileSizeBytes: int64(len(data)),
|
|
|
|
|
OriginalFilename: header.Filename,
|
|
|
|
|
MimeType: mimeType,
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.Insert(rec); err != nil {
|
|
|
|
|
s.serverError(w, "insert receipt", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
// Save each attachment now that the parent receipt id exists, keyed to the
|
|
|
|
|
// receipt's date+amount so the files sort alongside it.
|
|
|
|
|
var attachmentNames []string
|
|
|
|
|
for _, a := range attachments {
|
|
|
|
|
relPath, err := s.saveAttachmentFile(receiptDate, amountCents, a.ext, a.data)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.serverError(w, "save attachment", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.InsertAttachment(receipt.Attachment{
|
|
|
|
|
ID: newID(),
|
|
|
|
|
ReceiptID: id,
|
|
|
|
|
UploadedBy: sess.Subject,
|
|
|
|
|
UploadedAt: time.Now().UTC(),
|
|
|
|
|
FilePath: relPath,
|
|
|
|
|
ImageData: a.data,
|
|
|
|
|
FileSizeBytes: int64(len(a.data)),
|
|
|
|
|
OriginalFilename: a.filename,
|
|
|
|
|
MimeType: a.mimeType,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
s.serverError(w, "insert attachment", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
attachmentNames = append(attachmentNames, a.filename)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
2026-06-19 11:06:15 +00:00
|
|
|
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
|
|
|
|
Category: labelFor(categoryID, cats),
|
|
|
|
|
Who: whoLabel(personID, people),
|
|
|
|
|
Amount: fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
|
|
|
|
|
Date: dateStr,
|
|
|
|
|
Filename: header.Filename,
|
|
|
|
|
Attachments: attachmentNames,
|
2026-06-18 01:40:12 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
// confirmView is the data for the post-save confirmation page.
|
|
|
|
|
type confirmView struct {
|
|
|
|
|
Category string
|
|
|
|
|
Who string
|
|
|
|
|
Amount string
|
|
|
|
|
Date string
|
|
|
|
|
Filename string
|
|
|
|
|
Attachments []string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
|
|
|
|
|
type pendingAttachment struct {
|
|
|
|
|
data []byte
|
|
|
|
|
mimeType string
|
|
|
|
|
ext string
|
|
|
|
|
filename string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readAttachments reads and validates the optional "attachments" multi-file field.
|
|
|
|
|
// Empty parts are skipped; invalid types accumulate into errs (and the upload is
|
|
|
|
|
// re-rendered, so nothing is written).
|
|
|
|
|
func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) {
|
|
|
|
|
if r.MultipartForm == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
for _, fh := range r.MultipartForm.File["attachments"] {
|
|
|
|
|
af, err := fh.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Could not read an attachment.")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
adata, err := io.ReadAll(af)
|
|
|
|
|
af.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, "Could not read an attachment.")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(adata) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
amime := detectMime(adata)
|
|
|
|
|
if !allowedMime(amime) {
|
|
|
|
|
errs = append(errs, "Attachments must be images or PDFs.")
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-20 00:26:03 +00:00
|
|
|
adata = normalizeOrientation(adata, amime) // bake in EXIF rotation
|
2026-06-19 11:06:15 +00:00
|
|
|
out = append(out, pendingAttachment{
|
|
|
|
|
data: adata,
|
|
|
|
|
mimeType: amime,
|
|
|
|
|
ext: extFor(fh.Filename, amime),
|
|
|
|
|
filename: fh.Filename,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return out, errs
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
// parseLookupID parses s as an int64 and confirms it exists in the given set.
|
|
|
|
|
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
|
|
|
|
|
id, err := strconv.ParseInt(s, 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
for _, l := range set {
|
|
|
|
|
if l.ID == id {
|
|
|
|
|
return id, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func labelFor(id int64, set []storage.Lookup) string {
|
|
|
|
|
for _, l := range set {
|
|
|
|
|
if l.ID == id {
|
|
|
|
|
return l.Label
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func whoLabel(id *int64, set []storage.Lookup) string {
|
|
|
|
|
if id == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return labelFor(*id, set)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
// receiptStem returns the dated, amount-tagged base name (no extension) shared by
|
|
|
|
|
// a receipt and its attachments, e.g. "06_08_42.50".
|
|
|
|
|
func receiptStem(date time.Time, amountCents int64) string {
|
|
|
|
|
return fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// saveReceiptFile writes the receipt bytes under
|
|
|
|
|
// <StorageDir>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>.
|
2026-06-19 02:10:41 +00:00
|
|
|
func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
|
2026-06-19 11:06:15 +00:00
|
|
|
return s.saveBlobFile("receipts", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents), ext, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// saveAttachmentFile writes an attachment's bytes under
|
|
|
|
|
// <StorageDir>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>, keyed to
|
|
|
|
|
// the PARENT receipt's date and amount so an expense's files sort together.
|
|
|
|
|
func (s *Server) saveAttachmentFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
|
|
|
|
|
return s.saveBlobFile("attachments", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents)+"_att", ext, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// saveBlobFile writes data to <StorageDir>/<subdir>/<year>/<stem><ext>, adding a
|
|
|
|
|
// _1, _2, … suffix on the stem when the name is taken (legitimately possible —
|
|
|
|
|
// duplicates can be approved, and a receipt can have several attachments).
|
|
|
|
|
// Exclusive create avoids two concurrent uploads racing onto the same name.
|
|
|
|
|
// Returns the storage-relative path actually written (forward-slash separated).
|
|
|
|
|
func (s *Server) saveBlobFile(subdir, year, stem, ext string, data []byte) (string, error) {
|
|
|
|
|
if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, subdir, year), 0o700); err != nil {
|
2026-06-19 02:10:41 +00:00
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
for i := 0; i < 10000; i++ {
|
|
|
|
|
name := stem
|
|
|
|
|
if i > 0 {
|
|
|
|
|
name += "_" + strconv.Itoa(i)
|
|
|
|
|
}
|
|
|
|
|
name += ext
|
2026-06-19 11:06:15 +00:00
|
|
|
rel := filepath.Join(subdir, year, name)
|
2026-06-19 02:10:41 +00:00
|
|
|
f, err := os.OpenFile(filepath.Join(s.cfg.StorageDir, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
|
|
|
if os.IsExist(err) {
|
|
|
|
|
continue // name taken — try the next suffix
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
_, werr := f.Write(data)
|
|
|
|
|
cerr := f.Close()
|
|
|
|
|
if werr != nil {
|
|
|
|
|
return "", werr
|
|
|
|
|
}
|
|
|
|
|
if cerr != nil {
|
|
|
|
|
return "", cerr
|
|
|
|
|
}
|
|
|
|
|
return filepath.ToSlash(rel), nil
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
2026-06-19 02:10:41 +00:00
|
|
|
return "", fmt.Errorf("could not find a free filename for %s", stem)
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func detectMime(data []byte) string {
|
|
|
|
|
n := min(len(data), 512)
|
|
|
|
|
ct := http.DetectContentType(data[:n])
|
|
|
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
|
|
|
ct = ct[:i]
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(ct)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func allowedMime(mime string) bool {
|
|
|
|
|
return strings.HasPrefix(mime, "image/") || mime == "application/pdf"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func extFor(filename, mimeType string) string {
|
|
|
|
|
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" && len(ext) <= 5 {
|
|
|
|
|
return ext
|
|
|
|
|
}
|
|
|
|
|
switch mimeType {
|
|
|
|
|
case "image/jpeg":
|
|
|
|
|
return ".jpg"
|
|
|
|
|
case "image/png":
|
|
|
|
|
return ".png"
|
|
|
|
|
case "image/gif":
|
|
|
|
|
return ".gif"
|
|
|
|
|
case "image/webp":
|
|
|
|
|
return ".webp"
|
|
|
|
|
case "application/pdf":
|
|
|
|
|
return ".pdf"
|
|
|
|
|
default:
|
|
|
|
|
return ".bin"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newID returns a random UUIDv4 string.
|
|
|
|
|
func newID() string {
|
|
|
|
|
var b [16]byte
|
|
|
|
|
_, _ = rand.Read(b[:])
|
|
|
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
|
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
|
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
|
|
|
}
|