diff --git a/.air.hold.toml b/.air.hold.toml new file mode 100644 index 0000000..a5af303 --- /dev/null +++ b/.air.hold.toml @@ -0,0 +1,25 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold" +entrypoint = ["./tmp/atcr-hold"] +include_ext = ["go"] +exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview"] +exclude_regex = ["_test\\.go$"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "blue" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/.gitignore b/.gitignore index 1c0e539..23c8aff 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ pkg/appview/static/js/htmx.min.js pkg/appview/static/js/lucide.min.js # IDE +.zed/ .claude/ .vscode/ .idea/ diff --git a/Dockerfile.dev b/Dockerfile.dev index 63fd979..96bab16 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,9 +1,12 @@ # Development image with Air hot reload -# Build: docker build -f Dockerfile.dev -t atcr-appview-dev . -# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev +# Build: docker build -f Dockerfile.dev -t atcr-dev . +# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-dev FROM docker.io/golang:1.25.4-trixie +ARG AIR_CONFIG=.air.toml + ENV DEBIAN_FRONTEND=noninteractive +ENV AIR_CONFIG=${AIR_CONFIG} RUN apt-get update && \ apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \ @@ -17,5 +20,4 @@ COPY go.mod go.sum ./ RUN go mod download # For development: source mounted as volume, Air handles builds -EXPOSE 5000 -CMD ["air", "-c", ".air.toml"] +CMD ["sh", "-c", "air -c ${AIR_CONFIG}"] diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 68fa0c8..71e6245 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -114,7 +114,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { slog.Debug("Base URL for OAuth", "base_url", baseURL) if testMode { - slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") + slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution") } // Create OAuth client app (automatically configures confidential client for production) @@ -123,11 +123,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create OAuth client app: %w", err) } - if testMode { - slog.Info("Using OAuth scopes with transition:generic (test mode)") - } else { - slog.Info("Using OAuth scopes with RPC scope (production mode)") - } // Invalidate sessions with mismatched scopes on startup // This ensures all users have the latest required scopes after deployment @@ -383,7 +378,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png" policyURI := cfg.Server.BaseURL + "/privacy" tosURI := cfg.Server.BaseURL + "/terms" - + metadata := config.ClientMetadata() metadata.ClientName = &cfg.Server.ClientName metadata.ClientURI = &cfg.Server.BaseURL diff --git a/cmd/usage-report/main.go b/cmd/usage-report/main.go index f2dcb87..94a2e9a 100644 --- a/cmd/usage-report/main.go +++ b/cmd/usage-report/main.go @@ -110,7 +110,7 @@ func main() { fmt.Println("=== Calculating from hold layer records ===") fmt.Println("NOTE: May undercount app-password users due to layer record bug") fmt.Println(" Use --from-manifests for more accurate results") - + userUsage, err = calculateFromLayerRecords(baseURL, holdDID) } diff --git a/docker-compose.yml b/docker-compose.yml index d544a91..57a592a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,12 +57,18 @@ services: # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*) build: context: . - dockerfile: Dockerfile.hold - image: atcr-hold:latest + dockerfile: Dockerfile.dev + args: + AIR_CONFIG: .air.hold.toml + image: atcr-hold-dev:latest container_name: atcr-hold ports: - "8080:8080" volumes: + # Mount source code for Air hot reload + - .:/app + # Cache go modules between rebuilds + - go-mod-cache:/go/pkg/mod # PDS data (carstore SQLite + signing keys) - atcr-hold:/var/lib/atcr-hold restart: unless-stopped diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md new file mode 100644 index 0000000..040564f --- /dev/null +++ b/docs/ADMIN_PANEL.md @@ -0,0 +1,1399 @@ +# 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 + +1. [Overview](#overview) +2. [Requirements](#requirements) +3. [Architecture](#architecture) +4. [File Structure](#file-structure) +5. [Authentication](#authentication) +6. [Session Management](#session-management) +7. [Route Structure](#route-structure) +8. [Feature Implementations](#feature-implementations) +9. [Templates](#templates) +10. [Environment Variables](#environment-variables) +11. [Security Considerations](#security-considerations) +12. [Implementation Phases](#implementation-phases) +13. [Testing Strategy](#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 + +1. **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 + +2. **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 + +3. **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) + +4. **Hold Settings** + - Toggle `public` (allow anonymous blob reads) + - Toggle `allowAllCrew` (allow any authenticated user to join) + - Toggle `enableBlueskyPosts` (post to Bluesky on image push) + +### 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 + +1. **AdminUI** - Main struct containing all admin dependencies +2. **Session Store** - SQLite-backed session management (separate from carstore) +3. **OAuth Client** - Reuses `pkg/auth/oauth/` for browser-based login +4. **Auth Middleware** - Validates owner-only access +5. **Handlers** - HTTP handlers for each admin page +6. **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 +└── static/ + ├── 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: + +```go +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 + +```go +// 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 + +```sql +-- 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 + +```go +// 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: + +```go +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/static/*` | GET | Public | Static files | CSS, JS assets | + +### Route Registration + +```go +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/static/*", http.StripPrefix("/admin/static/", 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 + +```go +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 + +```go +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 + +```go +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 + +```go +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 + +```go +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 + +```go +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" + + _, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts) + 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) + +```go +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) + +```html +{{ define "base" }} + + +
+ + +{{ .Stats.TotalCrewMembers }}
+Loading storage stats...
+Loading top users...
+| DID | +Role | +Permissions | +Tier | +Usage | +Actions | +
|---|
{{ .DID | truncate 20 }}
+ {{ if .Plankowner }}Plankowner{{ end }}
+