Files
Moko Consulting d9bda6047f
Cascade Main -> Dev / Cascade main -> dev (push) Successful in 4s
Universal: Push Notifications / push-notify (push) Successful in 2s
Generic: Standards Compliance / Secret Scanning (push) Successful in 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 5s
Generic: Standards Compliance / Repository Structure Validation (push) Failing after 3s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 4s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 10s
Generic: Standards Compliance / Documentation Quality Check (push) Failing after 7s
Generic: Standards Compliance / README Completeness Check (push) Failing after 6s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 8s
Deploy (Prod) / Deploy to Prod (push) Failing after 48s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / Line Length Check (push) Successful in 6s
Generic: Standards Compliance / File Naming Standards (push) Successful in 3s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 1m9s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 4s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 45s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 46s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 3s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 3s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 3s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 43s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 43s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 7s
Generic: Standards Compliance / Enterprise Readiness Check (push) Successful in 39s
Generic: Standards Compliance / Repository Health Check (push) Successful in 38s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
feat: per-user audit attribution, login page, chart range selector, key search
- Audit entries now record the actor (session email or 'admin (basic)'); new
  audit.actor column with a safe ALTER migration for existing DBs
- Styled /login landing page with a 'Sign in with Google' button; adminAuth
  redirects unauthenticated browsers there
- Dashboard chart range selector (24h / 7d / 30d)
- Client-side search/filter box on the API keys list

Authored-by: Moko Consulting
Claude-Session: https://claude.ai/code/session_015UauDPfYC8S2mdUhoSK8zQ
Signed-off-by: Jonathan Miller <jmiller@mokoconsulting.tech>
2026-07-21 21:35:37 -05:00

289 lines
8.1 KiB
Go

// Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
// FILE INFORMATION
// REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoTranslate
// PATH: /source/httpapi/admin.go
// BRIEF: Admin GUI handlers — dashboard (totals + recent activity) and API-key
// management (list, create, disable). Rendered server-side from embedded
// html/template files.
package httpapi
import (
"encoding/csv"
"fmt"
"net/http"
"strconv"
"time"
)
const dateLayout = "2006-01-02"
// parseExpiresDays returns now+days (UTC) for days>0, else the zero time (never).
func parseExpiresDays(v string) time.Time {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Now().UTC().Add(time.Duration(n) * 24 * time.Hour)
}
return time.Time{}
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
rng := r.URL.Query().Get("range")
interval, count := time.Hour, 24
switch rng {
case "7d":
interval, count = 6*time.Hour, 28
case "30d":
interval, count = 24*time.Hour, 30
default:
rng = "24h"
}
keys, requests, chars := s.store.Totals()
series := s.store.UsageSeries(interval, count)
var maxReq int64
for _, b := range series {
if b.Requests > maxReq {
maxReq = b.Requests
}
}
s.render(w, "dashboard", map[string]any{
"Title": "Dashboard",
"Keys": keys,
"Requests": requests,
"Chars": chars,
"Events": s.store.RecentEvents(50),
"KeyList": s.store.ListKeys(),
"Upstream": s.cfg.LibreTranslateURL,
"Public": s.cfg.PublicBaseURL,
"EngineUp": s.client.Healthy(),
"Series": series,
"MaxReq": maxReq,
"Range": rng,
})
}
func (s *Server) handleKeysList(w http.ResponseWriter, _ *http.Request) {
s.render(w, "keys", map[string]any{
"Title": "API Keys",
"KeyList": s.store.ListKeys(),
"Default": s.cfg.DefaultRatePerMin,
})
}
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
label := r.FormValue("label")
if label == "" {
label = "unnamed"
}
rate := s.cfg.DefaultRatePerMin
if v := r.FormValue("rate"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
rate = n
}
}
plaintext, k, err := s.store.CreateKey(label, rate, parseExpiresDays(r.FormValue("expires_days")))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.store.RecordAudit("key.create", s.currentUser(r), fmt.Sprintf("key #%d (%s)", k.ID, k.Label))
// Render the list with the one-time plaintext banner. This is the only time
// the full key is ever shown.
s.render(w, "keys", map[string]any{
"Title": "API Keys",
"KeyList": s.store.ListKeys(),
"Default": s.cfg.DefaultRatePerMin,
"NewKey": plaintext,
"NewKeyLabel": label,
})
}
// keyID parses the {id} path value, writing an error response on failure.
func keyID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return 0, false
}
return id, true
}
func (s *Server) handleKeyDetail(w http.ResponseWriter, r *http.Request) {
id, ok := keyID(w, r)
if !ok {
return
}
k, found := s.store.GetKey(id)
if !found {
http.Error(w, "key not found", http.StatusNotFound)
return
}
series := s.store.KeyUsageSeries(id, time.Hour, 24)
var maxReq int64
for _, b := range series {
if b.Requests > maxReq {
maxReq = b.Requests
}
}
s.render(w, "key_detail", map[string]any{
"Title": "Key: " + k.Label,
"Key": k,
"Events": s.store.KeyEvents(id, 100),
"Series": series,
"MaxReq": maxReq,
})
}
func (s *Server) handleKeyEdit(w http.ResponseWriter, r *http.Request) {
id, ok := keyID(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
rate := -1
if v := r.FormValue("rate"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
rate = n
}
}
// Expiry: a YYYY-MM-DD date field; empty clears it to "never".
var expiresAt time.Time
if v := r.FormValue("expires"); v != "" {
if t, err := time.Parse(dateLayout, v); err == nil {
expiresAt = t.UTC()
}
}
if err := s.store.UpdateKey(id, r.FormValue("label"), rate, expiresAt); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
s.store.RecordAudit("key.edit", s.currentUser(r), fmt.Sprintf("key #%d", id))
http.Redirect(w, r, "/admin/keys/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
func (s *Server) handleKeyDisable(w http.ResponseWriter, r *http.Request) {
s.setKeyState(w, r, true)
}
func (s *Server) handleKeyEnable(w http.ResponseWriter, r *http.Request) {
s.setKeyState(w, r, false)
}
func (s *Server) setKeyState(w http.ResponseWriter, r *http.Request, disabled bool) {
id, ok := keyID(w, r)
if !ok {
return
}
if err := s.store.SetKeyDisabled(id, disabled); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
action := "key.enable"
if disabled {
action = "key.disable"
}
s.store.RecordAudit(action, s.currentUser(r), fmt.Sprintf("key #%d", id))
http.Redirect(w, r, "/admin/keys/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// handleStatus renders the config/status page: engine reachability, loaded
// languages, and effective settings.
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
up := s.client.Healthy()
langCount := 0
if up {
if langs, err := s.client.Languages(); err == nil {
langCount = len(langs)
}
}
nKeys, reqs, chars := s.store.Totals()
s.render(w, "status", map[string]any{
"Title": "Status",
"Upstream": s.cfg.LibreTranslateURL,
"EngineUp": up,
"Languages": langCount,
"Public": s.cfg.PublicBaseURL,
"RequireKey": s.cfg.RequireKey,
"DefaultRate": s.cfg.DefaultRatePerMin,
"StorePath": s.cfg.StorePath,
"ListenAddr": s.cfg.ListenAddr,
"KeyCount": nKeys,
"Requests": reqs,
"Chars": chars,
})
}
// handleExportKeys streams all keys as CSV.
func (s *Server) handleExportKeys(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="mokotranslate-keys.csv"`)
cw := csv.NewWriter(w)
_ = cw.Write([]string{"id", "label", "state", "rate_per_min", "requests", "chars", "created", "last_used", "expires_at"})
for _, k := range s.store.ListKeys() {
state := "active"
if k.Disabled {
state = "disabled"
} else if k.Expired() {
state = "expired"
}
_ = cw.Write([]string{
strconv.FormatInt(k.ID, 10), k.Label, state,
strconv.Itoa(k.RateLimitPerMin),
strconv.FormatInt(k.Requests, 10), strconv.FormatInt(k.Chars, 10),
k.Created.Format(time.RFC3339), zeroableTime(k.LastUsed), zeroableTime(k.ExpiresAt),
})
}
cw.Flush()
}
// handleKeyExport streams one key's recent events as CSV.
func (s *Server) handleKeyExport(w http.ResponseWriter, r *http.Request) {
id, ok := keyID(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="mokotranslate-key-`+strconv.FormatInt(id, 10)+`-events.csv"`)
cw := csv.NewWriter(w)
_ = cw.Write([]string{"time", "endpoint", "chars", "status"})
for _, e := range s.store.KeyEvents(id, 10000) {
_ = cw.Write([]string{e.Time.Format(time.RFC3339), e.Endpoint, strconv.Itoa(e.Chars), strconv.Itoa(e.Status)})
}
cw.Flush()
}
func zeroableTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// handleAudit renders the admin audit log.
func (s *Server) handleAudit(w http.ResponseWriter, _ *http.Request) {
s.render(w, "audit", map[string]any{
"Title": "Audit Log",
"Entries": s.store.AuditLog(200),
})
}
// handleLanguages lists the engine's supported languages.
func (s *Server) handleLanguages(w http.ResponseWriter, _ *http.Request) {
data := map[string]any{"Title": "Languages"}
if langs, err := s.client.Languages(); err != nil {
data["EngineError"] = err.Error()
} else {
data["Languages"] = langs
}
s.render(w, "languages", data)
}