Files
at-container-registry/pkg/hold/admin/admin.go
2026-01-07 04:16:16 +00:00

355 lines
9.4 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 curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
//go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
import (
"context"
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"net/url"
"sync"
"time"
"atcr.io/pkg/atproto"
"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 static/*
var staticFS 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
}
// 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
clientApp *indigooauth.ClientApp
templates *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, 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,
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
func parseTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"truncate": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
"formatBytes": formatHumanBytes,
"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
},
}
tmpl := template.New("").Funcs(funcMap)
err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if len(path) < 5 || path[len(path)-5:] != ".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/"):]
_, err = tmpl.New(name).Parse(string(content))
if err != nil {
return fmt.Errorf("failed to parse template %s: %w", path, err)
}
return nil
})
if err != nil {
return nil, err
}
return tmpl, 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(staticFS, "static")
r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", 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)
// Dashboard
r.Get("/admin", ui.handleDashboard)
r.Get("/admin/", ui.handleDashboard)
// Crew management
r.Get("/admin/crew", ui.handleCrewList)
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)
// Settings
r.Get("/admin/settings", ui.handleSettings)
r.Post("/admin/settings/update", ui.handleSettingsUpdate)
// API endpoints (for HTMX)
r.Get("/admin/api/stats", ui.handleStatsAPI)
r.Get("/admin/api/top-users", ui.handleTopUsersAPI)
// Logout
r.Get("/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
}