gitmote / internal / webui / webui.go · history · branches
24679 bytes · raw
// Package webui serves the authenticated management UI — the operations that
// can't be done over git: create/list repos, mint/revoke tokens, and manage
// per-repo ACLs (docs/architecture/storage.md, the "web UI" component). It also
// serves read-only repo browsing (tree/blob/raw/commits/commit) over the
// materialized bare repo. It is a thin server-rendered layer over the same
// s3lite tables and object store the git path uses; it introduces no new source
// of truth.
//
// Management (create/list repos, tokens, users, ACLs, secrets) is gated on the
// global-admin role (users.is_admin); read-only browsing is gated on repo-read
// (public → anyone, private → an ACL — see authorizeRead). A browser logs in by
// pasting a personal access token once (verified through the same path git auth
// uses); the server then issues a signed, stateless session cookie.
package webui
import (
"context"
"embed"
"errors"
"html/template"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
// mermaidJS is the vendored, version-pinned mermaid library, embedded and served
// same-origin so diagram rendering needs no external fetch (see static/README.md).
//
//go:embed static/mermaid.min.js
var mermaidJS []byte
// Authenticator verifies a raw personal access token, resolving it to its owner.
// *auth.Guard satisfies it; the login form is the only caller.
type Authenticator interface {
VerifyToken(ctx context.Context, raw string) (*meta.User, error)
}
// SecretsAdmin manages a repo's CI secrets for the admin panel. Values are
// write-only: only names are ever read back. *secrets.Service satisfies it; nil
// disables the secrets UI.
type SecretsAdmin interface {
Enabled() bool
SetSecret(ctx context.Context, repoID int64, name, value string) error
ListSecretNames(ctx context.Context, repoID int64) ([]string, error)
DeleteSecret(ctx context.Context, repoID int64, name string) error
}
// nameSegment matches a valid owner or repo path segment.
var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// LiveLog reads a job's in-memory live CI log tail (the leader's ephemeral buffer
// the browser polls while a job runs). *ci.LiveLogs satisfies it. nil disables the
// live tail — the UI then shows only the durable log once the job completes.
type LiveLog interface {
Read(jobID int64, offset int) (data []byte, next int, done, ok bool)
}
// Handler serves the management UI.
type Handler struct {
md *meta.Metadata
mz *repo.Materializer
store store.Store
live LiveLog
auth Authenticator
secrets SecretsAdmin
sess *sessions
tmpl *template.Template
log *slog.Logger
now func() time.Time
}
// New builds the UI handler. cookieKey signs session cookies and must be
// non-empty; the auth verifier backs the login form; mz materializes repos for
// the read-only browse pages; objs reads CI log blobs for the run views; live
// (may be nil) backs the live log tail of a running job; secrets (may be nil)
// backs the CI secrets panel.
func New(md *meta.Metadata, mz *repo.Materializer, objs store.Store, live LiveLog, a Authenticator, secrets SecretsAdmin, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
if len(cookieKey) == 0 {
return nil, errors.New("webui: empty cookie key")
}
tmpl, err := template.New("webui").Funcs(template.FuncMap{
// parentPath is the enclosing directory of a browse path, "" at the root
// — used only to build the ".." link in the tree view.
"parentPath": func(p string) string {
p = strings.Trim(p, "/")
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[:i]
}
return ""
},
// highlightCSS is chroma's class stylesheet, included once in the head so
// highlighted blobs and README code blocks share one theme.
"highlightCSS": render.HighlightCSS,
// short abbreviates a commit SHA for display.
"short": func(s string) string {
if len(s) > 8 {
return s[:8]
}
return s
},
// statusColor maps a CI run/job status to a CSS color for its badge.
"statusColor": func(s meta.RunStatus) string {
switch s {
case meta.RunPassed:
return "#27ae60"
case meta.RunFailed, meta.RunError:
return "#c0392b"
case meta.RunRunning:
return "#b7950b"
default: // queued, superseded
return "#7f8c8d"
}
},
}).ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, err
}
if logger == nil {
logger = slog.Default()
}
return &Handler{
md: md,
mz: mz,
store: objs,
live: live,
auth: a,
secrets: secrets,
sess: &sessions{key: cookieKey},
tmpl: tmpl,
log: logger,
now: time.Now,
}, nil
}
// Register mounts the UI routes on mux. The dashboard, login, and read-only
// browsing are reachable unauthenticated (viewer-scoped); the global-management
// and per-repo-management routes require a global-admin session.
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /login", h.getLogin)
mux.HandleFunc("POST /login", h.postLogin)
mux.HandleFunc("POST /logout", h.postLogout)
// The bare root is the dashboard — a viewer-scoped repo list, not a redirect.
// GET /{$} matches only the exact root, so it never shadows a repo path;
// POST /{$} is the admin-only repo-create. Both exist only when the UI does.
mux.HandleFunc("GET /{$}", h.getDashboard)
mux.HandleFunc("POST /{$}", h.requireAdmin(h.postRepo))
// Global management — top-level, admin-only (no /ui/ prefix).
mux.HandleFunc("GET /users", h.requireAdmin(h.getUsers))
mux.HandleFunc("POST /users", h.requireAdmin(h.postUser))
mux.HandleFunc("GET /tokens", h.requireAdmin(h.getTokens))
mux.HandleFunc("POST /tokens", h.requireAdmin(h.postToken))
mux.HandleFunc("POST /tokens/revoke", h.requireAdmin(h.postRevokeToken))
// Per-repo management — segment-2 verbs, admin-only. Enumerated (never a broad
// /{repo}/{rest...}), so git's own suffixes still fall through to the catch-all.
mux.HandleFunc("GET /{repo}/settings", h.requireAdmin(h.getSettings))
mux.HandleFunc("POST /{repo}/settings", h.requireAdmin(h.postSettings))
mux.HandleFunc("GET /{repo}/access", h.requireAdmin(h.getAccess))
mux.HandleFunc("POST /{repo}/access", h.requireAdmin(h.postAccess))
mux.HandleFunc("POST /{repo}/access/revoke", h.requireAdmin(h.postRevokeAccess))
mux.HandleFunc("GET /{repo}/secrets", h.requireAdmin(h.getSecrets))
mux.HandleFunc("POST /{repo}/secrets", h.requireAdmin(h.postSecret))
mux.HandleFunc("POST /{repo}/secrets/delete", h.requireAdmin(h.postDeleteSecret))
// Read-only browsing. Gated on repo-read (public → anyone, private → an ACL),
// not on admin — this is what makes spectators and public repos usable; the
// per-request check lives in each handler via authorizeRead. The repo is a
// single path segment and the ref lives in the path (greedily resolved), so
// each content verb is its own enumerated route. Only these fixed verbs are
// registered — never a broad /{repo}/{rest...} — so git's own /{repo}/info/refs,
// /{repo}/git-upload-pack, … fall through to the smart-HTTP catch-all at "/"
// (docs/architecture/urls.md → Implementation seams).
// Each verb registers a bare form and a "/{rest...}" form: a "/{rest...}"
// pattern is a subtree, so ServeMux would otherwise 307-redirect the bare
// "/{repo}/runs" to "/{repo}/runs/". The bare form (empty rest) selects the
// default branch, or the list for runs.
mux.HandleFunc("GET /{repo}/tree", h.browseTreeRoute)
mux.HandleFunc("GET /{repo}/tree/{rest...}", h.browseTreeRoute)
mux.HandleFunc("GET /{repo}/blob", h.browseBlobRoute)
mux.HandleFunc("GET /{repo}/blob/{rest...}", h.browseBlobRoute)
mux.HandleFunc("GET /{repo}/raw", h.browseRawRoute)
mux.HandleFunc("GET /{repo}/raw/{rest...}", h.browseRawRoute)
mux.HandleFunc("GET /{repo}/commits", h.browseCommitsRoute)
mux.HandleFunc("GET /{repo}/commits/{rest...}", h.browseCommitsRoute)
mux.HandleFunc("GET /{repo}/commit/{sha}", h.browseCommitRoute)
mux.HandleFunc("GET /{repo}/compare/{rest...}", h.browseCompareRoute)
mux.HandleFunc("GET /{repo}/refs", h.browseRefsRoute)
mux.HandleFunc("GET /{repo}/runs", h.ciRunsRoute)
mux.HandleFunc("GET /{repo}/runs/{rest...}", h.ciRunsRoute)
// The bare repo landing: README + tree at the default branch. One segment, so
// distinct from git's GET /{repo}/info/refs.
mux.HandleFunc("GET /{repo}", h.browseLanding)
// The vendored mermaid library for diagram rendering, served same-origin.
// Public and non-sensitive (a pinned copy of an open-source library), so it
// needs no admin session; hard-cached, since the file itself is the version pin.
mux.HandleFunc("GET /static/mermaid.min.js", h.serveMermaidJS)
}
// serveMermaidJS serves the embedded mermaid library for the browse UI's diagram
// rendering. Trusted, fixed asset — immutable-cached so a browser fetches it once.
func (h *Handler) serveMermaidJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "max-age=31536000, immutable")
_, _ = w.Write(mermaidJS)
}
// ctxUser carries the authenticated admin from the middleware to the handler.
type ctxKey int
const userKey ctxKey = 0
func userFrom(ctx context.Context) *meta.User {
u, _ := ctx.Value(userKey).(*meta.User)
return u
}
// requireAdmin resolves the session cookie to a current, still-admin user on
// every request. Missing/invalid session → redirect to login (GET) or 401;
// authenticated but not an admin → 403.
func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, ok := h.sess.verify(r, h.now())
if !ok {
h.denyUnauth(w, r)
return
}
user, err := h.md.GetUserByID(r.Context(), uid)
if errors.Is(err, meta.ErrNotFound) {
h.denyUnauth(w, r)
return
}
if err != nil {
h.serverError(w, "load session user", err)
return
}
if !user.IsAdmin {
http.Error(w, "forbidden: admin only", http.StatusForbidden)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
}
}
func (h *Handler) denyUnauth(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
// --- login / logout ---
func (h *Handler) getLogin(w http.ResponseWriter, r *http.Request) {
h.render(w, "login.html", loginData{})
}
func (h *Handler) postLogin(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.FormValue("token"))
user, err := h.auth.VerifyToken(r.Context(), token)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
h.render(w, "login.html", loginData{Err: "invalid token"})
return
}
// Any valid user may sign in — a spectator/collaborator needs a session to
// browse the private repos they have access to on the dashboard. Management
// stays admin-only, enforced per-route by requireAdmin (not here).
h.sess.issue(w, r, user.ID, h.now())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) postLogout(w http.ResponseWriter, r *http.Request) {
h.sess.clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- dashboard / repos ---
// getDashboard renders the viewer-scoped repo list at "/": public repos to
// anyone, private repos to a viewer holding an ACL, all repos to an admin. It is
// the unauthenticated landing — a signed-out visitor sees public repos and a
// sign-in link, never a 403 wall.
func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
h.renderDashboard(w, r, "", "")
}
func (h *Handler) renderDashboard(w http.ResponseWriter, r *http.Request, flash, errMsg string) {
u := h.viewer(r)
var (
repos []meta.Repo
err error
)
if u != nil && u.IsAdmin {
repos, err = h.md.ListRepos(r.Context())
} else {
repos, err = h.md.ListReposForViewer(r.Context(), userID(u))
}
if err != nil {
h.serverError(w, "list repos", err)
return
}
h.render(w, "dashboard.html", dashboardData{
base: h.baseFor(u, flash, errMsg),
Repos: repos,
})
}
func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
branch := strings.TrimSpace(r.FormValue("default_branch"))
if !nameSegment.MatchString(name) {
h.renderDashboard(w, r, "", "name must be alphanumeric (._- allowed, not leading)")
return
}
// Flat namespace: the repo is a single path segment. CreateRepo enforces the
// reserved-name and structural rules; its error is surfaced below.
repo, err := h.md.CreateRepo(r.Context(), name, branch)
if err != nil {
h.renderDashboard(w, r, "", "create repo: "+err.Error())
return
}
// Grant the creating admin admin on the new repo so it is immediately usable
// (clone/push) without a separate ACL step. There is no separate "owner" — on
// a personal instance the admin implicitly owns repos and ACLs grant everyone
// else (docs/architecture/urls.md). requireAdmin guarantees a user in context.
creator := userFrom(r.Context())
if err := h.md.SetACL(r.Context(), repo.ID, creator.ID, meta.PermAdmin); err != nil {
h.serverError(w, "grant creator acl", err)
return
}
h.renderDashboard(w, r, "created "+name, "")
}
// --- per-repo settings ---
func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) {
h.renderSettings(w, r, r.PathValue("repo"), "", "")
}
func (h *Handler) renderSettings(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
h.render(w, "settings.html", settingsData{
base: h.base(r, flash, errMsg),
Repo: *rp,
})
}
// postSettings applies whichever settings the form carries: a non-empty
// default_branch and/or a visibility value. A form with neither is a no-op.
func (h *Handler) postSettings(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
if v := strings.TrimSpace(r.FormValue("visibility")); v != "" {
if v != meta.VisibilityPrivate && v != meta.VisibilityPublic {
h.renderSettings(w, r, name, "", "visibility must be private or public")
return
}
if err := h.md.SetVisibility(r.Context(), rp.ID, v); err != nil {
h.serverError(w, "set visibility", err)
return
}
h.renderSettings(w, r, name, "visibility set to "+v, "")
return
}
branch := strings.TrimSpace(r.FormValue("default_branch"))
if branch == "" {
h.renderSettings(w, r, name, "", "default branch cannot be empty")
return
}
if err := h.md.SetDefaultBranch(r.Context(), rp.ID, branch); err != nil {
h.serverError(w, "set default branch", err)
return
}
h.renderSettings(w, r, name, "default branch set to "+branch, "")
}
// --- users ---
func (h *Handler) getUsers(w http.ResponseWriter, r *http.Request) {
h.renderUsers(w, r, "", "")
}
func (h *Handler) renderUsers(w http.ResponseWriter, r *http.Request, flash, errMsg string) {
users, err := h.md.ListUsers(r.Context())
if err != nil {
h.serverError(w, "list users", err)
return
}
h.render(w, "users.html", usersData{
base: h.base(r, flash, errMsg),
Users: users,
})
}
func (h *Handler) postUser(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.FormValue("handle"))
if !nameSegment.MatchString(handle) {
h.renderUsers(w, r, "", "handle must be alphanumeric (._- allowed, not leading)")
return
}
admin := r.FormValue("is_admin") != ""
var err error
if admin {
_, err = h.md.CreateAdmin(r.Context(), handle)
} else {
_, err = h.md.CreateUser(r.Context(), handle)
}
if err != nil {
h.renderUsers(w, r, "", "create user: "+err.Error())
return
}
h.renderUsers(w, r, "created user "+handle, "")
}
// --- tokens ---
func (h *Handler) getTokens(w http.ResponseWriter, r *http.Request) {
h.renderTokens(w, r, "", "", "")
}
// renderTokens lists tokens for the selected user (query/form "user"). newRaw,
// when set, is the just-minted token shown exactly once.
func (h *Handler) renderTokens(w http.ResponseWriter, r *http.Request, selected, newRaw, errMsg string) {
if selected == "" {
selected = strings.TrimSpace(r.FormValue("user"))
}
users, err := h.md.ListUsers(r.Context())
if err != nil {
h.serverError(w, "list users", err)
return
}
data := tokensData{
base: h.base(r, "", errMsg),
Users: users,
Selected: selected,
NewToken: newRaw,
}
if selected != "" {
u, err := h.md.GetUser(r.Context(), selected)
if errors.Is(err, meta.ErrNotFound) {
data.Err = "no such user: " + selected
h.render(w, "tokens.html", data)
return
}
if err != nil {
h.serverError(w, "get user", err)
return
}
toks, err := h.md.ListTokens(r.Context(), u.ID)
if err != nil {
h.serverError(w, "list tokens", err)
return
}
data.Tokens = toks
}
h.render(w, "tokens.html", data)
}
func (h *Handler) postToken(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.FormValue("user"))
label := strings.TrimSpace(r.FormValue("label"))
u, err := h.md.GetUser(r.Context(), handle)
if errors.Is(err, meta.ErrNotFound) {
h.renderTokens(w, r, handle, "", "no such user: "+handle)
return
}
if err != nil {
h.serverError(w, "get user", err)
return
}
raw, selector, verifier, err := auth.Mint()
if err != nil {
h.serverError(w, "mint token", err)
return
}
if _, err := h.md.CreateToken(r.Context(), u.ID, selector, verifier, label); err != nil {
h.serverError(w, "store token", err)
return
}
h.renderTokens(w, r, handle, raw, "")
}
func (h *Handler) postRevokeToken(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.FormValue("user"))
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
h.renderTokens(w, r, handle, "", "bad token id")
return
}
if err := h.md.DeleteToken(r.Context(), id); err != nil {
h.serverError(w, "revoke token", err)
return
}
h.renderTokens(w, r, handle, "", "")
}
// --- per-repo access (ACLs) ---
func (h *Handler) getAccess(w http.ResponseWriter, r *http.Request) {
h.renderAccess(w, r, r.PathValue("repo"), "", "")
}
func (h *Handler) renderAccess(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
acls, err := h.md.ListACLs(r.Context(), rp.ID)
if err != nil {
h.serverError(w, "list acls", err)
return
}
h.render(w, "access.html", accessData{
base: h.base(r, flash, errMsg),
Repo: rp.Name,
ACLs: acls,
})
}
func (h *Handler) postAccess(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
handle := strings.TrimSpace(r.FormValue("handle"))
perm := meta.Perm(strings.TrimSpace(r.FormValue("perm")))
if perm != meta.PermRead && perm != meta.PermWrite && perm != meta.PermAdmin {
h.renderAccess(w, r, name, "", "permission must be read, write, or admin")
return
}
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
u, err := h.md.GetUser(r.Context(), handle)
if errors.Is(err, meta.ErrNotFound) {
h.renderAccess(w, r, name, "", "no such user: "+handle)
return
}
if err != nil {
h.serverError(w, "get user", err)
return
}
if err := h.md.SetACL(r.Context(), rp.ID, u.ID, perm); err != nil {
h.serverError(w, "set acl", err)
return
}
h.renderAccess(w, r, name, "granted "+string(perm)+" to "+handle, "")
}
func (h *Handler) postRevokeAccess(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
uid, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64)
if err != nil {
h.renderAccess(w, r, name, "", "bad user id")
return
}
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
if err := h.md.DeleteACL(r.Context(), rp.ID, uid); err != nil {
h.serverError(w, "revoke acl", err)
return
}
h.renderAccess(w, r, name, "revoked", "")
}
// --- per-repo ci secrets ---
func (h *Handler) getSecrets(w http.ResponseWriter, r *http.Request) {
h.renderSecrets(w, r, r.PathValue("repo"), "", "")
}
func (h *Handler) renderSecrets(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
data := secretsData{
base: h.base(r, flash, errMsg),
Repo: rp.Name,
Enabled: h.secrets != nil && h.secrets.Enabled(),
}
if data.Enabled {
names, err := h.secrets.ListSecretNames(r.Context(), rp.ID)
if err != nil {
h.serverError(w, "list secret names", err)
return
}
data.Names = names
}
h.render(w, "secrets.html", data)
}
func (h *Handler) postSecret(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
secretName := strings.TrimSpace(r.FormValue("name"))
value := r.FormValue("value") // not trimmed: a secret may have meaningful edges
if h.secrets == nil || !h.secrets.Enabled() {
h.renderSecrets(w, r, name, "", "secrets are disabled: set GITMOTE_CI_SECRET_KEY_V1")
return
}
if value == "" {
h.renderSecrets(w, r, name, "", "value cannot be empty")
return
}
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
if err := h.secrets.SetSecret(r.Context(), rp.ID, secretName, value); err != nil {
// The error names the (public) secret name at worst, never the value.
h.renderSecrets(w, r, name, "", "set secret: "+err.Error())
return
}
h.renderSecrets(w, r, name, "saved secret "+secretName, "")
}
func (h *Handler) postDeleteSecret(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
secretName := strings.TrimSpace(r.FormValue("name"))
rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
if err := h.secrets.DeleteSecret(r.Context(), rp.ID, secretName); err != nil {
h.serverError(w, "delete secret", err)
return
}
h.renderSecrets(w, r, name, "deleted secret "+secretName, "")
}
// --- rendering ---
// viewer resolves the optional signed-in user for a request: the admin injected
// by requireAdmin if present, else the session cookie's user, else nil
// (anonymous). Best-effort — a missing/expired/invalid cookie is anonymous.
func (h *Handler) viewer(r *http.Request) *meta.User {
if u := userFrom(r.Context()); u != nil {
return u
}
if id, ok := h.sess.verify(r, h.now()); ok {
if u, err := h.md.GetUserByID(r.Context(), id); err == nil {
return u
}
}
return nil
}
// userID is the user's id, or 0 for a nil (anonymous) viewer.
func userID(u *meta.User) int64 {
if u != nil {
return u.ID
}
return 0
}
func (h *Handler) base(r *http.Request, flash, errMsg string) base {
return h.baseFor(h.viewer(r), flash, errMsg)
}
// baseFor builds the shared page header from an already-resolved viewer, so a
// handler that looked the viewer up (the dashboard) needn't do it twice.
func (h *Handler) baseFor(u *meta.User, flash, errMsg string) base {
b := base{Flash: flash, Err: errMsg}
if u != nil {
b.Me = u.Handle
b.IsAdmin = u.IsAdmin
}
return b
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
h.log.Error("render template", "template", name, "error", err)
}
}
func (h *Handler) serverError(w http.ResponseWriter, msg string, err error) {
h.log.Error(msg, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}