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 "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
27type 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
38func 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.
61func (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
122func (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
134func (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
147type statusWriter struct {
148 http.ResponseWriter
149 status int
150}
151
152func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) }
153
154func (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
165func 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
174var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) // ohne WithUnsafe: kein rohes HTML
175
176var 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
225func (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
246func (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
255func 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
260func 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
265func 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.
271func 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.
276func 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