media.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "mime/multipart" |
| 8 | "net/http" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | |
| 13 | "forge.gpl-nas.de/physiobase/internal/store" |
| 14 | ) |
| 15 | |
| 16 | const maxUpload = 1 << 30 // 1 GB pro Upload (Videos) |
| 17 | |
| 18 | var ( |
| 19 | imageExt = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} |
| 20 | videoExt = map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true} |
| 21 | ) |
| 22 | |
| 23 | func extOf(f string) string { return strings.ToLower(filepath.Ext(f)) } |
| 24 | func isImage(f string) bool { return imageExt[extOf(f)] } |
| 25 | func isVideo(f string) bool { return videoExt[extOf(f)] } |
| 26 | |
| 27 | func allowedUpload(f string) bool { |
| 28 | e := extOf(f) |
| 29 | return imageExt[e] || videoExt[e] || e == ".pdf" |
| 30 | } |
| 31 | |
| 32 | func (s *Server) uploadMedia(w http.ResponseWriter, r *http.Request) { |
| 33 | ctx := r.Context() |
| 34 | id, ok := pathID(r, "id") |
| 35 | if !ok { |
| 36 | http.NotFound(w, r) |
| 37 | return |
| 38 | } |
| 39 | if _, err := s.st.Entry(ctx, id); err != nil { |
| 40 | s.fail(w, r, err) |
| 41 | return |
| 42 | } |
| 43 | r.Body = http.MaxBytesReader(w, r.Body, maxUpload) |
| 44 | // Nur 32 MB im Speicher, der Rest landet in Temp-Dateien |
| 45 | if err := r.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt |
| 46 | http.Error(w, "Upload fehlgeschlagen (max. 1 GB)", http.StatusBadRequest) |
| 47 | return |
| 48 | } |
| 49 | defer func() { _ = r.MultipartForm.RemoveAll() }() |
| 50 | caption := formText(r, "caption") |
| 51 | for _, fh := range r.MultipartForm.File["files"] { |
| 52 | if !allowedUpload(fh.Filename) { |
| 53 | http.Error(w, "Dateityp nicht erlaubt: "+fh.Filename+" (Bilder, Videos, PDF)", http.StatusBadRequest) |
| 54 | return |
| 55 | } |
| 56 | name := fmt.Sprintf("%d-%s%s", id, randomHex(8), extOf(fh.Filename)) |
| 57 | if err := saveUpload(s.cfg.MediaDir, fh.Open, name); err != nil { |
| 58 | s.fail(w, r, err) |
| 59 | return |
| 60 | } |
| 61 | c := caption |
| 62 | if c == "" { |
| 63 | c = strings.TrimSuffix(filepath.Base(fh.Filename), filepath.Ext(fh.Filename)) |
| 64 | } |
| 65 | if err := s.st.AddMedia(ctx, store.Media{EntryID: id, Filename: name, Caption: c}); err != nil { |
| 66 | os.Remove(filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name vom Server erzeugt |
| 67 | s.fail(w, r, err) |
| 68 | return |
| 69 | } |
| 70 | } |
| 71 | redirect(w, r, "/eintrag/%d#medien", id) |
| 72 | } |
| 73 | |
| 74 | // saveUpload schreibt erst in eine Temp-Datei und benennt dann um, damit nie |
| 75 | // halbe Dateien unter dem endgültigen Namen liegen. |
| 76 | func saveUpload(dir string, open func() (multipart.File, error), name string) error { |
| 77 | src, err := open() |
| 78 | if err != nil { |
| 79 | return err |
| 80 | } |
| 81 | defer func() { _ = src.Close() }() |
| 82 | if err := os.MkdirAll(dir, 0o750); err != nil { |
| 83 | return err |
| 84 | } |
| 85 | tmp, err := os.CreateTemp(dir, ".upload-*") |
| 86 | if err != nil { |
| 87 | return err |
| 88 | } |
| 89 | if _, err := io.Copy(tmp, src); err != nil { |
| 90 | _ = tmp.Close() |
| 91 | os.Remove(tmp.Name()) |
| 92 | return err |
| 93 | } |
| 94 | if err := tmp.Close(); err != nil { |
| 95 | os.Remove(tmp.Name()) |
| 96 | return err |
| 97 | } |
| 98 | // CreateTemp legt die Datei mit 0600 an; ausgeliefert wird sie nur über die App. |
| 99 | return os.Rename(tmp.Name(), filepath.Join(dir, name)) //nolint:gosec // G703: Name vom Server erzeugt |
| 100 | } |
| 101 | |
| 102 | func (s *Server) deleteMedia(w http.ResponseWriter, r *http.Request) { |
| 103 | id, ok := pathID(r, "id") |
| 104 | if !ok { |
| 105 | http.NotFound(w, r) |
| 106 | return |
| 107 | } |
| 108 | m, err := s.st.DeleteMedia(r.Context(), id) |
| 109 | if err != nil { |
| 110 | s.fail(w, r, err) |
| 111 | return |
| 112 | } |
| 113 | if err := os.Remove(filepath.Join(s.cfg.MediaDir, m.Filename)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 114 | s.log.Warn("medium nicht gelöscht", "datei", m.Filename, "err", err) |
| 115 | } |
| 116 | redirect(w, r, "/eintrag/%d#medien", m.EntryID) |
| 117 | } |
| 118 | |
| 119 | func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) { |
| 120 | name := r.PathValue("file") |
| 121 | // Nur Dateien ausliefern, die in der Datenbank stehen – kein Pfad-Trick möglich. |
| 122 | if name != filepath.Base(name) || strings.HasPrefix(name, ".") { |
| 123 | http.NotFound(w, r) |
| 124 | return |
| 125 | } |
| 126 | known, err := s.st.MediaKnown(r.Context(), name) |
| 127 | if err != nil { |
| 128 | s.fail(w, r, err) |
| 129 | return |
| 130 | } |
| 131 | if !known { |
| 132 | http.NotFound(w, r) |
| 133 | return |
| 134 | } |
| 135 | w.Header().Set("Cache-Control", "private, max-age=86400") |
| 136 | if extOf(name) == ".pdf" { |
| 137 | w.Header().Set("Content-Disposition", "inline") |
| 138 | } |
| 139 | // ServeFile unterstützt Range-Requests → Videos sind spulbar. |
| 140 | http.ServeFile(w, r, filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name geprüft und in der DB bekannt |
| 141 | } |
| 142 |