/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package media import ( "context" "encoding/json" "fmt" "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.MustRenderAdminNoLayout(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.MustRenderAdminNoLayout(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() ckeditor := r.Header.Get("Accept") == CKEditorMIME if ckeditor { w.Header().Set("Content-Type", CKEditorMIME) } if err := f.ParseMultipart(w, r); err != nil { uploadError(w, err.Error(), http.StatusBadRequest, ckeditor) return } defer f.Close() if err := user.VerifyCSRFToken(r); err != nil { uploadError(w, err.Error(), http.StatusForbidden, ckeditor) return } isPicker := r.Form.Has("picker") if !f.Valid(user.Locale) { if ckeditor { uploadError(w, f.File.Error.Error(), http.StatusUnprocessableEntity, ckeditor) } else { if !httplib.IsHTMxRequest(r) && !ckeditor { w.WriteHeader(http.StatusUnprocessableEntity) } if isPicker { serveMediaPicker(w, r, user, company, conn, newMediaPickerWithUploadForm(f, r.Form)) } else { f.MustRender(w, r, user, company) } } return } 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 if ckeditor { if err := json.NewEncoder(w).Encode(CKEditorSuccess(conn.MustGetText(r.Context(), "select media.path from media where media_id = $1", mediaId))); err != nil { panic(err) } } else { httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther) } } const CKEditorMIME = "application/vnd.ckeditor+json" type CKEditorError string func (err CKEditorError) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf(`{"error": {"message": %q}}`, err)), nil } type CKEditorSuccess string func (success CKEditorSuccess) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf(`{"url": %q}`, success)), nil } func uploadError(w http.ResponseWriter, error string, code int, ckeditor bool) { if ckeditor { w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(CKEditorError(error)); err != nil { panic(err) } } else { http.Error(w, error, code) } } 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) }