feat: add authentication, audit log, HTTP API and base web UI
Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque hashed session tokens with idle/absolute expiry, CSRF token plus Origin check on cookie sessions, per-IP token bucket and per-account exponential lockout, three roles enforced by middleware, personal API tokens for noemactl. Audit log writer used by every admin mutation. API: request ids, panic recovery, strict CSP and security headers, body limits, single JSON error envelope, health/ready/status, users, sessions, tokens, audit, token-protected /metrics. UI: embedded html/template pages (login, first-run setup, dashboard, system, users, account, audit, interpretation boundary), light/dark theme, dependency-free app.js with CSRF-aware fetch and SSE helper. Binaries: noemad with graceful shutdown, migrations at start, bootstrap admin from environment, supervised background tasks; noemactl with status, migrate, user create and meaningful exit codes.
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.PrincipalFrom(r.Context()); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if n, err := u.deps.Auth.CountUsers(r.Context()); err == nil && n == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "login", "Sign in", map[string]any{"Next": safeNext(r.URL.Query().Get("next"))})
|
||||
}
|
||||
|
||||
func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.errorPage(w, r, http.StatusBadRequest, "Bad form.")
|
||||
return
|
||||
}
|
||||
ip := auth.ClientIP(r, false)
|
||||
sess, token, err := u.deps.Auth.Login(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
u.deps.Metrics.AuthFailures.Inc()
|
||||
msg := "Invalid username or password."
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrLocked):
|
||||
msg = "Account temporarily locked after repeated failures. Try again later."
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
msg = "Too many attempts from this address. Slow down."
|
||||
case errors.Is(err, auth.ErrDisabled):
|
||||
msg = "This account is disabled."
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "login.failed", "user", "", map[string]any{"username": r.PostFormValue("username"), "reason": err.Error()})
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
u.render(w, r, "login", "Sign in", map[string]any{"Error": msg, "Next": safeNext(r.PostFormValue("next"))})
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(w, token, u.deps.Config.Production(), int(u.deps.Config.SessionAbsoluteTTL.Seconds()))
|
||||
ctx := auth.WithPrincipal(r.Context(), auth.Principal{User: auth.User{ID: sess.UserID, Username: r.PostFormValue("username")}, SessionID: sess.ID})
|
||||
u.deps.Audit.Log(ctx, "login.success", "session", sess.ID.String(), nil)
|
||||
to := safeNext(r.PostFormValue("next"))
|
||||
if to == "" {
|
||||
to = "/"
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if p, ok := auth.PrincipalFrom(r.Context()); ok && !p.SessionID.IsZero() {
|
||||
u.deps.Auth.Logout(r.Context(), p.SessionID) //nolint:errcheck
|
||||
u.deps.Audit.Log(r.Context(), "logout", "session", p.SessionID.String(), nil)
|
||||
}
|
||||
auth.ClearSessionCookie(w, u.deps.Config.Production())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// setup is only reachable while no users exist; it is the browser equivalent
|
||||
// of the bootstrap environment variables.
|
||||
func (u *UI) setupForm(w http.ResponseWriter, r *http.Request) {
|
||||
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "setup", "Setup", nil)
|
||||
}
|
||||
|
||||
func (u *UI) setupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
r.ParseForm() //nolint:errcheck
|
||||
if r.PostFormValue("password") != r.PostFormValue("confirm") {
|
||||
u.render(w, r, "setup", "Setup", map[string]any{"Error": "Passwords do not match."})
|
||||
return
|
||||
}
|
||||
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.RoleAdministrator)
|
||||
if err != nil {
|
||||
u.render(w, r, "setup", "Setup", map[string]any{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.bootstrap", "user", user.ID.String(), map[string]any{"username": user.Username, "via": "setup page"})
|
||||
u.flash(w, r, "ok", "Administrator created. Sign in.", "/login")
|
||||
}
|
||||
|
||||
func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
entries, _ := u.deps.Audit.List(r.Context(), 0, 8, "", "")
|
||||
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": 0, "Status": u.statusF(r), "Audit": entries})
|
||||
}
|
||||
|
||||
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "system", "System", u.statusF(r))
|
||||
}
|
||||
|
||||
func (u *UI) audit(w http.ResponseWriter, r *http.Request) {
|
||||
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||
entries, err := u.deps.Audit.List(r.Context(), before, 100, r.URL.Query().Get("target_kind"), r.URL.Query().Get("target_id"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 500, "Could not load audit log.")
|
||||
return
|
||||
}
|
||||
var next int64
|
||||
if len(entries) == 100 {
|
||||
next = entries[len(entries)-1].Seq
|
||||
}
|
||||
u.render(w, r, "audit", "Audit", map[string]any{"Entries": entries, "Next": next})
|
||||
}
|
||||
|
||||
func (u *UI) account(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
sessions, _ := u.deps.Auth.ListSessions(r.Context(), p.User.ID)
|
||||
tokens, _ := u.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
|
||||
var newToken string
|
||||
if c, err := r.Cookie("noema_newtoken"); err == nil {
|
||||
newToken = c.Value
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true})
|
||||
}
|
||||
u.render(w, r, "account", "Account", map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken})
|
||||
}
|
||||
|
||||
func (u *UI) accountPassword(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
r.ParseForm() //nolint:errcheck
|
||||
if r.PostFormValue("password") != r.PostFormValue("confirm") {
|
||||
u.flash(w, r, "error", "New passwords do not match.", "/account")
|
||||
return
|
||||
}
|
||||
// Re-verify the current password before changing it.
|
||||
if _, _, err := u.deps.Auth.Login(r.Context(), p.User.Username, r.PostFormValue("current"), auth.ClientIP(r, false), "password-change"); err != nil {
|
||||
u.flash(w, r, "error", "Current password incorrect.", "/account")
|
||||
return
|
||||
}
|
||||
if err := u.deps.Auth.ChangePassword(r.Context(), p.User.ID, r.PostFormValue("password"), p.SessionID); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/account")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.password_change", "user", p.User.ID.String(), nil)
|
||||
u.flash(w, r, "ok", "Password changed. Other sessions were signed out.", "/account")
|
||||
}
|
||||
|
||||
func (u *UI) accountSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.Auth.RevokeSession(r.Context(), p.User.ID, id) != nil {
|
||||
u.flash(w, r, "error", "Session not found.", "/account")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Session revoked.", "/account")
|
||||
}
|
||||
|
||||
func (u *UI) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
r.ParseForm() //nolint:errcheck
|
||||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||
if name == "" {
|
||||
u.flash(w, r, "error", "Token name required.", "/account")
|
||||
return
|
||||
}
|
||||
tok, err := u.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, name, nil)
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Could not create token.", "/account")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": name})
|
||||
// Shown exactly once on the next page load, then the cookie is cleared.
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()})
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id) != nil {
|
||||
u.flash(w, r, "error", "Token not found.", "/account")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Token revoked.", "/account")
|
||||
}
|
||||
|
||||
func (u *UI) users(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := u.deps.Auth.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 500, "Could not load users.")
|
||||
return
|
||||
}
|
||||
u.render(w, r, "users", "Users", map[string]any{"Users": users})
|
||||
}
|
||||
|
||||
func (u *UI) usersCreate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.Role(r.PostFormValue("role")))
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
msg = "That username already exists."
|
||||
}
|
||||
u.flash(w, r, "error", msg, "/admin/users")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.create", "user", user.ID.String(), map[string]any{"username": user.Username, "role": user.Role})
|
||||
u.flash(w, r, "ok", "User created.", "/admin/users")
|
||||
}
|
||||
|
||||
func (u *UI) usersUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Invalid user.", "/admin/users")
|
||||
return
|
||||
}
|
||||
if id == p.User.ID {
|
||||
u.flash(w, r, "error", "You cannot change your own account here.", "/admin/users")
|
||||
return
|
||||
}
|
||||
r.ParseForm() //nolint:errcheck
|
||||
switch r.PostFormValue("action") {
|
||||
case "role":
|
||||
role := auth.Role(r.PostFormValue("role"))
|
||||
if err := u.deps.Auth.SetUserRole(r.Context(), id, role); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/admin/users")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": role})
|
||||
case "disable", "enable":
|
||||
disabled := r.PostFormValue("action") == "disable"
|
||||
if err := u.deps.Auth.SetUserDisabled(r.Context(), id, disabled); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/admin/users")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": disabled})
|
||||
default:
|
||||
u.flash(w, r, "error", "Unknown action.", "/admin/users")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "User updated.", "/admin/users")
|
||||
}
|
||||
|
||||
// safeNext only allows same-site relative paths for post-login redirects.
|
||||
func safeNext(s string) string {
|
||||
if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
|
||||
return ""
|
||||
}
|
||||
if _, err := url.Parse(s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package ui serves the server-rendered administration interface. Every page
|
||||
// is a Go handler plus an html/template; live updates reuse the same templates
|
||||
// through SSE fragments (ADR-0007).
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/app"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/version"
|
||||
"github.com/andydixon/noema/web"
|
||||
)
|
||||
|
||||
// UI holds handlers and the parsed template set.
|
||||
type UI struct {
|
||||
deps app.Deps
|
||||
pages map[string]*template.Template
|
||||
static fs.FS
|
||||
statusF func(r *http.Request) any // system status provider injected by api
|
||||
}
|
||||
|
||||
// View is the data passed to every template.
|
||||
type View struct {
|
||||
Title string
|
||||
Path string
|
||||
Principal *auth.Principal
|
||||
CSRF string
|
||||
Flash *Flash
|
||||
Version string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Flash is a one-shot message shown on the next page load.
|
||||
type Flash struct{ Kind, Message string }
|
||||
|
||||
const flashCookie = "noema_flash"
|
||||
|
||||
// New parses templates. In development, when ./web exists on disk, templates
|
||||
// and static files are read from disk so edits show without a rebuild.
|
||||
func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) {
|
||||
tmplFS, staticFS := web.Templates(), web.Static()
|
||||
if !deps.Config.Production() {
|
||||
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
|
||||
tmplFS, staticFS = os.DirFS("web/templates"), os.DirFS("web/static")
|
||||
deps.Log.Info("ui: serving templates and static files from ./web (development)")
|
||||
}
|
||||
}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: statusF}
|
||||
if err := u.parse(tmplFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (u *UI) parse(fsys fs.FS) error {
|
||||
funcs := template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"roles": auth.AllRoles,
|
||||
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
|
||||
"ago": ago,
|
||||
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
|
||||
"bytes": humanBytes,
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
|
||||
}
|
||||
base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ui: parse layout: %w", err)
|
||||
}
|
||||
pages, err := fs.Glob(fsys, "pages/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.pages = map[string]*template.Template{}
|
||||
for _, p := range pages {
|
||||
t, err := base.Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := t.ParseFS(fsys, p); err != nil {
|
||||
return fmt.Errorf("ui: parse %s: %w", p, err)
|
||||
}
|
||||
u.pages[strings.TrimPrefix(strings.TrimSuffix(p, ".html"), "pages/")] = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// render executes a page template. Rendering into a buffer first means a
|
||||
// template error yields a proper 500 instead of a half-written page.
|
||||
func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string, data any) {
|
||||
t, ok := u.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found: "+page, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
v := View{Title: title, Path: r.URL.Path, Version: version.Version, Data: data}
|
||||
if p, ok := auth.PrincipalFrom(r.Context()); ok {
|
||||
v.Principal = &p
|
||||
if tok, err := u.deps.Auth.CSRFToken(r.Context(), p.SessionID); err == nil {
|
||||
v.CSRF = tok
|
||||
}
|
||||
}
|
||||
if c, err := r.Cookie(flashCookie); err == nil && c.Value != "" {
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(c.Value); err == nil {
|
||||
if kind, msg, ok := strings.Cut(string(raw), "|"); ok {
|
||||
v.Flash = &Flash{Kind: kind, Message: msg}
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "layout", v); err != nil {
|
||||
u.deps.Log.Error("template execution failed", "page", page, "err", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
buf.WriteTo(w) //nolint:errcheck
|
||||
}
|
||||
|
||||
// flash stores a one-shot message and redirects.
|
||||
func (u *UI) flash(w http.ResponseWriter, r *http.Request, kind, msg, to string) {
|
||||
val := base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + msg))
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()})
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) errorPage(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
w.WriteHeader(status)
|
||||
u.render(w, r, "error", http.StatusText(status), map[string]any{"Status": status, "Message": msg})
|
||||
}
|
||||
|
||||
// uiAuthFail redirects anonymous users to login and shows 403 to others.
|
||||
func (u *UI) uiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) {
|
||||
if authenticated {
|
||||
u.errorPage(w, r, http.StatusForbidden, "Your role does not permit this page.")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if r.Method == http.MethodGet && r.URL.Path != "/" {
|
||||
next = "?next=" + url.QueryEscape(r.URL.RequestURI())
|
||||
}
|
||||
http.Redirect(w, r, "/login"+next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) require(min auth.Role, h http.HandlerFunc) http.Handler {
|
||||
return auth.RequireRole(min, u.uiAuthFail)(h)
|
||||
}
|
||||
|
||||
// Register mounts all UI routes.
|
||||
func (u *UI) Register(m *http.ServeMux) {
|
||||
m.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(u.static))))
|
||||
m.HandleFunc("GET /login", u.loginForm)
|
||||
m.HandleFunc("POST /login", u.loginSubmit)
|
||||
m.HandleFunc("POST /logout", u.logout)
|
||||
m.HandleFunc("GET /setup", u.setupForm)
|
||||
m.HandleFunc("POST /setup", u.setupSubmit)
|
||||
m.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "about", "Interpretation boundary", nil) })
|
||||
|
||||
m.Handle("GET /{$}", u.require(auth.RoleObserver, u.dashboard))
|
||||
m.Handle("GET /system", u.require(auth.RoleObserver, u.system))
|
||||
m.Handle("GET /audit", u.require(auth.RoleOperator, u.audit))
|
||||
m.Handle("GET /account", u.require(auth.RoleObserver, u.account))
|
||||
m.Handle("POST /account/password", u.require(auth.RoleObserver, u.accountPassword))
|
||||
m.Handle("POST /account/sessions/{id}/revoke", u.require(auth.RoleObserver, u.accountSessionRevoke))
|
||||
m.Handle("POST /account/tokens", u.require(auth.RoleObserver, u.accountTokenCreate))
|
||||
m.Handle("POST /account/tokens/{id}/revoke", u.require(auth.RoleObserver, u.accountTokenRevoke))
|
||||
m.Handle("GET /admin/users", u.require(auth.RoleAdministrator, u.users))
|
||||
m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate))
|
||||
m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate))
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{
|
||||
{"/minds", "Minds", "Mind management arrives in phase 2."},
|
||||
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||
{"/llm", "LLM", "Provider configuration arrives in phase 7."},
|
||||
{"/prompts", "Prompts", "The prompt manager arrives in phase 7."},
|
||||
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
|
||||
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
|
||||
} {
|
||||
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
|
||||
}
|
||||
}
|
||||
|
||||
func cacheStatic(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func ago(t any) string {
|
||||
var tm time.Time
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
tm = v
|
||||
case *time.Time:
|
||||
if v == nil {
|
||||
return "never"
|
||||
}
|
||||
tm = *v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
d := time.Since(tm)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(n uint64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
Reference in New Issue
Block a user