Files
verdande/internal/httpapi/tokens_handlers.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

144 lines
4.5 KiB
Go

package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Personal API tokens: what a script, a CalDAV client or an MCP connector
// authenticates with. The store has held them since the first migration; this is
// the surface that lets somebody actually make one.
//
// Every route here needs a *session*, not merely an authenticated caller — see
// requireSession. Managing credentials is something a person does in a browser.
type apiTokenJSON struct {
ID string `json:"id"`
Name string `json:"name"`
// Prefix is the first few characters, which is all that is ever shown again.
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// createdTokenJSON is the only response that carries the token itself, and only
// the once. The store keeps a hash, so there is nothing to reveal later even if
// the interface offered to.
type createdTokenJSON struct {
apiTokenJSON
Token string `json:"token"`
}
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list api tokens", err)
return
}
out := make([]apiTokenJSON, 0, len(tokens))
for _, t := range tokens {
j := apiTokenJSON{
ID: t.ID, Name: t.Name, Prefix: t.Prefix,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
if t.LastUsedAt != nil {
j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339)
}
if t.ExpiresAt != nil {
j.ExpiresAt = t.ExpiresAt.Format(time.RFC3339)
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
type createTokenRequest struct {
Name string `json:"name"`
// ExpiresInDays is optional; 0 means the token does not expire, which is what
// a calendar client subscribed for years actually needs.
ExpiresInDays int `json:"expires_in_days"`
}
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
if len([]rune(name)) > 80 {
writeFieldErrors(w, map[string]string{"name": "must be at most 80 characters"})
return
}
// A negative lifetime would mint a token that is already dead — accepted
// silently it looks like the feature is broken rather than the input.
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
writeFieldErrors(w, map[string]string{
"expires_in_days": "must be between 0 and 3650, where 0 means no expiry",
})
return
}
var expires *time.Time
if req.ExpiresInDays > 0 {
at := time.Now().UTC().AddDate(0, 0, req.ExpiresInDays)
expires = &at
}
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil {
s.internal(w, r, "create api token", err)
return
}
j := createdTokenJSON{
apiTokenJSON: apiTokenJSON{
ID: token.ID, Name: token.Name, Prefix: token.Prefix,
CreatedAt: token.CreatedAt.Format(time.RFC3339),
},
Token: plaintext,
}
if expires != nil {
j.ExpiresAt = expires.Format(time.RFC3339)
}
s.log.Info("api token created", "user", userFrom(r.Context()).ID, "name", name)
writeJSON(w, http.StatusCreated, j)
}
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil {
s.storeError(w, r, "delete api token", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// requireSession refuses a caller who authenticated with an API token.
//
// authenticate() accepts a bearer token everywhere else on purpose: a token
// carries its owner's permissions exactly. Minting tokens is the one thing that
// must not follow that rule, because it is how a single leaked token becomes
// permanent access — the thief issues a second one and revoking the first
// changes nothing. Deleting is held to the same standard so a token cannot
// revoke the session-holder's other credentials either.
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if sessionFrom(r.Context()) == nil {
writeError(w, http.StatusForbidden, CodeForbidden,
"API tokens are managed while signed in, not with an API token")
return
}
next.ServeHTTP(w, r)
})
}