Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/notices/handler.go
d658fcc4ea9f3bb562fd00dce8f863934560c56c
· 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 | } |