server.go
| 1 | // Package web enthält Routen, Handler und Templates-Rendering. |
| 2 | package web |
| 3 | |
| 4 | import ( |
| 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 | |
| 28 | type 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 | |
| 39 | func 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. |
| 62 | func (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 | |
| 133 | func (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 | |
| 145 | func (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 | |
| 158 | type statusWriter struct { |
| 159 | http.ResponseWriter |
| 160 | status int |
| 161 | } |
| 162 | |
| 163 | func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) } |
| 164 | |
| 165 | func (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 | |
| 176 | func 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 | |
| 185 | var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) // ohne WithUnsafe: kein rohes HTML |
| 186 | |
| 187 | var 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 | |
| 245 | func (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 | |
| 266 | func (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 | |
| 275 | func 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). |
| 281 | func urlPathEscape(s string) string { return url.PathEscape(s) } |
| 282 | |
| 283 | func 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 | |
| 288 | func 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. |
| 294 | func 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. |
| 299 | func 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 |