docs.go
⎇
Raw
1package web
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "forge.gpl-nas.de/physiobase/internal/store"
12)
13
14const maxDocUpload = 300 << 20 // 300 MB pro Upload (mehrere Scans auf einmal)
15
16// Scans und Fotos. HEIC/TIFF werden gespeichert, aber vom Browser meist nicht angezeigt (→ Download).
17var docExt = map[string]bool{
18 ".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
19 ".heic": true, ".heif": true, ".tif": true, ".tiff": true,
20}
21
22func isPreviewable(f string) bool { return isImage(f) }
23
24func (s *Server) uploadDocs(w http.ResponseWriter, r *http.Request) {
25 ctx := r.Context()
26 pid, ok := pathID(r, "id")
27 if !ok {
28 http.NotFound(w, r)
29 return
30 }
31 if _, err := s.st.Patient(ctx, pid); err != nil {
32 s.fail(w, r, err)
33 return
34 }
35 r.Body = http.MaxBytesReader(w, r.Body, maxDocUpload)
36 if err := r.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt
37 http.Error(w, "Upload fehlgeschlagen (max. 300 MB auf einmal)", http.StatusBadRequest)
38 return
39 }
40 defer func() { _ = r.MultipartForm.RemoveAll() }()
41
42 files := r.MultipartForm.File["files"]
43 if len(files) == 0 {
44 http.Error(w, "Keine Datei ausgewählt", http.StatusBadRequest)
45 return
46 }
47 title, category, date := formText(r, "title"), formText(r, "category"), validDate(formText(r, "date"))
48 for i, fh := range files {
49 ext := extOf(fh.Filename)
50 if !docExt[ext] {
51 http.Error(w, "Dateityp nicht erlaubt: "+fh.Filename+" (PDF oder Foto)", http.StatusBadRequest)
52 return
53 }
54 name := fmt.Sprintf("%d-%s%s", pid, randomHex(10), ext)
55 if err := saveUpload(s.cfg.DocsDir, fh.Open, name); err != nil {
56 s.fail(w, r, err)
57 return
58 }
59 t := title
60 switch {
61 case t == "":
62 t = strings.TrimSuffix(filepath.Base(fh.Filename), filepath.Ext(fh.Filename))
63 case len(files) > 1:
64 t = fmt.Sprintf("%s (%d/%d)", title, i+1, len(files))
65 }
66 d := store.Doc{PatientID: pid, Filename: name, Original: filepath.Base(fh.Filename), Title: t, Category: category, Date: date, Size: fh.Size}
67 if err := s.st.AddDoc(ctx, d); err != nil {
68 os.Remove(filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
69 s.fail(w, r, err)
70 return
71 }
72 }
73 redirect(w, r, "/patient/%d#dokumente", pid)
74}
75
76func (s *Server) deleteDoc(w http.ResponseWriter, r *http.Request) {
77 id, ok := pathID(r, "id")
78 if !ok {
79 http.NotFound(w, r)
80 return
81 }
82 d, err := s.st.DeleteDoc(r.Context(), id)
83 if err != nil {
84 s.fail(w, r, err)
85 return
86 }
87 s.removeDocFile(d.Filename)
88 redirect(w, r, "/patient/%d#dokumente", d.PatientID)
89}
90
91func (s *Server) removeDocFile(name string) {
92 err := os.Remove(filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name stammt aus der Datenbank
93 if err != nil && !errors.Is(err, os.ErrNotExist) {
94 s.log.Warn("dokument nicht gelöscht", "datei", name, "err", err)
95 }
96}
97
98func (s *Server) serveDoc(w http.ResponseWriter, r *http.Request) {
99 name := r.PathValue("file")
100 if name != filepath.Base(name) || strings.HasPrefix(name, ".") {
101 http.NotFound(w, r)
102 return
103 }
104 d, err := s.st.DocByFile(r.Context(), name)
105 if err != nil {
106 s.fail(w, r, err)
107 return
108 }
109 // Gesundheitsdaten: nicht in geteilten Caches ablegen
110 w.Header().Set("Cache-Control", "private, no-store")
111 disposition := "inline"
112 if !isPreviewable(name) && extOf(name) != ".pdf" {
113 disposition = "attachment" // HEIC/TIFF kann der Browser meist nicht anzeigen
114 }
115 w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename*=UTF-8''%s`, disposition, urlPathEscape(d.Original)))
116 http.ServeFile(w, r, filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name geprüft und in der DB bekannt
117}
118