Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/notices/handler.go

554bcfc63721b22faf8bca78d5518d76e24a4d20 · 8.6 KB · 285 lines raw

1 package notices
2
3 import (
4 "crypto/rand"
5 "crypto/subtle"
6 "embed"
7 "encoding/hex"
8 "fmt"
9 "html/template"
10 "log/slog"
11 "net/http"
12 "sort"
13 "strconv"
14 "strings"
15
16 "gsg/internal/httpx"
17 )
18
19 //go:embed admin.html
20 var adminFS embed.FS
21
22 // HandlerConfig is everything the admin surface needs that this package
23 // cannot discover for itself. Repos is the visible repository list, used to
24 // populate the scope dropdown.
25 type HandlerConfig struct {
26 Store *Store
27 Repos []string
28 SiteName string
29 UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz
30
31 // GroupHeader and RequiredGroup gate the admin surface itself, on top of
32 // whatever authentication the reverse proxy already did. Authentik's
33 // forward_auth can be shared by every /admin/* route (so several people
34 // can log in for share links), while only members of RequiredGroup may
35 // reach notices editing. RequiredGroup empty disables the check -- every
36 // request that reaches this handler is authorized, i.e. today's
37 // behavior. GroupHeader defaults to "X-Authentik-Groups", which
38 // Authentik sends as a "|"-delimited list of the user's groups.
39 GroupHeader string
40 RequiredGroup string
41
42 Logger *slog.Logger
43 }
44
45 type adminRow struct {
46 Scope, Type, Title, Body, Link, LinkText string
47 }
48
49 type adminView struct {
50 Site, CSRF, Error string
51 Saved bool
52 Repos []string
53 Rows []adminRow
54 }
55
56 const (
57 csrfCookie = "gsg_admin_csrf"
58 maxFormBytes = 1 << 20
59 )
60
61 type handler struct {
62 cfg HandlerConfig
63 tmpl *template.Template
64 }
65
66 // NewHandler builds the admin mux. It is deliberately separate from the
67 // read-only server: the only write path in gsg lives here, and it writes a
68 // config file, never a repository.
69 func NewHandler(cfg HandlerConfig) (http.Handler, error) {
70 if cfg.Store == nil {
71 return nil, fmt.Errorf("notices admin: no store")
72 }
73 if cfg.Logger == nil {
74 cfg.Logger = slog.Default()
75 }
76 if cfg.UserHeader == "" {
77 cfg.UserHeader = "X-Authentik-Username"
78 }
79 if cfg.GroupHeader == "" {
80 cfg.GroupHeader = "X-Authentik-Groups"
81 }
82 tmpl, err := template.ParseFS(adminFS, "admin.html")
83 if err != nil {
84 return nil, fmt.Errorf("notices admin template: %w", err)
85 }
86 h := &handler{cfg: cfg, tmpl: tmpl}
87
88 mux := http.NewServeMux()
89 // Method-qualified patterns: anything else (PUT, DELETE, or a stray
90 // GET/POST to another path) is refused by the mux, not silently handled.
91 mux.HandleFunc("GET /admin/{$}", h.index)
92 mux.HandleFunc("GET /admin/notices", h.get)
93 mux.HandleFunc("POST /admin/notices", h.post)
94
95 if cfg.RequiredGroup == "" {
96 return mux, nil
97 }
98 return h.requireGroup(mux), nil
99 }
100
101 // requireGroup wraps next so that every admin route first checks
102 // RequiredGroup, before any handler runs (including the CSRF cookie setup
103 // in get/index -- a rejected caller gets no state from this handler at
104 // all). Authentication already happened upstream at the reverse proxy; this
105 // is authorization on top of it, using a group claim the proxy forwards, so
106 // that reaching the (shared) /admin/* forward_auth is not by itself enough
107 // to edit notices.
108 func (h *handler) requireGroup(next http.Handler) http.Handler {
109 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110 if !h.inRequiredGroup(r) {
111 h.cfg.Logger.Warn("notices admin: group check rejected",
112 "user", r.Header.Get(h.cfg.UserHeader),
113 "groups", r.Header.Get(h.cfg.GroupHeader))
114 http.Error(w, "forbidden", http.StatusForbidden)
115 return
116 }
117 next.ServeHTTP(w, r)
118 })
119 }
120
121 // inRequiredGroup reports whether r carries cfg.RequiredGroup in its
122 // GroupHeader. Authentik delimits multiple groups with "|"; entries are
123 // trimmed so incidental whitespace around the delimiter doesn't cause a
124 // false negative.
125 func (h *handler) inRequiredGroup(r *http.Request) bool {
126 for _, g := range strings.Split(r.Header.Get(h.cfg.GroupHeader), "|") {
127 if strings.TrimSpace(g) == h.cfg.RequiredGroup {
128 return true
129 }
130 }
131 return false
132 }
133
134 func (h *handler) index(w http.ResponseWriter, r *http.Request) {
135 http.Redirect(w, r, "/admin/notices", http.StatusSeeOther)
136 }
137
138 func (h *handler) get(w http.ResponseWriter, r *http.Request) {
139 httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
140 tok, err := ensureCSRF(w, r)
141 if err != nil {
142 http.Error(w, "cannot start session", http.StatusInternalServerError)
143 return
144 }
145 h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1")
146 }
147
148 func (h *handler) post(w http.ResponseWriter, r *http.Request) {
149 httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
150
151 // Header checks first: they need no body. Then bound and parse the
152 // body, then compare the CSRF token against the cookie. Parsing before
153 // the token check is safe because the read is capped.
154 if !httpx.SameOrigin(r) {
155 h.cfg.Logger.Warn("notices admin: origin rejected",
156 "host", r.Host, "origin", r.Header.Get("Origin"),
157 "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
158 http.Error(w, "forbidden", http.StatusForbidden)
159 return
160 }
161 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
162 if err := r.ParseForm(); err != nil {
163 http.Error(w, "bad form", http.StatusBadRequest)
164 return
165 }
166 if !checkCSRF(r) {
167 h.cfg.Logger.Warn("notices admin: csrf rejected",
168 "user", r.Header.Get(h.cfg.UserHeader))
169 http.Error(w, "forbidden", http.StatusForbidden)
170 return
171 }
172
173 doc, err := docFromForm(r)
174 if err == nil {
175 err = h.cfg.Store.Set(doc)
176 }
177 if err != nil {
178 tok, _ := ensureCSRF(w, r)
179 h.render(w, http.StatusBadRequest, tok, err.Error(), false)
180 return
181 }
182
183 h.cfg.Logger.Info("notices updated",
184 "user", r.Header.Get(h.cfg.UserHeader),
185 "global", len(doc.Global), "repos", len(doc.Repos))
186 http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther)
187 }
188
189 func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) {
190 snap := h.cfg.Store.Snapshot()
191 rows := make([]adminRow, 0, len(snap.Doc.Global)+2)
192 for _, n := range snap.Doc.Global {
193 rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText})
194 }
195 slugs := make([]string, 0, len(snap.Doc.Repos))
196 for slug := range snap.Doc.Repos {
197 slugs = append(slugs, slug)
198 }
199 sort.Strings(slugs)
200 for _, slug := range slugs {
201 for _, n := range snap.Doc.Repos[slug] {
202 rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText})
203 }
204 }
205 // One blank row is the no-JS "add" affordance: fill it and save. The
206 // admin.js helper clones it for the Add button.
207 rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""})
208
209 w.Header().Set("Content-Type", "text/html; charset=utf-8")
210 w.WriteHeader(status)
211 if err := h.tmpl.Execute(w, adminView{
212 Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved,
213 Repos: h.cfg.Repos, Rows: rows,
214 }); err != nil {
215 h.cfg.Logger.Error("notices admin render", "err", err)
216 }
217 }
218
219 // docFromForm rebuilds the document from the rendered row list. Rows are
220 // parallel slices (scope[], type[], ...); deletion is a sparse del[] list
221 // of row indices. A row with every field blank is treated as unused -- so
222 // clearing a row removes it. PostForm, not Form, so a query string cannot
223 // inject or override fields.
224 func docFromForm(r *http.Request) (Document, error) {
225 form := r.PostForm
226 at := func(name string, i int) string {
227 if v := form[name]; i < len(v) {
228 return v[i]
229 }
230 return ""
231 }
232 del := map[int]bool{}
233 for _, s := range form["del"] {
234 if i, err := strconv.Atoi(s); err == nil {
235 del[i] = true
236 }
237 }
238 n := len(form["scope"])
239 doc := Document{Repos: map[string][]Notice{}}
240 for i := 0; i < n; i++ {
241 if del[i] {
242 continue
243 }
244 nt := Notice{
245 Type: at("type", i), Title: at("title", i), Body: at("body", i),
246 Link: at("link", i), LinkText: at("link_text", i),
247 }
248 if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" {
249 continue
250 }
251 scope := at("scope", i)
252 if scope == "" || scope == "global" {
253 doc.Global = append(doc.Global, nt)
254 } else {
255 doc.Repos[scope] = append(doc.Repos[scope], nt)
256 }
257 }
258 return doc, nil // Store.Set validates
259 }
260
261 func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
262 if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 {
263 return c.Value, nil
264 }
265 b := make([]byte, 32)
266 if _, err := rand.Read(b); err != nil {
267 return "", err
268 }
269 tok := hex.EncodeToString(b)
270 http.SetCookie(w, &http.Cookie{
271 Name: csrfCookie, Value: tok, Path: "/admin",
272 HttpOnly: true, Secure: httpx.IsHTTPS(r), SameSite: http.SameSiteStrictMode,
273 MaxAge: 86400,
274 })
275 return tok, nil
276 }
277
278 func checkCSRF(r *http.Request) bool {
279 c, err := r.Cookie(csrfCookie)
280 if err != nil {
281 return false
282 }
283 got := r.PostForm.Get("csrf")
284 return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1
285 }