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 // 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 } 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 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.") } } if len(errs) > 0 { view.Errors = errs 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 } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = confirmPage.ExecuteTemplate(w, "base", map[string]string{ "Category": labelFor(categoryID, cats), "Who": whoLabel(personID, people), "Amount": fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100), "Date": dateStr, "Filename": header.Filename, }) } // 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) } // saveReceiptFile writes the bytes under // // //_
_. // // using the receipt date and amount. When a file with the same date+amount+ext // already exists (legitimately possible — duplicates can be approved), a _1, _2, … // suffix is added to the stem. Exclusive create avoids two concurrent uploads // racing onto the same name. Returns the storage-relative path actually written // (forward-slash separated, as stored in receipts.file_path). func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) { yearDir := fmt.Sprintf("%04d", date.Year()) if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, yearDir), 0o700); err != nil { return "", err } stem := fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100) for i := 0; i < 10000; i++ { name := stem if i > 0 { name += "_" + strconv.Itoa(i) } name += ext rel := filepath.Join(yearDir, 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]) }