package handler import ( "encoding/json" "errors" "fmt" "html" "net/http" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/auth" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" ) // TokensPathPrefix is the URL of the user-facing self-service token // management page. Browser GET returns an HTML page; the page fetches // the JSON API at TokensAPIPathPrefix. const TokensPathPrefix = "/.tokens" // TokensAPIPathPrefix is the URL prefix for the token JSON API. // // GET /.api/tokens → list current user's tokens (metadata only) // POST /.api/tokens → create a new token; returns plaintext once // DELETE /.api/tokens/ → revoke a token by short ID or full hash const TokensAPIPathPrefix = "/.api/tokens" // MaxTokenDescription caps how much free-form text a user can attach // to a token. Keeps the on-disk YAML small and the HTML rendering // simple. const MaxTokenDescription = 200 // MaxTokensPerUser is a soft cap; refusing to create a new token at // the limit is enough to prevent runaway accumulation. Operators who // need more can revoke first. const MaxTokensPerUser = 50 // tokenAPIView is the JSON shape returned to the management UI. Never // includes the plaintext token (which is only returned on creation). type tokenAPIView struct { ID string `json:"id"` Email string `json:"email"` Created time.Time `json:"created"` Expires time.Time `json:"expires,omitempty"` Description string `json:"description,omitempty"` } // tokenCreateRequest is the body of POST /.api/tokens. type tokenCreateRequest struct { Description string `json:"description,omitempty"` Expires time.Time `json:"expires,omitempty"` } // tokenCreateResponse is the body of a successful POST /.api/tokens. // The plaintext Token is returned exactly once and is never derivable // from the persisted file again. type tokenCreateResponse struct { Token string `json:"token"` ID string `json:"id"` Email string `json:"email"` Created time.Time `json:"created"` Expires time.Time `json:"expires,omitempty"` Description string `json:"description,omitempty"` } // ServeTokensAPI dispatches requests to the JSON token API. Anonymous // requests are 401; missing token store yields 503 (token issuance is // disabled when storage isn't reachable). func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if store == nil { http.Error(w, "Token store unavailable", http.StatusServiceUnavailable) return } rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix) switch { case rest == "" || rest == "/": switch r.Method { case http.MethodGet: handleTokensList(store, email, w) case http.MethodPost: handleTokensCreate(store, email, w, r) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(rest, "/"): id := strings.TrimPrefix(rest, "/") if id == "" || strings.Contains(id, "/") { http.NotFound(w, r) return } switch r.Method { case http.MethodDelete: handleTokensRevoke(store, email, id, w) default: w.Header().Set("Allow", "DELETE") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } default: http.NotFound(w, r) } } func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) { list, err := store.List(email) if err != nil { http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError) return } views := make([]tokenAPIView, 0, len(list)) for _, t := range list { views = append(views, tokenAPIView{ ID: t.ID(), Email: t.Email, Created: t.Created, Expires: t.Expires, Description: t.Description, }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(views) } func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var req tokenCreateRequest if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest) return } } desc := strings.TrimSpace(req.Description) if len(desc) > MaxTokenDescription { http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest) return } if !req.Expires.IsZero() && req.Expires.Before(time.Now()) { http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest) return } // Soft cap to prevent runaway accumulation. existing, err := store.List(email) if err == nil && len(existing) >= MaxTokensPerUser { http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict) return } plaintext, tok, err := store.Generate(email, desc, req.Expires) if err != nil { http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(tokenCreateResponse{ Token: plaintext, ID: tok.ID(), Email: tok.Email, Created: tok.Created, Expires: tok.Expires, Description: tok.Description, }) } func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) { err := store.Revoke(email, id) if err == nil { w.WriteHeader(http.StatusNoContent) return } if errors.Is(err, auth.ErrNotFound) { http.NotFound(w, nil) return } http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError) } // ServeTokensPage renders the self-service token management HTML page. // The page fetches /.api/tokens via JavaScript; the server-side render // only emits a static skeleton plus the authenticated user's email so // the page can show "you are signed in as alice@..." without a flash. func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } email := EmailFromContext(r) if email == "" { http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if r.Method == http.MethodHead { return } storeAvailable := store != nil body := renderTokensPage(email, storeAvailable) _, _ = w.Write([]byte(body)) } // renderTokensPage builds the HTML for the management page. Kept inline // (no template files) to match the rest of the handler package; the // page is small enough that string-concat is readable and there are no // untrusted-data injection concerns beyond the `email` parameter, // which is escaped via html.EscapeString. func renderTokensPage(email string, storeAvailable bool) string { storeNote := "" if !storeAvailable { storeNote = `

Token store is unavailable — the server failed to initialise .zddc.d/tokens/. Operations on this page will return 503.

` } const css = ` body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; } h1 { font-size: 1.4rem; margin-bottom: 0.2em; } .who { color: #666; margin-bottom: 1.5em; } fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; } legend { font-weight: 600; padding: 0 0.4em; } label { display: block; margin: 0.5em 0; } label span { display: inline-block; min-width: 9em; } input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; } button { padding: 0.4em 0.9em; cursor: pointer; } .danger { color: #b00; border-color: #b00; } .warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; } table { width: 100%; border-collapse: collapse; margin-top: 0.5em; } th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; } .token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; } .empty { color: #888; font-style: italic; } ` const js = ` const api = "/.api/tokens"; async function refresh() { const tbody = document.querySelector("#tokens tbody"); tbody.innerHTML = ""; const r = await fetch(api, { headers: {"Accept": "application/json"} }); if (!r.ok) { tbody.innerHTML = 'Could not load tokens (' + r.status + ')'; return; } const list = await r.json(); if (!list.length) { tbody.innerHTML = 'No tokens issued yet.'; return; } for (const t of list) { const tr = document.createElement("tr"); const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)"; const created = new Date(t.created).toLocaleString(); tr.innerHTML = '' + escapeHTML(t.id) + '' + '' + escapeHTML(t.description || "") + '' + '' + escapeHTML(created) + '' + '' + escapeHTML(expires) + '' + ''; tbody.appendChild(tr); } tbody.querySelectorAll("button.danger").forEach(b => { b.addEventListener("click", () => revoke(b.dataset.id)); }); } function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); } async function revoke(id) { if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return; const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" }); if (!r.ok) { alert("Revoke failed: " + r.status + " " + r.statusText); return; } refresh(); } document.getElementById("create").addEventListener("submit", async (ev) => { ev.preventDefault(); const desc = document.getElementById("desc").value.trim(); const expRaw = document.getElementById("expires").value; const body = {}; if (desc) body.description = desc; if (expRaw) body.expires = new Date(expRaw).toISOString(); const r = await fetch(api, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body), }); const out = document.getElementById("created"); if (!r.ok) { out.innerHTML = '

Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '

'; return; } const data = await r.json(); out.innerHTML = '

New token (copy now — it is shown only once):

' + escapeHTML(data.token) + '
'; document.getElementById("desc").value = ""; document.getElementById("expires").value = ""; refresh(); }); refresh(); ` return ` ZDDC tokens

API tokens

Signed in as ` + html.EscapeString(email) + `

` + storeNote + `
Create a new token
Existing tokens
IDDescriptionCreatedExpires
` }