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