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 } // Render the token list through the shared tables engine (chrome + // declarative columns) with a server-injected collection, instead of a // bespoke chrome-less page. Create + revoke are driven by the generic // apiActions layer against the existing /.api/tokens endpoints (the // tables file-save path is untouched). Falls back to the legacy // skeleton if the store or the tables renderer isn't available. tablesHTML := EmbeddedTablesHTML() if store == nil || len(tablesHTML) == 0 { _, _ = w.Write([]byte(renderTokensPage(email, store != nil))) return } injected, err := injectTableContextObj(tablesHTML, buildTokensTableContext(store, email)) if err != nil { _, _ = w.Write([]byte(renderTokensPage(email, true))) return } _, _ = w.Write(injected) } // buildTokensTableContext assembles the pre-rendered #table-context for the // token page: the user's tokens as read-only rows + the apiActions config that // wires create/revoke to /.api/tokens (create surfaces the one-time secret). func buildTokensTableContext(store *auth.Store, email string) map[string]interface{} { rows := []map[string]interface{}{} if list, err := store.List(email); err == nil { for _, t := range list { exp := "never" if !t.Expires.IsZero() { exp = t.Expires.Format("2006-01-02") } rows = append(rows, map[string]interface{}{ "url": t.ID(), "editable": false, "data": map[string]interface{}{ "description": t.Description, "created": t.Created.Format("2006-01-02 15:04"), "expires": exp, "id": t.ID(), }, }) } } col := func(field, title, width string) map[string]interface{} { c := map[string]interface{}{"field": field, "title": title} if width != "" { c["width"] = width } return c } return map[string]interface{}{ "title": "API tokens", "description": "Bearer tokens for CLI / scripted access as " + email + ". A token's secret is shown once, at creation.", "addable": false, "columns": []map[string]interface{}{ col("description", "Description", ""), col("created", "Created", "12em"), col("expires", "Expires", "9em"), col("id", "ID", "16em"), }, "rows": rows, "apiActions": map[string]interface{}{ "create": map[string]interface{}{ "url": TokensAPIPathPrefix, "title": "New token", "secretField": "token", "secretLabel": "New token — copy it now, it is shown only once:", "fields": []map[string]interface{}{ {"name": "description", "label": "Description", "placeholder": "e.g. Field laptop"}, {"name": "expires", "label": "Expires (optional)", "type": "date"}, }, }, "deleteRow": map[string]interface{}{ "urlTemplate": TokensAPIPathPrefix + "/{id}", "label": "Revoke", "confirm": "Revoke this token? Any client using it will stop working.", }, }, } } // 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
` }