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 := 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.
76func 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
102func (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
119func (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