455 lines
13 KiB
Go
455 lines
13 KiB
Go
// Package admin provides an owner-only web UI for managing the hold service.
|
|
// It includes OAuth-based authentication, crew management, settings configuration,
|
|
// and usage metrics. The admin panel is embedded directly in the hold service binary.
|
|
package admin
|
|
|
|
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'"
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/hold/gc"
|
|
"atcr.io/pkg/hold/pds"
|
|
"atcr.io/pkg/hold/quota"
|
|
|
|
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
//go:embed templates/*
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed public/*
|
|
var publicFS embed.FS
|
|
|
|
// AdminConfig holds admin panel configuration
|
|
type AdminConfig struct {
|
|
// Enabled controls whether the admin panel is accessible
|
|
Enabled bool
|
|
// PublicURL is the hold's public URL (for DID resolution, AppView communication)
|
|
PublicURL string
|
|
// ConfigPath is the path to the YAML config file (empty = env-only mode)
|
|
ConfigPath string
|
|
}
|
|
|
|
// DefaultAdminConfig returns sensible defaults
|
|
func DefaultAdminConfig() AdminConfig {
|
|
return AdminConfig{
|
|
Enabled: false,
|
|
}
|
|
}
|
|
|
|
// AdminSession represents an authenticated admin session
|
|
type AdminSession struct {
|
|
DID string
|
|
Handle string
|
|
}
|
|
|
|
// AdminUI manages the admin web interface
|
|
type AdminUI struct {
|
|
pds *pds.HoldPDS
|
|
quotaMgr *quota.Manager
|
|
gc *gc.GarbageCollector
|
|
clientApp *indigooauth.ClientApp
|
|
templates map[string]*template.Template
|
|
config AdminConfig
|
|
|
|
// In-memory session storage (single user, no persistence needed)
|
|
sessions map[string]*AdminSession
|
|
sessionsMu sync.RWMutex
|
|
}
|
|
|
|
// adminContextKey is used to store session data in request context
|
|
type adminContextKey struct{}
|
|
|
|
// NewAdminUI creates a new admin UI instance
|
|
func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manager, garbageCollector *gc.GarbageCollector, cfg AdminConfig) (*AdminUI, error) {
|
|
if !cfg.Enabled {
|
|
return nil, nil
|
|
}
|
|
|
|
// Validate required config
|
|
if cfg.PublicURL == "" {
|
|
return nil, fmt.Errorf("PublicURL is required for admin panel")
|
|
}
|
|
|
|
// Determine OAuth configuration based on URL type
|
|
u, err := url.Parse(cfg.PublicURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid PublicURL: %w", err)
|
|
}
|
|
|
|
// Use in-memory store for OAuth sessions
|
|
oauthStore := indigooauth.NewMemStore()
|
|
|
|
// Use minimal scopes for admin (only need basic auth, no blob access)
|
|
adminScopes := []string{"atproto"}
|
|
|
|
var oauthConfig indigooauth.ClientConfig
|
|
var redirectURI string
|
|
|
|
host := u.Hostname()
|
|
if isIPAddress(host) || host == "localhost" || host == "127.0.0.1" {
|
|
// Development mode: IP address or localhost - use localhost OAuth config
|
|
// Substitute 127.0.0.1 for Docker network IPs
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
oauthBaseURL := "http://127.0.0.1:" + port
|
|
redirectURI = oauthBaseURL + "/admin/auth/oauth/callback"
|
|
oauthConfig = indigooauth.NewLocalhostConfig(redirectURI, adminScopes)
|
|
|
|
slog.Info("Admin OAuth configured (localhost mode)",
|
|
"redirect_uri", redirectURI,
|
|
"public_url", cfg.PublicURL)
|
|
} else {
|
|
// Production mode: real domain - use public client with metadata endpoint
|
|
clientID := cfg.PublicURL + "/admin/oauth-client-metadata.json"
|
|
redirectURI = cfg.PublicURL + "/admin/auth/oauth/callback"
|
|
oauthConfig = indigooauth.NewPublicConfig(clientID, redirectURI, adminScopes)
|
|
|
|
slog.Info("Admin OAuth configured (production mode)",
|
|
"client_id", clientID,
|
|
"redirect_uri", redirectURI)
|
|
}
|
|
|
|
clientApp := indigooauth.NewClientApp(&oauthConfig, oauthStore)
|
|
clientApp.Dir = atproto.GetDirectory()
|
|
|
|
// Parse templates
|
|
templates, err := parseTemplates()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse templates: %w", err)
|
|
}
|
|
|
|
ui := &AdminUI{
|
|
pds: holdPDS,
|
|
quotaMgr: quotaMgr,
|
|
gc: garbageCollector,
|
|
clientApp: clientApp,
|
|
templates: templates,
|
|
config: cfg,
|
|
sessions: make(map[string]*AdminSession),
|
|
}
|
|
|
|
slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL)
|
|
|
|
return ui, nil
|
|
}
|
|
|
|
// Session management
|
|
|
|
func (ui *AdminUI) createSession(did, handle string) (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("failed to create session token: %w", err)
|
|
}
|
|
token := base64.URLEncoding.EncodeToString(b)
|
|
|
|
ui.sessionsMu.Lock()
|
|
ui.sessions[token] = &AdminSession{DID: did, Handle: handle}
|
|
ui.sessionsMu.Unlock()
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func (ui *AdminUI) getSession(token string) *AdminSession {
|
|
ui.sessionsMu.RLock()
|
|
defer ui.sessionsMu.RUnlock()
|
|
return ui.sessions[token]
|
|
}
|
|
|
|
func (ui *AdminUI) deleteSession(token string) {
|
|
ui.sessionsMu.Lock()
|
|
delete(ui.sessions, token)
|
|
ui.sessionsMu.Unlock()
|
|
}
|
|
|
|
// Cookie helpers
|
|
|
|
const sessionCookieName = "hold_admin_session"
|
|
|
|
func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/admin",
|
|
MaxAge: 86400, // 24 hours
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func getSessionCookie(r *http.Request) (string, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return cookie.Value, true
|
|
}
|
|
|
|
// parseTemplates loads and parses all HTML templates.
|
|
// Components (including layout) are parsed into a base template. Each page and
|
|
// partial gets its own clone of the base so that {{block}} overrides don't conflict.
|
|
func parseTemplates() (map[string]*template.Template, error) {
|
|
funcMap := template.FuncMap{
|
|
"truncate": func(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
},
|
|
"formatBytes": formatHumanBytes,
|
|
"formatDuration": func(d time.Duration) string {
|
|
if d < time.Second {
|
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
|
}
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
|
}
|
|
return d.Round(time.Second).String()
|
|
},
|
|
"formatTime": func(t time.Time) string {
|
|
return t.Format("2006-01-02 15:04")
|
|
},
|
|
"contains": func(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
// icon renders an SVG icon from the sprite sheet
|
|
// Usage: {{ icon "star" "size-4 text-amber-400" }}
|
|
"icon": func(name, classes string) template.HTML {
|
|
return template.HTML(fmt.Sprintf(
|
|
`<svg class="icon %s" aria-hidden="true"><use href="/admin/public/icons.svg#%s"></use></svg>`,
|
|
template.HTMLEscapeString(classes),
|
|
template.HTMLEscapeString(name),
|
|
))
|
|
},
|
|
}
|
|
|
|
// Collect template files by category
|
|
type tmplFile struct {
|
|
name string
|
|
content string
|
|
}
|
|
var components, pages, partials []tmplFile
|
|
|
|
err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.HasSuffix(path, ".html") {
|
|
return nil
|
|
}
|
|
content, err := templatesFS.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read template %s: %w", path, err)
|
|
}
|
|
name := path[len("templates/"):]
|
|
f := tmplFile{name: name, content: string(content)}
|
|
switch {
|
|
case strings.HasPrefix(name, "components/"):
|
|
components = append(components, f)
|
|
case strings.HasPrefix(name, "pages/"):
|
|
pages = append(pages, f)
|
|
case strings.HasPrefix(name, "partials/"):
|
|
partials = append(partials, f)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build base template with all components (head, nav, sidebar, layout, theme-toggle)
|
|
base := template.New("").Funcs(funcMap)
|
|
for _, c := range components {
|
|
if _, err := base.New(c.name).Parse(c.content); err != nil {
|
|
return nil, fmt.Errorf("failed to parse component %s: %w", c.name, err)
|
|
}
|
|
}
|
|
|
|
// For each page: clone base and parse the page into it (block overrides are per-clone)
|
|
templates := make(map[string]*template.Template)
|
|
for _, p := range pages {
|
|
clone, err := base.Clone()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to clone base for %s: %w", p.name, err)
|
|
}
|
|
if _, err := clone.New(p.name).Parse(p.content); err != nil {
|
|
return nil, fmt.Errorf("failed to parse page %s: %w", p.name, err)
|
|
}
|
|
templates[p.name] = clone
|
|
}
|
|
|
|
// For each partial: clone base and parse (partials may use icon func etc.)
|
|
for _, p := range partials {
|
|
clone, err := base.Clone()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to clone base for %s: %w", p.name, err)
|
|
}
|
|
if _, err := clone.New(p.name).Parse(p.content); err != nil {
|
|
return nil, fmt.Errorf("failed to parse partial %s: %w", p.name, err)
|
|
}
|
|
templates[p.name] = clone
|
|
}
|
|
|
|
return templates, nil
|
|
}
|
|
|
|
// formatHumanBytes formats bytes as human-readable string
|
|
func formatHumanBytes(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// isIPAddress returns true if the host is an IP address (not a domain name)
|
|
func isIPAddress(host string) bool {
|
|
return net.ParseIP(host) != nil
|
|
}
|
|
|
|
// RegisterRoutes registers all admin routes with the router
|
|
func (ui *AdminUI) RegisterRoutes(r chi.Router) {
|
|
// Static files (public)
|
|
staticSub, _ := fs.Sub(publicFS, "public")
|
|
r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", http.FileServer(http.FS(staticSub))))
|
|
|
|
// OAuth client metadata endpoint (required for production OAuth)
|
|
r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata)
|
|
|
|
// Public auth routes
|
|
r.Get("/admin/auth/login", ui.handleLogin)
|
|
r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
|
|
r.Get("/admin/auth/oauth/callback", ui.handleCallback)
|
|
|
|
// Protected routes (require owner)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(ui.requireOwner)
|
|
|
|
// Single admin page (client-side tab switching)
|
|
r.Get("/admin", ui.handleAdmin)
|
|
r.Get("/admin/", ui.handleAdmin)
|
|
|
|
// Tab content API (HTMX partials)
|
|
r.Get("/admin/api/tab/dashboard", ui.handleDashboardTab)
|
|
r.Get("/admin/api/tab/crew", ui.handleCrewTab)
|
|
r.Get("/admin/api/tab/settings", ui.handleSettingsTab)
|
|
r.Get("/admin/api/tab/relays", ui.handleRelaysTab)
|
|
r.Get("/admin/api/tab/storage", ui.handleGCTab)
|
|
|
|
// Backward-compat redirects for old bookmarks
|
|
r.Get("/admin/crew", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin#crew", http.StatusFound)
|
|
})
|
|
r.Get("/admin/settings", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin#settings", http.StatusFound)
|
|
})
|
|
r.Get("/admin/relays", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin#relays", http.StatusFound)
|
|
})
|
|
r.Get("/admin/storage", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin#storage", http.StatusFound)
|
|
})
|
|
|
|
// Crew sub-pages (full page, unchanged)
|
|
r.Get("/admin/crew/add", ui.handleCrewAddForm)
|
|
r.Post("/admin/crew/add", ui.handleCrewAdd)
|
|
r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm)
|
|
r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate)
|
|
r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete)
|
|
|
|
// Crew import/export
|
|
r.Get("/admin/crew/export", ui.handleCrewExport)
|
|
r.Get("/admin/crew/import", ui.handleCrewImportForm)
|
|
r.Post("/admin/crew/import", ui.handleCrewImport)
|
|
|
|
// Settings POST
|
|
r.Post("/admin/settings/update", ui.handleSettingsUpdate)
|
|
|
|
// Relay POSTs
|
|
r.Post("/admin/relays/crawl", ui.handleRelayCrawl)
|
|
r.Post("/admin/relays/crawl-all", ui.handleRelayCrawlAll)
|
|
|
|
// GC (background operations + polling status)
|
|
r.Post("/admin/api/gc/preview", ui.handleGCPreview)
|
|
r.Post("/admin/api/gc/run", ui.handleGCRun)
|
|
r.Post("/admin/api/gc/reconcile", ui.handleGCReconcile)
|
|
r.Post("/admin/api/gc/delete-records", ui.handleGCDeleteRecords)
|
|
r.Post("/admin/api/gc/delete-blobs", ui.handleGCDeleteBlobs)
|
|
r.Post("/admin/api/gc/backfill-configs", ui.handleGCBackfillConfigs)
|
|
r.Get("/admin/api/gc/status", ui.handleGCStatus)
|
|
|
|
// API endpoints (for HTMX)
|
|
r.Get("/admin/api/stats", ui.handleStatsAPI)
|
|
r.Get("/admin/api/top-users", ui.handleTopUsersAPI)
|
|
r.Get("/admin/api/relay/status", ui.handleRelayStatus)
|
|
r.Get("/admin/api/crew/member", ui.handleCrewMemberInfo)
|
|
|
|
// Logout
|
|
r.Post("/admin/auth/logout", ui.handleLogout)
|
|
})
|
|
}
|
|
|
|
// handleClientMetadata serves the OAuth client metadata for production deployments
|
|
func (ui *AdminUI) handleClientMetadata(w http.ResponseWriter, r *http.Request) {
|
|
metadata := ui.clientApp.Config.ClientMetadata()
|
|
|
|
// Set client name for display in OAuth consent screen
|
|
clientName := "Hold Admin Panel"
|
|
metadata.ClientName = &clientName
|
|
metadata.ClientURI = &ui.config.PublicURL
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
|
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// Close cleans up resources (no-op now, but keeps interface consistent)
|
|
func (ui *AdminUI) Close() error {
|
|
return nil
|
|
}
|