jordi fita mas b5d40cc262 Add the upload form to the media picker
It makes easier to upload new images from the place where we need it,
instead of having to go to the media section each time.

It was a little messy, this one.

First of all, I realized that POSTint to /admin/media/picker to get the
new media field was wrong: i was not asking the server to “accept an
entity”, but only requesting a new HTML value, just like a GET to
/admin/media/upload requests the form to upload a new media, thus here
i should do the same, except i needed the query parameters to change the
field, which is fine—it is actually a different resource, thus a
different URL.

Then, i thought that i could not POST the upload to /admin/media,
because i returned a different HTML —the media field—, so i reused the
recently unused POST to /admin/media/picker to upload that file and
return the HTML for the field.  It was wrong, because i was not
requesting the server to put the file as a subordinate of
/admin/media/picker, only /admin/media, but i did not come up with any
other solution.

Since i had two different upload functions now, i created uploadForm’s
Handle method to refactorize the duplicated logic to a single place.
Unfortunately, i did not work as i expected because uploadForm’s and
mediaPicker’s MustRender methods are different, and mediaPicker has to
embed uploadForm to render the form in the picker.  That made me change
Handle’s output to a boolean and error in order for the HTTP handler
function know when to render the form with the error messages with the
proper MustRender handler.

However, I saw the opportunity of reusing that Handler method for
editMedia, that was doing mostly the same job, but had to call a
different Validate than uploadForm’s, because editMedia does not require
the uploaded file.  That’s when i realized that i could use an interface
and that this interface could be reused not only within media but
throughout the application, and added HandleMultipart in form.

Had to create a different interface for multipart forms because they
need different parameters in Parse that non-multipart form, when i add
that interface, hence had to also change Parse to ParseForm to account
for the difference in signature; not a big deal.

After all that, i realized that i **could** POST to /admin/media in both
cases, because i always return “an HTML entity”, it just happens that
for the media section it is empty with a redirect, and for the picker is
the field.  That made the whole Handle method a bit redundant, but i
left it nevertheless, as i find it slightly easier to read the
uploadMedia function now.
2023-09-22 01:40:22 +02:00

343 lines
8.7 KiB
Go

/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package media
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
mediaDir string
}
func NewAdminHandler(mediaDir string) *AdminHandler {
// mediaDir is already created in public handler
return &AdminHandler{
mediaDir: mediaDir,
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
serveMediaIndex(w, r, user, company, conn)
case http.MethodPost:
uploadMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "picker":
switch r.Method {
case http.MethodGet:
serveMediaPicker(w, r, user, company, conn, newMediaPicker(r.URL.Query()))
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "field":
switch r.Method {
case http.MethodGet:
pickMedia(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "upload":
switch r.Method {
case http.MethodGet:
f := newUploadForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
return
}
f := newMediaForm()
if err = f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editMedia(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "content":
switch r.Method {
case http.MethodGet:
httplib.Redirect(w, r, f.Path, http.StatusFound)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
})
}
func serveMediaIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &mediaIndex{
Media: media,
}
page.MustRender(w, r, user, company)
}
func collectMediaEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*mediaEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
from media
where company_id = $1
order by media_id
`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*mediaEntry
for rows.Next() {
entry := &mediaEntry{}
if err = rows.Scan(&entry.ID, &entry.Path); err != nil {
return nil, err
}
media = append(media, entry)
}
return media, nil
}
type mediaEntry struct {
ID uint
Path string
}
type mediaIndex struct {
Media []*mediaEntry
}
func (page *mediaIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/index.gohtml", page)
}
func serveMediaPicker(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, picker *mediaPicker) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
picker.Media = media
picker.MustRender(w, r, user, company)
}
func pickMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
picker := newMediaPicker(r.URL.Query())
picker.MustRenderField(w, r, user, company)
}
type mediaPicker struct {
*uploadForm
Media []*mediaEntry
Field *form.Media
}
func newMediaPicker(query url.Values) *mediaPicker {
return newMediaPickerWithUploadForm(newUploadForm(), query)
}
func newMediaPickerWithUploadForm(f *uploadForm, query url.Values) *mediaPicker {
return &mediaPicker{
uploadForm: f,
Field: &form.Media{
Input: &form.Input{
Name: strings.TrimSpace(query.Get("name")),
Val: strings.TrimSpace(query.Get("value")),
},
Label: strings.TrimSpace(query.Get("label")),
Prompt: strings.TrimSpace(query.Get("prompt")),
},
}
}
func (picker *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/picker.gohtml", picker)
}
func (picker *mediaPicker) MustRenderField(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/field.gohtml", picker.Field)
}
func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newUploadForm()
ok, err := form.HandleMultipart(f, w, r, user)
if err != nil {
return
}
isPicker := r.Form.Has("picker")
if !ok {
if isPicker {
serveMediaPicker(w, r, user, company, conn, newMediaPickerWithUploadForm(f, r.Form))
} else {
f.MustRender(w, r, user, company)
}
return
}
defer f.Close()
bytes := f.MustReadAllFile()
mediaId := conn.MustGetText(r.Context(), "select add_media($1, $2, $3, $4)::text", company.ID, f.File.Filename(), f.File.ContentType, bytes)
if isPicker {
picker := newMediaPickerWithUploadForm(f, r.Form)
picker.Field.Val = mediaId
picker.MustRenderField(w, r, user, company)
} else {
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
}
type uploadForm struct {
File *form.File
}
func newUploadForm() *uploadForm {
return &uploadForm{
File: &form.File{
Name: "media",
MaxSize: 10 * 1 << 20,
},
}
}
func (f *uploadForm) ParseMultipart(w http.ResponseWriter, r *http.Request) error {
maxSize := f.File.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
if err := f.File.FillValue(r); err != nil {
return err
}
return nil
}
func (f *uploadForm) Close() error {
return f.File.Close()
}
func (f *uploadForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.Check(f.File, f.HasFile(), l.GettextNoop("Uploaded file can not be empty."))
return v.AllOK
}
func (f *uploadForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/upload.gohtml", f)
}
func (f *uploadForm) HasFile() bool {
return f.File.HasData()
}
func (f *uploadForm) MustReadAllFile() []byte {
if !f.HasFile() {
return nil
}
bytes, err := io.ReadAll(f.File)
if err != nil {
panic(err)
}
return bytes
}
type mediaForm struct {
*uploadForm
ID int
Path string
OriginalFilename *form.Input
}
func newMediaForm() *mediaForm {
return &mediaForm{
uploadForm: newUploadForm(),
OriginalFilename: &form.Input{
Name: "original_filename",
},
}
}
func (f *mediaForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, "select original_filename, media.path from media where media_id = $1", id)
return row.Scan(&f.OriginalFilename.Val, &f.Path)
}
func (f *mediaForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/form.gohtml", f)
}
func (f *mediaForm) ParseMultipart(w http.ResponseWriter, r *http.Request) error {
if err := f.uploadForm.ParseMultipart(w, r); err != nil {
return err
}
f.OriginalFilename.FillValue(r)
return nil
}
func (f *mediaForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(f.OriginalFilename, l.GettextNoop("Filename can not be empty."))
return v.AllOK
}
func editMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *mediaForm) {
if ok, err := form.HandleMultipart(f, w, r, user); err != nil {
return
} else if !ok {
f.MustRender(w, r, user, company)
return
}
if f.HasFile() {
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select edit_media($1, $2, $3, $4)", f.ID, f.OriginalFilename, f.File.ContentType, bytes)
} else {
conn.MustExec(r.Context(), "select edit_media($1, $2)", f.ID, f.OriginalFilename)
}
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}