46 KiB
Hold Admin Panel Implementation Plan
This document describes the implementation plan for adding an owner-only admin web UI to the ATCR hold service. The admin panel will be embedded directly in the hold service binary for simplified deployment.
Table of Contents
- Overview
- Requirements
- Architecture
- File Structure
- Authentication
- Session Management
- Route Structure
- Feature Implementations
- Templates
- Environment Variables
- Security Considerations
- Implementation Phases
- Testing Strategy
Overview
The hold admin panel provides a web-based interface for hold owners to:
- Manage crew members: Add, remove, edit permissions and quota tiers
- Configure hold settings: Toggle public access, open registration, Bluesky posting
- View usage metrics: Storage usage per user, top users, repository statistics
- Monitor quota utilization: Track tier distribution and usage percentages
The admin panel is owner-only - only the DID that matches captain.Owner can access it.
Requirements
Functional Requirements
-
Crew Management
- List all crew members with their DID, role, permissions, tier, and storage usage
- Add new crew members with specified permissions and tier
- Edit existing crew member permissions and tier
- Remove crew members (with confirmation)
- Display each crew member's quota utilization percentage
-
Quota/Tier Management
- Display available tiers from
quotas.yaml - Show tier limits and descriptions
- Allow changing crew member tiers
- Display current vs limit usage for each user
- Display available tiers from
-
Usage Metrics
- Total storage used across all users
- Total unique blobs (deduplicated)
- Number of crew members
- Top 10/50/100 users by storage consumption
- Per-repository statistics (pulls, pushes)
-
Hold Settings
- Toggle
public(allow anonymous blob reads) - Toggle
allowAllCrew(allow any authenticated user to join) - Toggle
enableBlueskyPosts(post to Bluesky on image push)
- Toggle
Non-Functional Requirements
- Single binary: Embedded in hold service, no separate deployment
- Responsive UI: Works on desktop and mobile browsers
- Low latency: Dashboard loads in <500ms for typical data volumes
- Minimal dependencies: Uses Go templates, HTMX for interactivity
Architecture
High-Level Design
┌─────────────────────────────────────────────────────────┐
│ Hold Service │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ XRPC/PDS │ │ OCI XRPC │ │ Admin Panel │ │
│ │ Handlers │ │ Handlers │ │ Handlers │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌──────┴────────────────┴───────────────────┴────────┐ │
│ │ Chi Router │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴─────────────────────────┐ │
│ │ Embedded PDS │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Captain │ │ Crew │ │ Layer │ │ │
│ │ │ Records │ │ Records │ │ Records │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Components
- AdminUI - Main struct containing all admin dependencies
- Session Store - SQLite-backed session management (separate from carstore)
- OAuth Client - Reuses
pkg/auth/oauth/for browser-based login - Auth Middleware - Validates owner-only access
- Handlers - HTTP handlers for each admin page
- Templates - Go html/template with embed.FS
File Structure
pkg/hold/admin/
├── admin.go # Main struct, initialization, route registration
├── auth.go # requireOwner middleware, session validation
├── handlers.go # HTTP handlers for all admin pages
├── session.go # SQLite session store implementation
├── metrics.go # Metrics collection and aggregation
├── templates/
│ ├── base.html # Base layout (html, head, body wrapper)
│ ├── components/
│ │ ├── head.html # CSS/JS includes (HTMX, Lucide icons)
│ │ ├── nav.html # Admin navigation bar
│ │ └── flash.html # Flash message component
│ ├── pages/
│ │ ├── login.html # OAuth login page
│ │ ├── dashboard.html # Metrics overview
│ │ ├── crew.html # Crew list with management actions
│ │ ├── crew_add.html # Add crew member form
│ │ ├── crew_edit.html # Edit crew member form
│ │ └── settings.html # Hold settings page
│ └── partials/
│ ├── crew_row.html # Single crew row (for HTMX updates)
│ ├── usage_stats.html # Usage stats partial
│ └── top_users.html # Top users table partial
└── public/
├── css/
│ └── admin.css # Admin-specific styles
└── js/
└── admin.js # Admin-specific JavaScript (if needed)
Files to Modify
| File | Changes |
|---|---|
cmd/hold/main.go |
Add admin UI initialization and route registration |
pkg/hold/config.go |
Add Admin.Enabled and Admin.SessionDuration fields |
.env.hold.example |
Document HOLD_ADMIN_ENABLED, HOLD_ADMIN_SESSION_DURATION |
Authentication
OAuth Flow for Admin Login
The admin panel uses ATProto OAuth with DPoP for browser-based authentication:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ Hold │ │ PDS │ │ Owner │
│ │ │ Admin │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ GET /admin │ │ │
│───────────────>│ │ │
│ │ │ │
│ 302 /admin/auth/login │ │
│<───────────────│ │ │
│ │ │ │
│ GET /admin/auth/login │ │
│───────────────>│ │ │
│ │ │ │
│ Login page (enter handle) │ │
│<───────────────│ │ │
│ │ │ │
│ POST handle │ │ │
│───────────────>│ │ │
│ │ │ │
│ │ StartAuthFlow │ │
│ │───────────────>│ │
│ │ │ │
│ 302 to PDS auth URL │ │
│<───────────────│ │ │
│ │ │ │
│ Authorize in browser │ │
│────────────────────────────────>│ │
│ │ │ Approve? │
│ │ │───────────────>│
│ │ │ │
│ │ │ Yes │
│ │ │<───────────────│
│ │ │ │
│ 302 callback with code │ │
│<────────────────────────────────│ │
│ │ │ │
│ GET /admin/auth/oauth/callback │ │
│───────────────>│ │ │
│ │ │ │
│ │ ProcessCallback│ │
│ │───────────────>│ │
│ │ │ │
│ │ OAuth tokens │ │
│ │<───────────────│ │
│ │ │ │
│ │ Check: DID == captain.Owner? │
│ │─────────────────────────────────│
│ │ │ │
│ │ YES: Create session │
│ │ │ │
│ 302 /admin + session cookie │ │
│<───────────────│ │ │
│ │ │ │
│ GET /admin (with cookie) │ │
│───────────────>│ │ │
│ │ │ │
│ Dashboard │ │ │
│<───────────────│ │ │
Owner Validation
The callback handler performs owner validation:
func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
// Process OAuth callback
sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query())
if err != nil {
ui.renderError(w, "OAuth failed: " + err.Error())
return
}
did := sessionData.AccountDID.String()
// Get captain record to check owner
_, captain, err := ui.pds.GetCaptainRecord(r.Context())
if err != nil {
ui.renderError(w, "Failed to verify ownership")
return
}
// CRITICAL: Only allow the hold owner
if did != captain.Owner {
slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner)
ui.renderError(w, "Access denied: Only the hold owner can access the admin panel")
return
}
// Create admin session
sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour)
if err != nil {
ui.renderError(w, "Failed to create session")
return
}
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "hold_admin_session",
Value: sessionID,
Path: "/admin",
MaxAge: 86400, // 24 hours
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/admin", http.StatusFound)
}
Auth Middleware
// requireOwner ensures the request is from the hold owner
func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get session cookie
cookie, err := r.Cookie("hold_admin_session")
if err != nil {
http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound)
return
}
// Validate session
session, err := ui.sessionStore.Get(cookie.Value)
if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) {
// Clear invalid cookie
http.SetCookie(w, &http.Cookie{
Name: "hold_admin_session",
Value: "",
Path: "/admin",
MaxAge: -1,
})
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
return
}
// Double-check DID still matches captain.Owner
// (in case ownership transferred while session active)
_, captain, err := ui.pds.GetCaptainRecord(r.Context())
if err != nil || session.DID != captain.Owner {
ui.sessionStore.Delete(cookie.Value)
http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden)
return
}
// Add session to context for handlers
ctx := context.WithValue(r.Context(), adminSessionKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Session Management
Session Store Schema
-- Admin sessions (browser login state)
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
did TEXT NOT NULL,
handle TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Index for cleanup queries
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did);
-- OAuth sessions (indigo library storage)
CREATE TABLE IF NOT EXISTS admin_oauth_sessions (
session_id TEXT PRIMARY KEY,
did TEXT NOT NULL,
data BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Session Store Interface
// AdminSession represents an authenticated admin session
type AdminSession struct {
ID string
DID string
Handle string
CreatedAt time.Time
ExpiresAt time.Time
LastAccessed time.Time
}
// AdminSessionStore manages admin sessions
type AdminSessionStore struct {
db *sql.DB
}
func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error)
func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error)
func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error)
func (s *AdminSessionStore) Delete(sessionID string) error
func (s *AdminSessionStore) DeleteForDID(did string) error
func (s *AdminSessionStore) Cleanup() error // Remove expired sessions
func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed
Database Location
The admin database should be in the same directory as the carstore database:
adminDBPath := filepath.Join(cfg.Database.Path, "admin.db")
This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions.
Route Structure
Complete Route Table
| Route | Method | Auth | Handler | Description |
|---|---|---|---|---|
/admin |
GET | Owner | DashboardHandler |
Main dashboard with metrics |
/admin/crew |
GET | Owner | CrewListHandler |
List all crew members |
/admin/crew/add |
GET | Owner | CrewAddFormHandler |
Add crew form |
/admin/crew/add |
POST | Owner | CrewAddHandler |
Process add crew |
/admin/crew/{rkey} |
GET | Owner | CrewEditFormHandler |
Edit crew form |
/admin/crew/{rkey}/update |
POST | Owner | CrewUpdateHandler |
Process crew update |
/admin/crew/{rkey}/delete |
POST | Owner | CrewDeleteHandler |
Delete crew member |
/admin/settings |
GET | Owner | SettingsHandler |
Hold settings page |
/admin/settings/update |
POST | Owner | SettingsUpdateHandler |
Update settings |
/admin/api/stats |
GET | Owner | StatsAPIHandler |
JSON stats endpoint |
/admin/api/top-users |
GET | Owner | TopUsersAPIHandler |
JSON top users |
/admin/auth/login |
GET | Public | LoginHandler |
Login page |
/admin/auth/oauth/authorize |
GET | Public | OAuth authorize | Start OAuth flow |
/admin/auth/oauth/callback |
GET | Public | CallbackHandler |
OAuth callback |
/admin/auth/logout |
GET | Owner | LogoutHandler |
Logout and clear session |
/admin/public/* |
GET | Public | Static files | CSS, JS assets |
Route Registration
func (ui *AdminUI) RegisterRoutes(r chi.Router) {
// Public routes (login flow)
r.Get("/admin/auth/login", ui.handleLogin)
r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
r.Get("/admin/auth/oauth/callback", ui.handleCallback)
// Static files (public)
r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", ui.staticHandler()))
// Protected routes (require owner)
r.Group(func(r chi.Router) {
r.Use(ui.requireOwner)
// Dashboard
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)
})
}
Feature Implementations
Dashboard Handler
type DashboardStats struct {
TotalCrewMembers int
TotalBlobs int64
TotalStorageBytes int64
TotalStorageHuman string
TierDistribution map[string]int // tier -> count
RecentActivity []ActivityEntry
}
func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Collect basic stats
crew, _ := ui.pds.ListCrewMembers(ctx)
stats := DashboardStats{
TotalCrewMembers: len(crew),
TierDistribution: make(map[string]int),
}
// Count tier distribution
for _, member := range crew {
tier := member.Tier
if tier == "" {
tier = ui.quotaMgr.GetDefaultTier()
}
stats.TierDistribution[tier]++
}
// Storage stats (loaded via HTMX to avoid slow initial load)
// The actual calculation happens in handleStatsAPI
data := struct {
AdminPageData
Stats DashboardStats
}{
AdminPageData: ui.newPageData(r),
Stats: stats,
}
ui.templates.ExecuteTemplate(w, "dashboard", data)
}
Crew List Handler
type CrewMemberView struct {
RKey string
DID string
Handle string // Resolved from DID
Role string
Permissions []string
Tier string
TierLimit string // Human-readable
CurrentUsage int64
UsageHuman string
UsagePercent int
Plankowner bool
AddedAt time.Time
}
func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
crew, err := ui.pds.ListCrewMembers(ctx)
if err != nil {
ui.renderError(w, "Failed to list crew: "+err.Error())
return
}
// Enrich with usage data
var crewViews []CrewMemberView
for _, member := range crew {
view := CrewMemberView{
RKey: member.RKey,
DID: member.Member,
Role: member.Role,
Permissions: member.Permissions,
Tier: member.Tier,
Plankowner: member.Plankowner,
AddedAt: member.AddedAt,
}
// Get tier limit
if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil {
view.TierLimit = quota.FormatHumanBytes(*limit)
} else {
view.TierLimit = "Unlimited"
}
// Get usage (expensive - consider caching)
usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr)
view.CurrentUsage = usage
view.UsageHuman = quota.FormatHumanBytes(usage)
if limit != nil && *limit > 0 {
view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
}
crewViews = append(crewViews, view)
}
// Sort by usage (highest first)
sort.Slice(crewViews, func(i, j int) bool {
return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage
})
data := struct {
AdminPageData
Crew []CrewMemberView
Tiers []TierOption
}{
AdminPageData: ui.newPageData(r),
Crew: crewViews,
Tiers: ui.getTierOptions(),
}
ui.templates.ExecuteTemplate(w, "crew", data)
}
Add Crew Handler
func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
ui.setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
return
}
did := strings.TrimSpace(r.FormValue("did"))
role := r.FormValue("role")
tier := r.FormValue("tier")
// Parse permissions checkboxes
var permissions []string
if r.FormValue("perm_read") == "on" {
permissions = append(permissions, "blob:read")
}
if r.FormValue("perm_write") == "on" {
permissions = append(permissions, "blob:write")
}
if r.FormValue("perm_admin") == "on" {
permissions = append(permissions, "crew:admin")
}
// Validate DID format
if !strings.HasPrefix(did, "did:") {
ui.setFlash(w, "error", "Invalid DID format")
http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
return
}
// Add crew member
_, err := ui.pds.AddCrewMember(ctx, did, role, permissions)
if err != nil {
ui.setFlash(w, "error", "Failed to add crew member: "+err.Error())
http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
return
}
// Set tier if specified
if tier != "" && tier != ui.quotaMgr.GetDefaultTier() {
if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil {
slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err)
}
}
ui.setFlash(w, "success", "Crew member added successfully")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
}
Update Crew Handler
func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rkey := chi.URLParam(r, "rkey")
if err := r.ParseForm(); err != nil {
ui.setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
return
}
// Get current crew member
current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
if err != nil {
ui.setFlash(w, "error", "Crew member not found")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
return
}
// Parse new values
role := r.FormValue("role")
tier := r.FormValue("tier")
var permissions []string
if r.FormValue("perm_read") == "on" {
permissions = append(permissions, "blob:read")
}
if r.FormValue("perm_write") == "on" {
permissions = append(permissions, "blob:write")
}
if r.FormValue("perm_admin") == "on" {
permissions = append(permissions, "crew:admin")
}
// Update tier if changed
if tier != current.Tier {
if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
ui.setFlash(w, "error", "Failed to update tier: "+err.Error())
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
return
}
}
// For role/permissions changes, need to delete and recreate
// (ATProto records are immutable, updates require delete+create)
if role != current.Role || !slicesEqual(permissions, current.Permissions) {
// Delete old record
if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
ui.setFlash(w, "error", "Failed to update: "+err.Error())
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
return
}
// Create new record with updated values
if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil {
ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error())
http.Redirect(w, r, "/admin/crew", http.StatusFound)
return
}
// Re-apply tier to new record
if tier != "" {
ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier)
}
}
ui.setFlash(w, "success", "Crew member updated successfully")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
}
Delete Crew Handler
func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rkey := chi.URLParam(r, "rkey")
// Get crew member to log who was deleted
member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
if err != nil {
ui.setFlash(w, "error", "Crew member not found")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
return
}
// Prevent deleting self (captain)
session := getAdminSession(ctx)
if member.Member == session.DID {
ui.setFlash(w, "error", "Cannot remove yourself from crew")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
return
}
// Delete
if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error())
http.Redirect(w, r, "/admin/crew", http.StatusFound)
return
}
slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID)
// For HTMX requests, return empty response (row will be removed)
if r.Header.Get("HX-Request") == "true" {
w.WriteHeader(http.StatusOK)
return
}
ui.setFlash(w, "success", "Crew member removed")
http.Redirect(w, r, "/admin/crew", http.StatusFound)
}
Settings Handler
func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_, captain, err := ui.pds.GetCaptainRecord(ctx)
if err != nil {
ui.renderError(w, "Failed to load settings: "+err.Error())
return
}
data := struct {
AdminPageData
Settings struct {
Public bool
AllowAllCrew bool
EnableBlueskyPosts bool
OwnerDID string
HoldDID string
}
}{
AdminPageData: ui.newPageData(r),
}
data.Settings.Public = captain.Public
data.Settings.AllowAllCrew = captain.AllowAllCrew
data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts
data.Settings.OwnerDID = captain.Owner
data.Settings.HoldDID = ui.pds.DID()
ui.templates.ExecuteTemplate(w, "settings", data)
}
func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
ui.setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, "/admin/settings", http.StatusFound)
return
}
public := r.FormValue("public") == "on"
allowAllCrew := r.FormValue("allow_all_crew") == "on"
enablePosts := r.FormValue("enable_bluesky_posts") == "on"
_, captain, _ := ui.pds.GetCaptainRecord(ctx)
captain.Public = public
captain.AllowAllCrew = allowAllCrew
captain.EnableBlueskyPosts = enablePosts
_, err := ui.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
ui.setFlash(w, "error", "Failed to update settings: "+err.Error())
http.Redirect(w, r, "/admin/settings", http.StatusFound)
return
}
ui.setFlash(w, "success", "Settings updated successfully")
http.Redirect(w, r, "/admin/settings", http.StatusFound)
}
Metrics Handler (for HTMX lazy loading)
func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Calculate total storage (expensive operation)
// Iterate through all layer records
records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true)
if err != nil {
http.Error(w, "Failed to load stats", http.StatusInternalServerError)
return
}
var totalSize int64
uniqueDigests := make(map[string]bool)
userUsage := make(map[string]int64)
for _, record := range records {
var layer atproto.LayerRecord
if err := json.Unmarshal(record.Value, &layer); err != nil {
continue
}
if !uniqueDigests[layer.Digest] {
uniqueDigests[layer.Digest] = true
totalSize += layer.Size
}
userUsage[layer.UserDID] += layer.Size
}
stats := struct {
TotalBlobs int `json:"totalBlobs"`
TotalSize int64 `json:"totalSize"`
TotalHuman string `json:"totalHuman"`
}{
TotalBlobs: len(uniqueDigests),
TotalSize: totalSize,
TotalHuman: quota.FormatHumanBytes(totalSize),
}
// If HTMX request, return HTML partial
if r.Header.Get("HX-Request") == "true" {
data := struct {
Stats interface{}
}{Stats: stats}
ui.templates.ExecuteTemplate(w, "usage_stats", data)
return
}
// Otherwise return JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
Templates
Base Layout (templates/base.html)
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} - Hold Admin</title>
{{ template "head" . }}
</head>
<body>
{{ template "nav" . }}
<main class="admin-container">
{{ template "flash" . }}
{{ template "content" . }}
</main>
<footer class="admin-footer">
<p>Hold: {{ .HoldDID }}</p>
</footer>
</body>
</html>
{{ end }}
Head Component (templates/components/head.html)
{{ define "head" }}
<link rel="stylesheet" href="/admin/public/css/admin.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/lucide@latest"></script>
{{ end }}
Navigation (templates/components/nav.html)
{{ define "nav" }}
<nav class="admin-nav">
<div class="nav-brand">
<a href="/admin">Hold Admin</a>
</div>
<ul class="nav-links">
<li><a href="/admin" class="{{ if eq .ActivePage "dashboard" }}active{{ end }}">Dashboard</a></li>
<li><a href="/admin/crew" class="{{ if eq .ActivePage "crew" }}active{{ end }}">Crew</a></li>
<li><a href="/admin/settings" class="{{ if eq .ActivePage "settings" }}active{{ end }}">Settings</a></li>
</ul>
<div class="nav-user">
<span>{{ .User.Handle }}</span>
<a href="/admin/auth/logout">Logout</a>
</div>
</nav>
{{ end }}
Dashboard Page (templates/pages/dashboard.html)
{{ define "dashboard" }}
{{ template "base" . }}
{{ define "content" }}
<h1>Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<h3>Crew Members</h3>
<p class="stat-value">{{ .Stats.TotalCrewMembers }}</p>
</div>
<div class="stat-card" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
<p>Loading storage stats...</p>
</div>
</div>
<section class="dashboard-section">
<h2>Tier Distribution</h2>
<div class="tier-chart">
{{ range $tier, $count := .Stats.TierDistribution }}
<div class="tier-bar">
<span class="tier-name">{{ $tier }}</span>
<span class="tier-count">{{ $count }}</span>
</div>
{{ end }}
</div>
</section>
<section class="dashboard-section">
<h2>Top Users by Storage</h2>
<div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
<p>Loading top users...</p>
</div>
</section>
{{ end }}
{{ end }}
Crew List Page (templates/pages/crew.html)
{{ define "crew" }}
{{ template "base" . }}
{{ define "content" }}
<div class="page-header">
<h1>Crew Members</h1>
<a href="/admin/crew/add" class="btn btn-primary">Add Crew Member</a>
</div>
<table class="data-table">
<thead>
<tr>
<th>DID</th>
<th>Role</th>
<th>Permissions</th>
<th>Tier</th>
<th>Usage</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="crew-list">
{{ range .Crew }}
{{ template "crew_row" . }}
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}
Crew Row Partial (templates/partials/crew_row.html)
{{ define "crew_row" }}
<tr id="crew-{{ .RKey }}">
<td>
<code title="{{ .DID }}">{{ .DID | truncate 20 }}</code>
{{ if .Plankowner }}<span class="badge badge-gold">Plankowner</span>{{ end }}
</td>
<td>{{ .Role }}</td>
<td>
{{ range .Permissions }}
<span class="badge badge-perm">{{ . }}</span>
{{ end }}
</td>
<td>
<span class="badge badge-tier tier-{{ .Tier }}">{{ .Tier }}</span>
<small>({{ .TierLimit }})</small>
</td>
<td>
<div class="usage-cell">
<span>{{ .UsageHuman }}</span>
<div class="progress-bar">
<div class="progress-fill {{ if gt .UsagePercent 90 }}danger{{ else if gt .UsagePercent 75 }}warning{{ end }}"
style="width: {{ .UsagePercent }}%"></div>
</div>
<small>{{ .UsagePercent }}%</small>
</div>
</td>
<td>
<a href="/admin/crew/{{ .RKey }}" class="btn btn-sm">Edit</a>
<button class="btn btn-sm btn-danger"
hx-post="/admin/crew/{{ .RKey }}/delete"
hx-confirm="Are you sure you want to remove this crew member?"
hx-target="#crew-{{ .RKey }}"
hx-swap="outerHTML">
Delete
</button>
</td>
</tr>
{{ end }}
Settings Page (templates/pages/settings.html)
{{ define "settings" }}
{{ template "base" . }}
{{ define "content" }}
<h1>Hold Settings</h1>
<form action="/admin/settings/update" method="POST" class="settings-form">
<div class="setting-group">
<h2>Access Control</h2>
<label class="toggle-setting">
<input type="checkbox" name="public" {{ if .Settings.Public }}checked{{ end }}>
<span class="toggle-label">
<strong>Public Hold</strong>
<small>Allow anonymous users to read blobs (no auth required for pulls)</small>
</span>
</label>
<label class="toggle-setting">
<input type="checkbox" name="allow_all_crew" {{ if .Settings.AllowAllCrew }}checked{{ end }}>
<span class="toggle-label">
<strong>Open Registration</strong>
<small>Allow any authenticated user to join as crew via requestCrew</small>
</span>
</label>
</div>
<div class="setting-group">
<h2>Integrations</h2>
<label class="toggle-setting">
<input type="checkbox" name="enable_bluesky_posts" {{ if .Settings.EnableBlueskyPosts }}checked{{ end }}>
<span class="toggle-label">
<strong>Bluesky Posts</strong>
<small>Post to Bluesky when images are pushed to this hold</small>
</span>
</label>
</div>
<div class="setting-group">
<h2>Hold Information</h2>
<dl>
<dt>Hold DID</dt>
<dd><code>{{ .Settings.HoldDID }}</code></dd>
<dt>Owner DID</dt>
<dd><code>{{ .Settings.OwnerDID }}</code></dd>
</dl>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
{{ end }}
{{ end }}
Environment Variables
Add to .env.hold.example:
# =============================================================================
# ADMIN PANEL CONFIGURATION
# =============================================================================
# Enable the admin web UI (default: false)
# When enabled, accessible at /admin
HOLD_ADMIN_ENABLED=false
# Admin session duration (default: 24h)
# How long admin sessions remain valid before requiring re-authentication
# Format: Go duration string (e.g., 24h, 168h for 1 week)
HOLD_ADMIN_SESSION_DURATION=24h
Config Struct Updates
// In pkg/hold/config.go
type Config struct {
// ... existing fields ...
Admin AdminConfig
}
type AdminConfig struct {
Enabled bool `env:"HOLD_ADMIN_ENABLED" envDefault:"false"`
SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"`
}
Security Considerations
1. Owner-Only Access
All admin routes validate that the authenticated user's DID matches captain.Owner. This check happens:
- In the OAuth callback (primary gate)
- In the
requireOwnermiddleware (defense in depth) - Before destructive operations (extra validation)
2. Cookie Security
http.SetCookie(w, &http.Cookie{
Name: "hold_admin_session",
Value: sessionID,
Path: "/admin", // Scoped to admin paths only
MaxAge: 86400, // 24 hours
HttpOnly: true, // No JavaScript access
Secure: isHTTPS(r), // HTTPS only in production
SameSite: http.SameSiteLaxMode, // CSRF protection
})
3. CSRF Protection
For state-changing operations:
- Forms include hidden CSRF token
- HTMX requests include token in header
- Server validates token before processing
<form action="/admin/crew/add" method="POST">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
...
</form>
4. Input Validation
- DID format validation before database operations
- Tier names validated against
quotas.yaml - Permission values validated against known set
- All user input sanitized before display
5. Rate Limiting
Consider adding rate limiting for:
- Login attempts (prevent brute force)
- OAuth flow starts (prevent abuse)
- API endpoints (prevent DoS)
6. Audit Logging
Log all administrative actions:
slog.Info("Admin action",
"action", "crew_add",
"admin_did", session.DID,
"target_did", newMemberDID,
"permissions", permissions)
Implementation Phases
Phase 1: Foundation (Est. 4-6 hours)
- Create
pkg/hold/admin/package structure - Implement
AdminSessionStorewith SQLite - Implement OAuth client setup (reuse
pkg/auth/oauth/) - Implement
requireOwnermiddleware - Create basic template loading with embed.FS
- Add env var configuration to
pkg/hold/config.go
Deliverables:
- Admin package compiles
- Can start OAuth flow
- Session store creates/validates sessions
Phase 2: Authentication (Est. 3-4 hours)
- Implement login page handler
- Implement OAuth authorize redirect
- Implement callback with owner validation
- Implement logout handler
- Wire up routes in
cmd/hold/main.go
Deliverables:
- Can login as hold owner
- Non-owners rejected at callback
- Sessions persist across requests
Phase 3: Dashboard (Est. 3-4 hours)
- Create base template and navigation
- Implement dashboard handler with basic stats
- Implement stats API for HTMX lazy loading
- Implement top users API
- Create dashboard template
Deliverables:
- Dashboard shows crew count, tier distribution
- Storage stats load asynchronously
- Top users table displays
Phase 4: Crew Management (Est. 4-6 hours)
- Implement crew list handler
- Create crew list template with HTMX delete
- Implement add crew form and handler
- Implement edit crew form and handler
- Implement delete crew handler
Deliverables:
- Full CRUD for crew members
- Tier and permission editing works
- HTMX updates without page reload
Phase 5: Settings (Est. 2-3 hours)
- Implement settings handler
- Create settings template
- Implement settings update handler
Deliverables:
- Can toggle public/allowAllCrew/enableBlueskyPosts
- Settings persist correctly
Phase 6: Polish (Est. 2-4 hours)
- Add CSS styling
- Add flash messages
- Add CSRF protection
- Add input validation
- Add audit logging
- Update documentation
Deliverables:
- Professional-looking UI
- Security hardening complete
- Documentation updated
Total Estimated Time: 18-27 hours
Testing Strategy
Unit Tests
// pkg/hold/admin/session_test.go
func TestSessionStore_Create(t *testing.T) {
store := newTestSessionStore(t)
sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour)
require.NoError(t, err)
require.NotEmpty(t, sessionID)
session, err := store.Get(sessionID)
require.NoError(t, err)
assert.Equal(t, "did:plc:test", session.DID)
}
// pkg/hold/admin/auth_test.go
func TestRequireOwner_RejectsNonOwner(t *testing.T) {
pds := setupTestPDSWithOwner(t, "did:plc:owner")
store := newTestSessionStore(t)
// Create session for non-owner
sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour)
middleware := requireOwner(pds, store)
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
Integration Tests
// pkg/hold/admin/integration_test.go
func TestAdminLoginFlow(t *testing.T) {
// Start test hold server
server := startTestHoldWithAdmin(t)
defer server.Close()
// Verify login page accessible
resp, _ := http.Get(server.URL + "/admin/auth/login")
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify dashboard redirects to login
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, _ = client.Get(server.URL + "/admin")
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login")
}
Manual Testing Checklist
- Login as owner succeeds
- Login as non-owner fails with clear error
- Dashboard loads with correct stats
- Add crew member with all permission combinations
- Edit crew member permissions
- Change crew member tier
- Delete crew member
- Toggle public setting
- Toggle allowAllCrew setting
- Toggle enableBlueskyPosts setting
- Logout clears session
- Session expires after configured duration
- Expired session redirects to login
Future Enhancements
Potential Future Features
- Crew Invite Links - Generate one-time invite URLs for adding crew
- Usage Alerts - Email/webhook when users approach quota
- Bulk Operations - Add/remove multiple crew members at once
- Export Data - Download crew list, usage reports as CSV
- Activity Log - View recent admin actions
- API Keys - Generate programmatic access keys for admin API
- Backup/Restore - Backup crew records, restore from backup
- Multi-Hold Management - Manage multiple holds from one UI (separate feature)
Performance Optimizations
- Cache usage stats - Don't recalculate on every request
- Paginate crew list - Handle holds with 1000+ crew members
- Background stat refresh - Update stats periodically in background
- Batch DID resolution - Resolve multiple DIDs in parallel