/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package media import ( "context" "io" "net/http" "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) case http.MethodPost: pickMedia(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } 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) { media, err := collectMediaEntries(r.Context(), company, conn) if err != nil { panic(err) } query := r.URL.Query() page := &mediaPicker{ Media: media, Field: &form.Media{ Input: &form.Input{ Name: query.Get("name"), Val: query.Get("value"), }, Label: query.Get("label"), Prompt: query.Get("prompt"), }, } page.MustRender(w, r, user, company) } type mediaPicker struct { Media []*mediaEntry Field *form.Media } func (page *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderNoLayout(w, r, user, company, "media/picker.gohtml", page) } func pickMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } input := &form.Media{ Input: &form.Input{ Name: strings.TrimSpace(r.FormValue("name")), Val: strings.TrimSpace(r.FormValue("value")), }, Label: strings.TrimSpace(r.FormValue("label")), Prompt: strings.TrimSpace(r.FormValue("prompt")), } template.MustRenderNoLayout(w, r, user, company, "media/field.gohtml", input) } func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newUploadForm() if err := f.Parse(w, r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer f.Close() if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if !f.Valid(user.Locale) { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } f.MustRender(w, r, user, company) return } bytes := f.MustReadAllFile() conn.MustExec(r.Context(), "select add_media($1, $2, $3, $4)", company.ID, f.File.Filename(), f.File.ContentType, bytes) 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) Parse(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) Parse(w http.ResponseWriter, r *http.Request) error { if err := f.uploadForm.Parse(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 err := f.Parse(w, r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer f.Close() if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if !f.Valid(user.Locale) { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } 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) }