package web import ( "crypto/rand" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "sort" "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 // 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 // TagChips is the alphabetical tag picker: every known tag plus any unsaved // labels carried across an error re-render, each marked selected or not. TagChips []tagChip } // tagChip is one selectable tag in the upload form's picker. type tagChip struct { Label string Selected bool } 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) { v.ClassifyModel = s.cfg.ClassifyModel v.ClassifyEnabled = s.classifier != nil if v.TagChips == nil { // callers that carry selected tags set this themselves if chips, err := s.tagChipsFor(nil); err == nil { v.TagChips = chips } } 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) } } // tagChipsFor builds the upload form's tag picker: every known tag in alphabetical // order, plus any selected labels that aren't saved tags yet (a chip the user // created before an error re-render), with the selected ones marked. func (s *Server) tagChipsFor(selected []string) ([]tagChip, error) { tags, err := s.store.ListTags() if err != nil { return nil, err } selSet := map[string]bool{} for _, l := range selected { if l = strings.TrimSpace(l); l != "" { selSet[strings.ToLower(l)] = true } } known := map[string]bool{} var chips []tagChip for _, t := range tags { key := strings.ToLower(t.Label) known[key] = true chips = append(chips, tagChip{Label: t.Label, Selected: selSet[key]}) } for _, l := range selected { // pending labels not yet persisted l = strings.TrimSpace(l) key := strings.ToLower(l) if l == "" || known[key] { continue } known[key] = true chips = append(chips, tagChip{Label: l, Selected: true}) } sort.Slice(chips, func(i, j int) bool { return strings.ToLower(chips[i].Label) < strings.ToLower(chips[j].Label) }) return chips, nil } 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.") } else { data = normalizeOrientation(data, mimeType) // bake in EXIF rotation } } // 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...) // Selected tags (existing labels and/or newly-typed ones) ride along as repeated // "tags" fields. Resolved/created at save time so an abandoned upload adds none. var tagLabels []string if r.MultipartForm != nil { tagLabels = r.MultipartForm.Value["tags"] } if len(errs) > 0 { view.Errors = errs if chips, err := s.tagChipsFor(tagLabels); err == nil { view.TagChips = chips // keep the user's tag selection across the re-render } s.renderForm(w, view) return } // Dual-write: file on disk (dated, amount-tagged name) + blob in DB. id := newID() ext := extFor(header.Filename, mimeType) relPath, err := s.saveReceiptFile(receiptDate, amountCents, ext, data) if err != nil { 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 } // Link tags now that the receipt id exists (create-if-missing by label). if len(tagLabels) > 0 { if err := s.store.SetReceiptTags(id, tagLabels); err != nil { s.serverError(w, "save tags", err) return } } // Persist the AI suggestion the browser round-tripped, so misreads can be // reviewed later (best-effort, diagnostic — never block the upload on it). if blob := strings.TrimSpace(r.FormValue("classify_json")); blob != "" { var cr classifyResponse _ = json.Unmarshal([]byte(blob), &cr) // model only; blob stored verbatim if err := s.store.InsertClassification(id, cr.Model, blob, time.Now().UTC()); err != nil { log.Printf("warning: store classification for %s: %v", id, err) } } // 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) } savedTags, _ := s.store.ListReceiptTags(id) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = 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, Tags: savedTags, }) } // 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 Tags []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 } adata = normalizeOrientation(adata, amime) // bake in EXIF rotation out = append(out, pendingAttachment{ data: adata, mimeType: amime, ext: extFor(fh.Filename, amime), filename: fh.Filename, }) } return out, errs } // 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) } // 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 // /receipts//_
_.. func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) { return s.saveBlobFile("receipts", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents), ext, data) } // saveAttachmentFile writes an attachment's bytes under // /attachments//_
_._att, 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 ///, 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 { return "", err } for i := 0; i < 10000; i++ { name := stem if i > 0 { name += "_" + strconv.Itoa(i) } name += ext rel := filepath.Join(subdir, year, name) 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 } return "", fmt.Errorf("could not find a free filename for %s", stem) } 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]) }