media.go
⎇
Raw
1package web
2
3import (
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
16const maxUpload = 1 << 30 // 1 GB pro Upload (Videos)
17
18var (
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
23func extOf(f string) string { return strings.ToLower(filepath.Ext(f)) }
24func isImage(f string) bool { return imageExt[extOf(f)] }
25func isVideo(f string) bool { return videoExt[extOf(f)] }
26
27func allowedUpload(f string) bool {
28 e := extOf(f)
29 return imageExt[e] || videoExt[e] || e == ".pdf"
30}
31
32func (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 := s.saveUpload(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.
76func (s *Server) saveUpload(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 tmp, err := os.CreateTemp(s.cfg.MediaDir, ".upload-*")
83 if err != nil {
84 return err
85 }
86 if _, err := io.Copy(tmp, src); err != nil {
87 _ = tmp.Close()
88 os.Remove(tmp.Name())
89 return err
90 }
91 if err := tmp.Close(); err != nil {
92 os.Remove(tmp.Name())
93 return err
94 }
95 // CreateTemp legt die Datei mit 0600 an; ausgeliefert wird sie nur über die App.
96 return os.Rename(tmp.Name(), filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
97}
98
99func (s *Server) deleteMedia(w http.ResponseWriter, r *http.Request) {
100 id, ok := pathID(r, "id")
101 if !ok {
102 http.NotFound(w, r)
103 return
104 }
105 m, err := s.st.DeleteMedia(r.Context(), id)
106 if err != nil {
107 s.fail(w, r, err)
108 return
109 }
110 if err := os.Remove(filepath.Join(s.cfg.MediaDir, m.Filename)); err != nil && !errors.Is(err, os.ErrNotExist) {
111 s.log.Warn("medium nicht gelöscht", "datei", m.Filename, "err", err)
112 }
113 redirect(w, r, "/eintrag/%d#medien", m.EntryID)
114}
115
116func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
117 name := r.PathValue("file")
118 // Nur Dateien ausliefern, die in der Datenbank stehen – kein Pfad-Trick möglich.
119 if name != filepath.Base(name) || strings.HasPrefix(name, ".") {
120 http.NotFound(w, r)
121 return
122 }
123 known, err := s.st.MediaKnown(r.Context(), name)
124 if err != nil {
125 s.fail(w, r, err)
126 return
127 }
128 if !known {
129 http.NotFound(w, r)
130 return
131 }
132 w.Header().Set("Cache-Control", "private, max-age=86400")
133 if extOf(name) == ".pdf" {
134 w.Header().Set("Content-Disposition", "inline")
135 }
136 // ServeFile unterstützt Range-Requests → Videos sind spulbar.
137 http.ServeFile(w, r, filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name geprüft und in der DB bekannt
138}
139