Files
verdande/internal/httpapi/middleware.go
T
Kristian WindandClaude Opus 5 c2e2d66e76
Release / Build and publish image (push) Failing after 12m56s
feat(admin): keep server errors where a restart cannot take them
The panel's watcher reported four 5xx today — twice from one address at
11:49, twice from another at 10:45 — and there is no way to find out what
any of them were. verdande logs the real cause of every 500, but on a Rune
the log belongs to the container and the container is replaced on every
restart. So an operator is told that something broke and given nothing to
act on, which is the worst of both: alarming and useless.

Every 500 now writes a row: what the handler was attempting in its own
words, the error it hit, the method and path, who was asking, and the
request id that ties it back to the log line for whoever still has it. The
"what" is the part a status code cannot give — "list projects" and "move
task" are the difference between a fault you can find and one you can only
count.

Written best-effort and never checked. This is the path that is already
reporting a failure, and a diagnostic that can itself fail the request
would turn one broken screen into two.

Threading the request through `internal()` and `storeError()` touched 108
call sites across 19 files, which is the price of not having had it there
from the start. It is mechanical and the compiler checked all of it.

Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.

Read at Indstillinger → Fejl, administrators only — it carries paths,
account names and the server's own error strings. The empty state says
"Ingen fejl registreret. Det er den rigtige tilstand", because a blank
panel should not read as a broken one.

The test drops a table and asks for the endpoint that reads it: the same
code path a disk error takes, and the row that comes back has to name the
operation, the account and the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:58:02 +02:00

256 lines
8.5 KiB
Go

package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
type ctxKey int
const (
ctxUser ctxKey = iota
ctxSession
)
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
// enforced by the browser: it refuses the cookie unless it is Secure, path=/ and
// has no Domain — which means a subdomain cannot set a cookie that overwrites this
// one. Over plain HTTP the prefix is invalid, so a local dev instance uses the
// bare name.
const (
sessionCookieSecure = "__Host-verdande_session"
sessionCookieInsecure = "verdande_session"
)
func (s *Server) cookieName() string {
if s.secureCookies() {
return sessionCookieSecure
}
return sessionCookieInsecure
}
func (s *Server) secureCookies() bool {
return strings.HasPrefix(s.cfg.BaseURL, "https://")
}
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName(),
Value: token,
Path: "/",
// httpOnly: the frontend never reads this, so a cross-site script that
// gets to run cannot exfiltrate the session.
HttpOnly: true,
Secure: s.secureCookies(),
// Lax rather than Strict: Strict would mean following an invite link from
// an email lands you logged out, which looks exactly like a broken invite.
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName(),
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.secureCookies(),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// requireAuth rejects anything without a complete login.
//
// A session still waiting on its TOTP code is refused here with totp_required, so
// the two-step login cannot be skipped by simply calling the endpoint you wanted.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, session, err := s.authenticate(r)
if err != nil {
// A database that could not be read is not a person who is not signed
// in. Answering 401 for both means an infrastructure problem shows up
// as everybody being logged out at once — the frontend clears its state
// and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, r, "authenticate", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
return
}
if session != nil && session.PendingTOTP {
writeError(w, http.StatusUnauthorized, CodeTOTPRequired,
"this login still needs its verification code")
return
}
ctx := context.WithValue(r.Context(), ctxUser, user)
if session != nil {
ctx = context.WithValue(ctx, ctxSession, session)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requirePendingSession guards the second step of a login, and nothing else.
func (s *Server) requirePendingSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, session, err := s.authenticate(r)
if err != nil || session == nil || !session.PendingTOTP {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "no login is in progress")
return
}
ctx := context.WithValue(r.Context(), ctxUser, user)
ctx = context.WithValue(ctx, ctxSession, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u := userFrom(r.Context()); u == nil || !u.IsAdmin {
writeError(w, http.StatusForbidden, CodeForbidden, "administrator access is required")
return
}
next.ServeHTTP(w, r)
})
}
// authenticate resolves a request to a user, by session cookie or by API token.
//
// Both are accepted everywhere. An API token belongs to a person and carries their
// permissions exactly — there is no separate service identity to reason about, and
// a script therefore cannot reach anything its owner could not.
// errNoCredentials means the request carried nothing to authenticate with, as
// opposed to carrying something that turned out to be wrong — or to the lookup
// itself failing, which callers must not treat as a failed login.
var errNoCredentials = errors.New("no credentials")
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
user, err := s.db.UserByAPIToken(r.Context(), token)
if err != nil {
return nil, nil, err
}
return user, nil, nil
}
cookie, err := r.Cookie(s.cookieName())
if err != nil || cookie.Value == "" {
return nil, nil, errNoCredentials
}
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
if err != nil {
return nil, nil, err
}
return user, session, nil
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
// withUser puts a user on the context. Used by the CalDAV handlers, which
// authenticate with Basic rather than through the session middleware.
func withUser(ctx context.Context, user *store.User) context.Context {
return context.WithValue(ctx, ctxUser, user)
}
func userFrom(ctx context.Context) *store.User {
u, _ := ctx.Value(ctxUser).(*store.User)
return u
}
func sessionFrom(ctx context.Context) *store.Session {
s, _ := ctx.Value(ctxSession).(*store.Session)
return s
}
// requireCSRF blocks cross-site form posts.
//
// SameSite=Lax already stops the browser sending the cookie on a cross-site POST,
// which covers current browsers. This is the second lock: a state-changing request
// must either be same-origin by its own headers or carry an API token, which a
// cross-site form cannot set. Checked here rather than with a token in every form,
// because a token the frontend has to remember to include is a token it will one
// day forget.
func (s *Server) requireCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// A bearer token cannot be attached by a cross-site form or image tag.
if bearerToken(r) != "" {
next.ServeHTTP(w, r)
return
}
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "same-site", "none":
next.ServeHTTP(w, r)
return
case "cross-site":
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
return
}
// Older clients send no Sec-Fetch-Site; fall back to comparing Origin.
if origin := r.Header.Get("Origin"); origin != "" && !s.isOwnOrigin(origin) {
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) isOwnOrigin(origin string) bool {
if strings.EqualFold(origin, s.cfg.BaseURL) {
return true
}
// A local dev frontend on another port talks to the API directly.
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
}
// requireProject checks the caller's standing in the {projectID} of the route
// before any handler under it runs.
//
// Doing this as middleware rather than as a first line in each handler is what
// makes it hard to forget: a new endpoint added under the route is guarded by
// virtue of where it sits, not by the author remembering to add a check.
func (s *Server) requireProject(min store.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
user := userFrom(r.Context())
if user == nil || projectID == "" {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, min); err != nil {
// 404 rather than 403: a 403 confirms the project exists, which
// is what somebody trying ids is hoping to learn.
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
next.ServeHTTP(w, r)
})
}
}