hsa-app/internal/web/upload.go

303 lines
7.5 KiB
Go
Raw Normal View History

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
//
// <StorageDir>/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>
//
// 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])
}