server.go
⎇
Raw
1// Package web enthält Routen, Handler und Templates-Rendering.
2package web
3
4import (
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "html/template"
10 "io/fs"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/yuin/goldmark"
20 "github.com/yuin/goldmark/extension"
21
22 physiobase "forge.gpl-nas.de/physiobase"
23 "forge.gpl-nas.de/physiobase/internal/befund"
24 "forge.gpl-nas.de/physiobase/internal/config"
25 "forge.gpl-nas.de/physiobase/internal/store"
26)
27
28type Server struct {
29 cfg *config.Config
30 st *store.Store
31 log *slog.Logger
32 pages map[string]*template.Template
33
34 sessionMu sync.RWMutex
35 sessionSecret string
36 loginSem chan struct{}
37}
38
39func New(ctx context.Context, cfg *config.Config, st *store.Store, log *slog.Logger) (*Server, error) {
40 s := &Server{cfg: cfg, st: st, log: log, pages: map[string]*template.Template{}, loginSem: make(chan struct{}, maxConcurrentLogins)}
41 if err := s.loadSessionSecret(ctx); err != nil {
42 return nil, err
43 }
44 for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare", "summary"} {
45 t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/layout.html", "web/templates/"+p+".html")
46 if err != nil {
47 return nil, fmt.Errorf("template %s: %w", p, err)
48 }
49 s.pages[p] = t
50 }
51 for _, p := range []string{"print", "befund_print", "summary_print"} {
52 t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/"+p+".html")
53 if err != nil {
54 return nil, fmt.Errorf("template %s: %w", p, err)
55 }
56 s.pages[p] = t
57 }
58 return s, nil
59}
60
61// Handler: Auth gilt global, damit neue Routen automatisch geschützt sind.
62func (s *Server) Handler() http.Handler {
63 mux := http.NewServeMux()
64 static, _ := fs.Sub(physiobase.StaticFS, "web/static")
65 mux.Handle("GET /static/", http.StripPrefix("/static/", cacheForever(http.FileServerFS(static))))
66 mux.HandleFunc("GET /healthz", s.health)
67 mux.HandleFunc("GET /login", s.loginForm)
68 mux.HandleFunc("POST /login", s.login)
69 mux.HandleFunc("POST /logout", s.logout)
70
71 mux.HandleFunc("GET /{$}", s.index)
72 mux.HandleFunc("GET /eintrag/neu", s.newEntry)
73 mux.HandleFunc("POST /eintrag/neu", s.saveEntry)
74 mux.HandleFunc("GET /eintrag/{id}", s.showEntry)
75 mux.HandleFunc("GET /eintrag/{id}/bearbeiten", s.editEntry)
76 mux.HandleFunc("POST /eintrag/{id}/bearbeiten", s.saveEntry)
77 mux.HandleFunc("POST /eintrag/{id}/loeschen", s.deleteEntry)
78 mux.HandleFunc("POST /eintrag/{id}/verknuepfen", s.linkEntry)
79 mux.HandleFunc("POST /eintrag/{id}/entknuepfen/{other}", s.unlinkEntry)
80 mux.HandleFunc("POST /eintrag/{id}/quelle", s.addSource)
81 mux.HandleFunc("POST /quelle/{id}/loeschen", s.deleteSource)
82 mux.HandleFunc("POST /eintrag/{id}/medien", s.uploadMedia)
83 mux.HandleFunc("POST /medium/{id}/loeschen", s.deleteMedia)
84 mux.HandleFunc("GET /medien/{file}", s.serveMedia)
85
86 mux.HandleFunc("GET /handouts", s.listHandouts)
87 mux.HandleFunc("POST /handouts", s.createHandout)
88 mux.HandleFunc("POST /handouts/uebung", s.addToHandout)
89 mux.HandleFunc("GET /handout/{id}", s.showHandout)
90 mux.HandleFunc("POST /handout/{id}", s.updateHandout)
91 mux.HandleFunc("POST /handout/{id}/loeschen", s.deleteHandout)
92 mux.HandleFunc("POST /handout/{id}/kopieren", s.copyHandout)
93 mux.HandleFunc("POST /handout/{id}/uebung", s.addHandoutItem)
94 mux.HandleFunc("POST /handout/{id}/uebung/{eid}", s.updateHandoutItem)
95 mux.HandleFunc("POST /handout/{id}/uebung/{eid}/hoch", s.moveHandoutItem)
96 mux.HandleFunc("POST /handout/{id}/uebung/{eid}/loeschen", s.removeHandoutItem)
97 mux.HandleFunc("GET /handout/{id}/druck", s.printHandout)
98
99 mux.HandleFunc("GET /patienten", s.listPatients)
100 mux.HandleFunc("POST /patienten", s.createPatient)
101 mux.HandleFunc("POST /patienten/import", s.importBefunde)
102 mux.HandleFunc("GET /patient/{id}", s.showPatient)
103 mux.HandleFunc("POST /patient/{id}", s.updatePatient)
104 mux.HandleFunc("POST /patient/{id}/archiv", s.archivePatient)
105 mux.HandleFunc("POST /patient/{id}/loeschen", s.deletePatient)
106 mux.HandleFunc("POST /patient/{id}/befund", s.newBefund)
107 mux.HandleFunc("POST /patient/{id}/verlauf", s.addSession)
108 mux.HandleFunc("POST /verlauf/{id}/loeschen", s.deleteSession)
109 mux.HandleFunc("POST /patient/{id}/wissen", s.linkPatientEntry)
110 mux.HandleFunc("POST /patient/{id}/wissen/{eid}/loeschen", s.unlinkPatientEntry)
111 mux.HandleFunc("POST /patient/{id}/handout", s.createPatientHandout)
112 mux.HandleFunc("GET /patient/{id}/vergleich", s.compareBefunde)
113 mux.HandleFunc("POST /patient/{id}/dokumente", s.uploadDocs)
114 mux.HandleFunc("POST /dokument/{id}/loeschen", s.deleteDoc)
115 mux.HandleFunc("GET /dokumente/{file}", s.serveDoc)
116 mux.HandleFunc("POST /patient/{id}/zusammenfassung", s.createGeneralSummary)
117 mux.HandleFunc("POST /verlauf/{id}/zusammenfassung", s.createSessionSummary)
118 mux.HandleFunc("GET /zusammenfassung/{id}", s.showSummary)
119 mux.HandleFunc("POST /zusammenfassung/{id}", s.saveSummary)
120 mux.HandleFunc("POST /zusammenfassung/{id}/loeschen", s.deleteSummary)
121 mux.HandleFunc("GET /zusammenfassung/{id}/druck", s.printSummary)
122 mux.HandleFunc("POST /zusammenfassung/{id}/senden", s.sendSummary)
123 mux.HandleFunc("GET /befund/{id}", s.showBefund)
124 mux.HandleFunc("POST /befund/{id}", s.saveBefund)
125 mux.HandleFunc("POST /befund/{id}/loeschen", s.deleteBefund)
126 mux.HandleFunc("GET /befund/{id}/druck", s.printBefund)
127 mux.HandleFunc("GET /befund/{id}/export", s.exportBefund)
128
129 // CrossOriginProtection (Go 1.25+) blockt fremde POSTs anhand von Sec-Fetch-Site/Origin – CSRF-Schutz ohne Tokens.
130 return s.securityHeaders(http.NewCrossOriginProtection().Handler(s.requireAuth(s.logRequests(mux))))
131}
132
133func (s *Server) health(w http.ResponseWriter, r *http.Request) {
134 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
135 defer cancel()
136 if err := s.st.Ping(ctx); err != nil {
137 http.Error(w, "false", http.StatusServiceUnavailable)
138 return
139 }
140 w.Write([]byte("true"))
141}
142
143// ---------- Middleware ----------
144
145func (s *Server) securityHeaders(next http.Handler) http.Handler {
146 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147 h := w.Header()
148 h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'")
149 h.Set("X-Content-Type-Options", "nosniff")
150 h.Set("Referrer-Policy", "same-origin")
151 if s.cfg.IsHTTPS() {
152 h.Set("Strict-Transport-Security", "max-age=31536000")
153 }
154 next.ServeHTTP(w, r)
155 })
156}
157
158type statusWriter struct {
159 http.ResponseWriter
160 status int
161}
162
163func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) }
164
165func (s *Server) logRequests(next http.Handler) http.Handler {
166 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167 start := time.Now()
168 sw := &statusWriter{ResponseWriter: w, status: 200}
169 next.ServeHTTP(sw, r)
170 if r.URL.Path != "/healthz" {
171 s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dauer", time.Since(start))
172 }
173 })
174}
175
176func cacheForever(next http.Handler) http.Handler {
177 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178 w.Header().Set("Cache-Control", "public, max-age=86400")
179 next.ServeHTTP(w, r)
180 })
181}
182
183// ---------- Rendering ----------
184
185var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) // ohne WithUnsafe: kein rohes HTML
186
187var funcs = template.FuncMap{
188 "kindLabel": store.KindLabel,
189 "md": func(src string) template.HTML {
190 var b bytes.Buffer
191 if err := md.Convert([]byte(src), &b); err != nil {
192 return template.HTML(template.HTMLEscapeString(src)) //nolint:gosec // G203: bereits escaped
193 }
194 // goldmark ohne WithUnsafe: rohes HTML im Markdown wird verworfen.
195 return template.HTML(b.String()) //nolint:gosec // G203
196 },
197 "date": func(t time.Time) string { return t.Local().Format("02.01.2006") },
198 "add1": func(i int) int { return i + 1 },
199 // Diagnose eines Befunds für Listen
200 "diag": func(raw string) string { d, _ := befund.Parse(raw); return d.Get("diag") },
201 "cell": func(t, c string, i int) string { return fmt.Sprintf("t.%s.%d.%s", t, i, c) },
202 "fmtNum": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
203 "isoDate": func(t time.Time) string { return t.Local().Format("2006-01-02") },
204 "de": func(iso string) string { // 2025-03-01 → 01.03.2025
205 if t, err := time.Parse("2006-01-02", iso); err == nil {
206 return t.Format("02.01.2006")
207 }
208 return iso
209 },
210 "isImage": isImage,
211 "isVideo": isVideo,
212 "isPDF": func(f string) bool { return strings.EqualFold(extOf(f), ".pdf") },
213 "size": func(n int64) string {
214 switch {
215 case n >= 1<<20:
216 return strings.ReplaceAll(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".", ",") + " MB"
217 case n >= 1<<10:
218 return strconv.FormatInt(n>>10, 10) + " KB"
219 }
220 return strconv.FormatInt(n, 10) + " B"
221 },
222 "tags": func(s string) []string {
223 var out []string
224 for _, p := range strings.Split(s, ",") {
225 if p = strings.TrimSpace(p); p != "" {
226 out = append(out, p)
227 }
228 }
229 return out
230 },
231 // Für Handouts: höchstens zwei Bilder pro Übung
232 "images": func(m []store.Media) []store.Media {
233 var out []store.Media
234 for _, x := range m {
235 if isImage(x.Filename) {
236 if out = append(out, x); len(out) == 2 {
237 break
238 }
239 }
240 }
241 return out
242 },
243}
244
245func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, status int, data map[string]any) {
246 if data == nil {
247 data = map[string]any{}
248 }
249 data["Practice"] = s.cfg.PracticeName
250 data["Kinds"] = store.Kinds
251 data["Path"] = r.URL.Path
252 name := "layout"
253 if page == "print" || page == "befund_print" || page == "summary_print" {
254 name = page
255 }
256 var buf bytes.Buffer
257 if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
258 s.fail(w, r, err)
259 return
260 }
261 w.Header().Set("Content-Type", "text/html; charset=utf-8")
262 w.WriteHeader(status)
263 buf.WriteTo(w)
264}
265
266func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
267 if errors.Is(err, store.ErrNotFound) {
268 http.Error(w, "Nicht gefunden", http.StatusNotFound)
269 return
270 }
271 s.log.Error("anfrage fehlgeschlagen", "method", r.Method, "path", r.URL.Path, "err", err)
272 http.Error(w, "Interner Fehler", http.StatusInternalServerError)
273}
274
275func redirect(w http.ResponseWriter, r *http.Request, format string, args ...any) {
276 // Ziele sind immer feste, interne Pfade mit numerischen IDs.
277 http.Redirect(w, r, fmt.Sprintf(format, args...), http.StatusSeeOther) //nolint:gosec // G710
278}
279
280// urlPathEscape für Dateinamen in Content-Disposition (RFC 5987).
281func urlPathEscape(s string) string { return url.PathEscape(s) }
282
283func pathID(r *http.Request, name string) (int64, bool) {
284 id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
285 return id, err == nil && id > 0
286}
287
288func formID(r *http.Request, name string) (int64, bool) {
289 id, err := strconv.ParseInt(r.FormValue(name), 10, 64)
290 return id, err == nil && id > 0
291}
292
293// formText: getrimmt, Zeilenenden vereinheitlicht.
294func formText(r *http.Request, name string) string {
295 return strings.TrimSpace(strings.ReplaceAll(r.FormValue(name), "\r\n", "\n"))
296}
297
298// limitForm begrenzt normale Formulare (nicht Uploads) auf 2 MB.
299func limitForm(w http.ResponseWriter, r *http.Request) bool {
300 r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
301 if err := r.ParseForm(); err != nil {
302 http.Error(w, "Formular ungültig oder zu groß", http.StatusBadRequest)
303 return false
304 }
305 return true
306}
307