diff --git a/pkg/expenses.go b/pkg/expenses.go index e09a037..da8f5a0 100644 --- a/pkg/expenses.go +++ b/pkg/expenses.go @@ -13,12 +13,13 @@ import ( ) type ExpenseEntry struct { - Slug string - InvoiceDate time.Time - InvoiceNumber string - Amount string - InvoicerName string - Tags []string + Slug string + InvoiceDate time.Time + InvoiceNumber string + Amount string + InvoicerName string + OriginalFileName string + Tags []string } type expensesIndexPage struct { @@ -78,8 +79,10 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company , invoice_number , to_price(amount, decimal_digits) , contact.business_name + , coalesce(attachment.original_filename, '') , expense.tags from expense + left join expense_attachment as attachment using (expense_id) join contact using (contact_id) join currency using (currency_code) where (%s) @@ -90,7 +93,7 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company var entries []*ExpenseEntry for rows.Next() { entry := &ExpenseEntry{} - if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.Tags); err != nil { + if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil { panic(err) } entries = append(entries, entry) @@ -148,6 +151,7 @@ type expenseForm struct { InvoiceDate *InputField Tax *SelectField Amount *InputField + File *FileField Tags *TagsField } @@ -188,6 +192,11 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), }, }, + File: &FileField{ + Name: "file", + Label: pgettext("input", "File", locale), + MaxSize: 1 << 20, + }, Tags: &TagsField{ Name: "tags", Label: pgettext("input", "Tags", locale), @@ -196,7 +205,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co } func (form *expenseForm) Parse(r *http.Request) error { - if err := r.ParseForm(); err != nil { + if err := r.ParseMultipartForm(form.File.MaxSize); err != nil { return err } form.Invoicer.FillValue(r) @@ -204,6 +213,9 @@ func (form *expenseForm) Parse(r *http.Request) error { form.InvoiceDate.FillValue(r) form.Tax.FillValue(r) form.Amount.FillValue(r) + if err := form.File.FillValue(r); err != nil { + return err + } form.Tags.FillValue(r) return nil } @@ -269,7 +281,10 @@ func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Param return } taxes := mustSliceAtoi(form.Tax.Selected) - conn.MustExec(r.Context(), "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags) + slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags) + if len(form.File.Content) > 0 { + conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug) + } htmxRedirect(w, r, companyURI(company, "/expenses")) } @@ -299,6 +314,9 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout http.NotFound(w, r) return } + if len(form.File.Content) > 0 { + conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4 on conflict (expense_id) do update set original_filename = excluded.original_filename, mime_type = excluded.mime_type, content = excluded.content", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug) + } htmxRedirect(w, r, companyURI(company, "/expenses")) } @@ -405,3 +423,24 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http } mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form) } + +func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + slug := params[0].Value + conn := getConn(r) + var contentType string + var content []byte + if notFoundErrorOrPanic(conn.QueryRow(r.Context(), ` + select mime_type + , content + from expense + join expense_attachment using (expense_id) + where slug = $1 +`, slug).Scan(&contentType, &content)) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10)) + w.WriteHeader(http.StatusOK) + w.Write(content) +} diff --git a/pkg/form.go b/pkg/form.go index c205399..191bfb6 100644 --- a/pkg/form.go +++ b/pkg/form.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/jackc/pgtype" "html/template" + "io/ioutil" "net/http" "net/mail" "net/url" @@ -216,6 +217,37 @@ func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*S return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language) } +type FileField struct { + Name string + Label string + MaxSize int64 + OriginalFileName string + ContentType string + Content []byte + Errors []error +} + +func (field *FileField) FillValue(r *http.Request) error { + file, header, err := r.FormFile(field.Name) + if err != nil { + if err == http.ErrMissingFile { + return nil + } + return err + } + defer file.Close() + field.Content, err = ioutil.ReadAll(file) + if err != nil { + return err + } + field.OriginalFileName = header.Filename + field.ContentType = header.Header.Get("Content-Type") + if len(field.Content) == 0 { + field.ContentType = http.DetectContentType(field.Content) + } + return nil +} + type TagsField struct { Name string Label string diff --git a/pkg/router.go b/pkg/router.go index cae758d..08892d6 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -1,9 +1,9 @@ package pkg import ( - "net/http" - "github.com/julienschmidt/httprouter" + "mime" + "net/http" ) func NewRouter(db *Db) http.Handler { @@ -44,6 +44,7 @@ func NewRouter(db *Db) http.Handler { companyRouter.PUT("/expenses/:slug", HandleUpdateExpense) companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags) companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags) + companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment) companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { mustRenderMainTemplate(w, r, "dashboard.gohtml", nil) }) @@ -85,10 +86,23 @@ func NewRouter(db *Db) http.Handler { func MethodOverrider(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { - if err := r.ParseForm(); err != nil { + contentType := r.Header.Get("Content-Type") + contentType, _, err := mime.ParseMediaType(contentType) + if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if contentType == "multipart/form-data" { + if err := r.ParseMultipartForm(20 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } else { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } override := r.FormValue(overrideMethodName) if override == http.MethodDelete || override == http.MethodPut { r2 := new(http.Request) diff --git a/pkg/template.go b/pkg/template.go index 1ed9afc..9c3969c 100644 --- a/pkg/template.go +++ b/pkg/template.go @@ -80,6 +80,10 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s "putMethod": func() template.HTML { return overrideMethodField(http.MethodPut) }, + "humanizeBytes": func(bytes int64) string { + sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} + return humanizeBytes(bytes, 1024, sizes) + }, }) if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil { panic(err) @@ -129,3 +133,22 @@ func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string, func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) { mustRenderTemplate(w, r, "web.gohtml", filename, data) } + +func humanizeBytes(s int64, base float64, sizes []string) string { + if s < 10 { + return fmt.Sprintf("%d B", s) + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} diff --git a/web/template/expenses/edit.gohtml b/web/template/expenses/edit.gohtml index 180f7f6..777b7a1 100644 --- a/web/template/expenses/edit.gohtml +++ b/web/template/expenses/edit.gohtml @@ -18,7 +18,7 @@ {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}}

{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}

-
+ {{ csrfToken }} {{ putMethod }} @@ -28,6 +28,7 @@ {{ template "input-field" .InvoiceDate }} {{ template "input-field" .Amount }} {{ template "select-field" .Tax }} + {{ template "file-field" .File }} {{ template "tags-field" .Tags }} {{- end }} diff --git a/web/template/expenses/index.gohtml b/web/template/expenses/index.gohtml index d5a6b06..9c4bc41 100644 --- a/web/template/expenses/index.gohtml +++ b/web/template/expenses/index.gohtml @@ -43,6 +43,7 @@ {{( pgettext "Invoice Number" "title" )}} {{( pgettext "Tags" "title" )}} {{( pgettext "Amount" "title" )}} + {{( pgettext "Download" "title" )}} {{( pgettext "Actions" "title" )}} @@ -65,6 +66,14 @@ {{- end }} {{ .Amount | formatPrice }} + + {{ if .OriginalFileName }} + + {{ end }} +