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", "appointments", "appointment"} {
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", "appointments_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
124 mux.HandleFunc("GET /termine", s.listAppointments)
125 mux.HandleFunc("POST /termine", s.createAppointment)
126 mux.HandleFunc("GET /termin/{id}", s.showAppointment)
127 mux.HandleFunc("POST /termin/{id}", s.saveAppointment)
128 mux.HandleFunc("POST /termin/{id}/absagen", s.toggleCancel)
129 mux.HandleFunc("POST /termin/{id}/loeschen", s.deleteAppointment)
130 mux.HandleFunc("POST /behandler", s.createTherapist)
131 mux.HandleFunc("POST /behandler/{id}", s.updateTherapist)
132 mux.HandleFunc("GET /patient/{id}/kalender-qr.svg", s.calendarQR)
133 mux.HandleFunc("POST /patient/{id}/kalender-link", s.rotateCalendar)
134 mux.HandleFunc("GET /patient/{id}/termine/druck", s.printAppointments)
135 mux.HandleFunc("GET /kalender/{file}", s.calendarFeed)
136 mux.HandleFunc("GET /befund/{id}", s.showBefund)
137 mux.HandleFunc("POST /befund/{id}", s.saveBefund)
138 mux.HandleFunc("POST /befund/{id}/loeschen", s.deleteBefund)
139 mux.HandleFunc("GET /befund/{id}/druck", s.printBefund)
140 mux.HandleFunc("GET /befund/{id}/export", s.exportBefund)
141
142 // CrossOriginProtection (Go 1.25+) blockt fremde POSTs anhand von Sec-Fetch-Site/Origin – CSRF-Schutz ohne Tokens.
143 return s.securityHeaders(http.NewCrossOriginProtection().Handler(s.requireAuth(s.logRequests(mux))))
144}
145
146func (s *Server) health(w http.ResponseWriter, r *http.Request) {
147 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
148 defer cancel()
149 if err := s.st.Ping(ctx); err != nil {
150 http.Error(w, "false", http.StatusServiceUnavailable)
151 return
152 }
153 w.Write([]byte("true"))
154}
155
156// ---------- Middleware ----------
157
158func (s *Server) securityHeaders(next http.Handler) http.Handler {
159 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160 h := w.Header()
161 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'")
162 h.Set("X-Content-Type-Options", "nosniff")
163 h.Set("Referrer-Policy", "same-origin")
164 if s.cfg.IsHTTPS() {
165 h.Set("Strict-Transport-Security", "max-age=31536000")
166 }
167 next.ServeHTTP(w, r)
168 })
169}
170
171type statusWriter struct {
172 http.ResponseWriter
173 status int
174}
175
176func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) }
177
178func (s *Server) logRequests(next http.Handler) http.Handler {
179 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180 start := time.Now()
181 sw := &statusWriter{ResponseWriter: w, status: 200}
182 next.ServeHTTP(sw, r)
183 if r.URL.Path != "/healthz" {
184 s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dauer", time.Since(start))
185 }
186 })
187}
188
189func cacheForever(next http.Handler) http.Handler {
190 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191 w.Header().Set("Cache-Control", "public, max-age=86400")
192 next.ServeHTTP(w, r)
193 })
194}
195
196// ---------- Rendering ----------
197
198var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) // ohne WithUnsafe: kein rohes HTML
199
200var funcs = template.FuncMap{
201 "kindLabel": store.KindLabel,
202 "md": func(src string) template.HTML {
203 var b bytes.Buffer
204 if err := md.Convert([]byte(src), &b); err != nil {
205 return template.HTML(template.HTMLEscapeString(src)) //nolint:gosec // G203: bereits escaped
206 }
207 // goldmark ohne WithUnsafe: rohes HTML im Markdown wird verworfen.
208 return template.HTML(b.String()) //nolint:gosec // G203
209 },
210 "date": func(t time.Time) string { return t.Local().Format("02.01.2006") },
211 "add1": func(i int) int { return i + 1 },
212 "day": dayLabel,
213 "hm": func(t time.Time) string { return t.Local().Format("15:04") },
214 "ymd": func(t time.Time) string { return t.Local().Format("2006-01-02") },
215 // Diagnose eines Befunds für Listen
216 "diag": func(raw string) string { d, _ := befund.Parse(raw); return d.Get("diag") },
217 "cell": func(t, c string, i int) string { return fmt.Sprintf("t.%s.%d.%s", t, i, c) },
218 "fmtNum": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
219 "isoDate": func(t time.Time) string { return t.Local().Format("2006-01-02") },
220 "de": func(iso string) string { // 2025-03-01 → 01.03.2025
221 if t, err := time.Parse("2006-01-02", iso); err == nil {
222 return t.Format("02.01.2006")
223 }
224 return iso
225 },
226 "isImage": isImage,
227 "isVideo": isVideo,
228 "isPDF": func(f string) bool { return strings.EqualFold(extOf(f), ".pdf") },
229 "size": func(n int64) string {
230 switch {
231 case n >= 1<<20:
232 return strings.ReplaceAll(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".", ",") + " MB"
233 case n >= 1<<10:
234 return strconv.FormatInt(n>>10, 10) + " KB"
235 }
236 return strconv.FormatInt(n, 10) + " B"
237 },
238 "tags": func(s string) []string {
239 var out []string
240 for _, p := range strings.Split(s, ",") {
241 if p = strings.TrimSpace(p); p != "" {
242 out = append(out, p)
243 }
244 }
245 return out
246 },
247 // Für Handouts: höchstens zwei Bilder pro Übung
248 "images": func(m []store.Media) []store.Media {
249 var out []store.Media
250 for _, x := range m {
251 if isImage(x.Filename) {
252 if out = append(out, x); len(out) == 2 {
253 break
254 }
255 }
256 }
257 return out
258 },
259}
260
261func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, status int, data map[string]any) {
262 if data == nil {
263 data = map[string]any{}
264 }
265 data["Practice"] = s.cfg.PracticeName
266 data["Kinds"] = store.Kinds
267 data["Path"] = r.URL.Path
268 name := "layout"
269 if strings.HasSuffix(page, "print") {
270 name = page
271 }
272 var buf bytes.Buffer
273 if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
274 s.fail(w, r, err)
275 return
276 }
277 w.Header().Set("Content-Type", "text/html; charset=utf-8")
278 w.WriteHeader(status)
279 buf.WriteTo(w)
280}
281
282func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
283 if errors.Is(err, store.ErrNotFound) {
284 http.Error(w, "Nicht gefunden", http.StatusNotFound)
285 return
286 }
287 s.log.Error("anfrage fehlgeschlagen", "method", r.Method, "path", r.URL.Path, "err", err)
288 http.Error(w, "Interner Fehler", http.StatusInternalServerError)
289}
290
291func redirect(w http.ResponseWriter, r *http.Request, format string, args ...any) {
292 // Ziele sind immer feste, interne Pfade mit numerischen IDs.
293 http.Redirect(w, r, fmt.Sprintf(format, args...), http.StatusSeeOther) //nolint:gosec // G710
294}
295
296// urlPathEscape für Dateinamen in Content-Disposition (RFC 5987).
297func urlPathEscape(s string) string { return url.PathEscape(s) }
298
299func pathID(r *http.Request, name string) (int64, bool) {
300 id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
301 return id, err == nil && id > 0
302}
303
304func formID(r *http.Request, name string) (int64, bool) {
305 id, err := strconv.ParseInt(r.FormValue(name), 10, 64)
306 return id, err == nil && id > 0
307}
308
309// formText: getrimmt, Zeilenenden vereinheitlicht.
310func formText(r *http.Request, name string) string {
311 return strings.TrimSpace(strings.ReplaceAll(r.FormValue(name), "\r\n", "\n"))
312}
313
314// limitForm begrenzt normale Formulare (nicht Uploads) auf 2 MB.
315func limitForm(w http.ResponseWriter, r *http.Request) bool {
316 r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
317 if err := r.ParseForm(); err != nil {
318 http.Error(w, "Formular ungültig oder zu groß", http.StatusBadRequest)
319 return false
320 }
321 return true
322}
323