A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix oauth scope mismatch

evan.jarrett.net f35bf2bc a448e825

verified
Changed files
+1510 -50
cmd
appview
usage-report
docs
pkg
+25
.air.hold.toml
··· 1 + root = "." 2 + tmp_dir = "tmp" 3 + 4 + [build] 5 + cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold" 6 + entrypoint = ["./tmp/atcr-hold"] 7 + include_ext = ["go"] 8 + exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview"] 9 + exclude_regex = ["_test\\.go$"] 10 + delay = 1000 11 + stop_on_error = true 12 + send_interrupt = true 13 + kill_delay = 500 14 + 15 + [log] 16 + time = false 17 + 18 + [color] 19 + main = "blue" 20 + watcher = "magenta" 21 + build = "yellow" 22 + runner = "green" 23 + 24 + [misc] 25 + clean_on_exit = true
+1
.gitignore
··· 18 18 pkg/appview/static/js/lucide.min.js 19 19 20 20 # IDE 21 + .zed/ 21 22 .claude/ 22 23 .vscode/ 23 24 .idea/
+6 -4
Dockerfile.dev
··· 1 1 # Development image with Air hot reload 2 - # Build: docker build -f Dockerfile.dev -t atcr-appview-dev . 3 - # Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev 2 + # Build: docker build -f Dockerfile.dev -t atcr-dev . 3 + # Run: docker run -v $(pwd):/app -p 5000:5000 atcr-dev 4 4 FROM docker.io/golang:1.25.4-trixie 5 5 6 + ARG AIR_CONFIG=.air.toml 7 + 6 8 ENV DEBIAN_FRONTEND=noninteractive 9 + ENV AIR_CONFIG=${AIR_CONFIG} 7 10 8 11 RUN apt-get update && \ 9 12 apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \ ··· 17 20 RUN go mod download 18 21 19 22 # For development: source mounted as volume, Air handles builds 20 - EXPOSE 5000 21 - CMD ["air", "-c", ".air.toml"] 23 + CMD ["sh", "-c", "air -c ${AIR_CONFIG}"]
+2 -7
cmd/appview/serve.go
··· 114 114 115 115 slog.Debug("Base URL for OAuth", "base_url", baseURL) 116 116 if testMode { 117 - slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") 117 + slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution") 118 118 } 119 119 120 120 // Create OAuth client app (automatically configures confidential client for production) ··· 122 122 oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName) 123 123 if err != nil { 124 124 return fmt.Errorf("failed to create OAuth client app: %w", err) 125 - } 126 - if testMode { 127 - slog.Info("Using OAuth scopes with transition:generic (test mode)") 128 - } else { 129 - slog.Info("Using OAuth scopes with RPC scope (production mode)") 130 125 } 131 126 132 127 // Invalidate sessions with mismatched scopes on startup ··· 383 378 logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png" 384 379 policyURI := cfg.Server.BaseURL + "/privacy" 385 380 tosURI := cfg.Server.BaseURL + "/terms" 386 - 381 + 387 382 metadata := config.ClientMetadata() 388 383 metadata.ClientName = &cfg.Server.ClientName 389 384 metadata.ClientURI = &cfg.Server.BaseURL
+1 -1
cmd/usage-report/main.go
··· 110 110 fmt.Println("=== Calculating from hold layer records ===") 111 111 fmt.Println("NOTE: May undercount app-password users due to layer record bug") 112 112 fmt.Println(" Use --from-manifests for more accurate results") 113 - 113 + 114 114 userUsage, err = calculateFromLayerRecords(baseURL, holdDID) 115 115 } 116 116
+8 -2
docker-compose.yml
··· 57 57 # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*) 58 58 build: 59 59 context: . 60 - dockerfile: Dockerfile.hold 61 - image: atcr-hold:latest 60 + dockerfile: Dockerfile.dev 61 + args: 62 + AIR_CONFIG: .air.hold.toml 63 + image: atcr-hold-dev:latest 62 64 container_name: atcr-hold 63 65 ports: 64 66 - "8080:8080" 65 67 volumes: 68 + # Mount source code for Air hot reload 69 + - .:/app 70 + # Cache go modules between rebuilds 71 + - go-mod-cache:/go/pkg/mod 66 72 # PDS data (carstore SQLite + signing keys) 67 73 - atcr-hold:/var/lib/atcr-hold 68 74 restart: unless-stopped
+1399
docs/ADMIN_PANEL.md
··· 1 + # Hold Admin Panel Implementation Plan 2 + 3 + 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. 4 + 5 + ## Table of Contents 6 + 7 + 1. [Overview](#overview) 8 + 2. [Requirements](#requirements) 9 + 3. [Architecture](#architecture) 10 + 4. [File Structure](#file-structure) 11 + 5. [Authentication](#authentication) 12 + 6. [Session Management](#session-management) 13 + 7. [Route Structure](#route-structure) 14 + 8. [Feature Implementations](#feature-implementations) 15 + 9. [Templates](#templates) 16 + 10. [Environment Variables](#environment-variables) 17 + 11. [Security Considerations](#security-considerations) 18 + 12. [Implementation Phases](#implementation-phases) 19 + 13. [Testing Strategy](#testing-strategy) 20 + 21 + --- 22 + 23 + ## Overview 24 + 25 + The hold admin panel provides a web-based interface for hold owners to: 26 + 27 + - **Manage crew members**: Add, remove, edit permissions and quota tiers 28 + - **Configure hold settings**: Toggle public access, open registration, Bluesky posting 29 + - **View usage metrics**: Storage usage per user, top users, repository statistics 30 + - **Monitor quota utilization**: Track tier distribution and usage percentages 31 + 32 + The admin panel is owner-only - only the DID that matches `captain.Owner` can access it. 33 + 34 + --- 35 + 36 + ## Requirements 37 + 38 + ### Functional Requirements 39 + 40 + 1. **Crew Management** 41 + - List all crew members with their DID, role, permissions, tier, and storage usage 42 + - Add new crew members with specified permissions and tier 43 + - Edit existing crew member permissions and tier 44 + - Remove crew members (with confirmation) 45 + - Display each crew member's quota utilization percentage 46 + 47 + 2. **Quota/Tier Management** 48 + - Display available tiers from `quotas.yaml` 49 + - Show tier limits and descriptions 50 + - Allow changing crew member tiers 51 + - Display current vs limit usage for each user 52 + 53 + 3. **Usage Metrics** 54 + - Total storage used across all users 55 + - Total unique blobs (deduplicated) 56 + - Number of crew members 57 + - Top 10/50/100 users by storage consumption 58 + - Per-repository statistics (pulls, pushes) 59 + 60 + 4. **Hold Settings** 61 + - Toggle `public` (allow anonymous blob reads) 62 + - Toggle `allowAllCrew` (allow any authenticated user to join) 63 + - Toggle `enableBlueskyPosts` (post to Bluesky on image push) 64 + 65 + ### Non-Functional Requirements 66 + 67 + - **Single binary**: Embedded in hold service, no separate deployment 68 + - **Responsive UI**: Works on desktop and mobile browsers 69 + - **Low latency**: Dashboard loads in <500ms for typical data volumes 70 + - **Minimal dependencies**: Uses Go templates, HTMX for interactivity 71 + 72 + --- 73 + 74 + ## Architecture 75 + 76 + ### High-Level Design 77 + 78 + ``` 79 + ┌─────────────────────────────────────────────────────────┐ 80 + │ Hold Service │ 81 + ├─────────────────────────────────────────────────────────┤ 82 + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ 83 + │ │ XRPC/PDS │ │ OCI XRPC │ │ Admin Panel │ │ 84 + │ │ Handlers │ │ Handlers │ │ Handlers │ │ 85 + │ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │ 86 + │ │ │ │ │ 87 + │ ┌──────┴────────────────┴───────────────────┴────────┐ │ 88 + │ │ Chi Router │ │ 89 + │ └─────────────────────────────────────────────────────┘ │ 90 + │ │ │ 91 + │ ┌────────────────────────┴─────────────────────────┐ │ 92 + │ │ Embedded PDS │ │ 93 + │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ 94 + │ │ │ Captain │ │ Crew │ │ Layer │ │ │ 95 + │ │ │ Records │ │ Records │ │ Records │ │ │ 96 + │ │ └──────────┘ └──────────┘ └──────────┘ │ │ 97 + │ └───────────────────────────────────────────────────┘ │ 98 + └─────────────────────────────────────────────────────────┘ 99 + ``` 100 + 101 + ### Components 102 + 103 + 1. **AdminUI** - Main struct containing all admin dependencies 104 + 2. **Session Store** - SQLite-backed session management (separate from carstore) 105 + 3. **OAuth Client** - Reuses `pkg/auth/oauth/` for browser-based login 106 + 4. **Auth Middleware** - Validates owner-only access 107 + 5. **Handlers** - HTTP handlers for each admin page 108 + 6. **Templates** - Go html/template with embed.FS 109 + 110 + --- 111 + 112 + ## File Structure 113 + 114 + ``` 115 + pkg/hold/admin/ 116 + ├── admin.go # Main struct, initialization, route registration 117 + ├── auth.go # requireOwner middleware, session validation 118 + ├── handlers.go # HTTP handlers for all admin pages 119 + ├── session.go # SQLite session store implementation 120 + ├── metrics.go # Metrics collection and aggregation 121 + ├── templates/ 122 + │ ├── base.html # Base layout (html, head, body wrapper) 123 + │ ├── components/ 124 + │ │ ├── head.html # CSS/JS includes (HTMX, Lucide icons) 125 + │ │ ├── nav.html # Admin navigation bar 126 + │ │ └── flash.html # Flash message component 127 + │ ├── pages/ 128 + │ │ ├── login.html # OAuth login page 129 + │ │ ├── dashboard.html # Metrics overview 130 + │ │ ├── crew.html # Crew list with management actions 131 + │ │ ├── crew_add.html # Add crew member form 132 + │ │ ├── crew_edit.html # Edit crew member form 133 + │ │ └── settings.html # Hold settings page 134 + │ └── partials/ 135 + │ ├── crew_row.html # Single crew row (for HTMX updates) 136 + │ ├── usage_stats.html # Usage stats partial 137 + │ └── top_users.html # Top users table partial 138 + └── static/ 139 + ├── css/ 140 + │ └── admin.css # Admin-specific styles 141 + └── js/ 142 + └── admin.js # Admin-specific JavaScript (if needed) 143 + ``` 144 + 145 + ### Files to Modify 146 + 147 + | File | Changes | 148 + |------|---------| 149 + | `cmd/hold/main.go` | Add admin UI initialization and route registration | 150 + | `pkg/hold/config.go` | Add `Admin.Enabled` and `Admin.SessionDuration` fields | 151 + | `.env.hold.example` | Document `HOLD_ADMIN_ENABLED`, `HOLD_ADMIN_SESSION_DURATION` | 152 + 153 + --- 154 + 155 + ## Authentication 156 + 157 + ### OAuth Flow for Admin Login 158 + 159 + The admin panel uses ATProto OAuth with DPoP for browser-based authentication: 160 + 161 + ``` 162 + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ 163 + │ Browser │ │ Hold │ │ PDS │ │ Owner │ 164 + │ │ │ Admin │ │ │ │ │ 165 + └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ 166 + │ │ │ │ 167 + │ GET /admin │ │ │ 168 + │───────────────>│ │ │ 169 + │ │ │ │ 170 + │ 302 /admin/auth/login │ │ 171 + │<───────────────│ │ │ 172 + │ │ │ │ 173 + │ GET /admin/auth/login │ │ 174 + │───────────────>│ │ │ 175 + │ │ │ │ 176 + │ Login page (enter handle) │ │ 177 + │<───────────────│ │ │ 178 + │ │ │ │ 179 + │ POST handle │ │ │ 180 + │───────────────>│ │ │ 181 + │ │ │ │ 182 + │ │ StartAuthFlow │ │ 183 + │ │───────────────>│ │ 184 + │ │ │ │ 185 + │ 302 to PDS auth URL │ │ 186 + │<───────────────│ │ │ 187 + │ │ │ │ 188 + │ Authorize in browser │ │ 189 + │────────────────────────────────>│ │ 190 + │ │ │ Approve? │ 191 + │ │ │───────────────>│ 192 + │ │ │ │ 193 + │ │ │ Yes │ 194 + │ │ │<───────────────│ 195 + │ │ │ │ 196 + │ 302 callback with code │ │ 197 + │<────────────────────────────────│ │ 198 + │ │ │ │ 199 + │ GET /admin/auth/oauth/callback │ │ 200 + │───────────────>│ │ │ 201 + │ │ │ │ 202 + │ │ ProcessCallback│ │ 203 + │ │───────────────>│ │ 204 + │ │ │ │ 205 + │ │ OAuth tokens │ │ 206 + │ │<───────────────│ │ 207 + │ │ │ │ 208 + │ │ Check: DID == captain.Owner? │ 209 + │ │─────────────────────────────────│ 210 + │ │ │ │ 211 + │ │ YES: Create session │ 212 + │ │ │ │ 213 + │ 302 /admin + session cookie │ │ 214 + │<───────────────│ │ │ 215 + │ │ │ │ 216 + │ GET /admin (with cookie) │ │ 217 + │───────────────>│ │ │ 218 + │ │ │ │ 219 + │ Dashboard │ │ │ 220 + │<───────────────│ │ │ 221 + ``` 222 + 223 + ### Owner Validation 224 + 225 + The callback handler performs owner validation: 226 + 227 + ```go 228 + func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { 229 + // Process OAuth callback 230 + sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query()) 231 + if err != nil { 232 + ui.renderError(w, "OAuth failed: " + err.Error()) 233 + return 234 + } 235 + 236 + did := sessionData.AccountDID.String() 237 + 238 + // Get captain record to check owner 239 + _, captain, err := ui.pds.GetCaptainRecord(r.Context()) 240 + if err != nil { 241 + ui.renderError(w, "Failed to verify ownership") 242 + return 243 + } 244 + 245 + // CRITICAL: Only allow the hold owner 246 + if did != captain.Owner { 247 + slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner) 248 + ui.renderError(w, "Access denied: Only the hold owner can access the admin panel") 249 + return 250 + } 251 + 252 + // Create admin session 253 + sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour) 254 + if err != nil { 255 + ui.renderError(w, "Failed to create session") 256 + return 257 + } 258 + 259 + // Set session cookie 260 + http.SetCookie(w, &http.Cookie{ 261 + Name: "hold_admin_session", 262 + Value: sessionID, 263 + Path: "/admin", 264 + MaxAge: 86400, // 24 hours 265 + HttpOnly: true, 266 + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", 267 + SameSite: http.SameSiteLaxMode, 268 + }) 269 + 270 + http.Redirect(w, r, "/admin", http.StatusFound) 271 + } 272 + ``` 273 + 274 + ### Auth Middleware 275 + 276 + ```go 277 + // requireOwner ensures the request is from the hold owner 278 + func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { 279 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 280 + // Get session cookie 281 + cookie, err := r.Cookie("hold_admin_session") 282 + if err != nil { 283 + http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound) 284 + return 285 + } 286 + 287 + // Validate session 288 + session, err := ui.sessionStore.Get(cookie.Value) 289 + if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) { 290 + // Clear invalid cookie 291 + http.SetCookie(w, &http.Cookie{ 292 + Name: "hold_admin_session", 293 + Value: "", 294 + Path: "/admin", 295 + MaxAge: -1, 296 + }) 297 + http.Redirect(w, r, "/admin/auth/login", http.StatusFound) 298 + return 299 + } 300 + 301 + // Double-check DID still matches captain.Owner 302 + // (in case ownership transferred while session active) 303 + _, captain, err := ui.pds.GetCaptainRecord(r.Context()) 304 + if err != nil || session.DID != captain.Owner { 305 + ui.sessionStore.Delete(cookie.Value) 306 + http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden) 307 + return 308 + } 309 + 310 + // Add session to context for handlers 311 + ctx := context.WithValue(r.Context(), adminSessionKey, session) 312 + next.ServeHTTP(w, r.WithContext(ctx)) 313 + }) 314 + } 315 + ``` 316 + 317 + --- 318 + 319 + ## Session Management 320 + 321 + ### Session Store Schema 322 + 323 + ```sql 324 + -- Admin sessions (browser login state) 325 + CREATE TABLE IF NOT EXISTS admin_sessions ( 326 + id TEXT PRIMARY KEY, 327 + did TEXT NOT NULL, 328 + handle TEXT, 329 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 330 + expires_at DATETIME NOT NULL, 331 + last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 332 + ); 333 + 334 + -- Index for cleanup queries 335 + CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at); 336 + CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did); 337 + 338 + -- OAuth sessions (indigo library storage) 339 + CREATE TABLE IF NOT EXISTS admin_oauth_sessions ( 340 + session_id TEXT PRIMARY KEY, 341 + did TEXT NOT NULL, 342 + data BLOB NOT NULL, 343 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 344 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 345 + ); 346 + ``` 347 + 348 + ### Session Store Interface 349 + 350 + ```go 351 + // AdminSession represents an authenticated admin session 352 + type AdminSession struct { 353 + ID string 354 + DID string 355 + Handle string 356 + CreatedAt time.Time 357 + ExpiresAt time.Time 358 + LastAccessed time.Time 359 + } 360 + 361 + // AdminSessionStore manages admin sessions 362 + type AdminSessionStore struct { 363 + db *sql.DB 364 + } 365 + 366 + func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error) 367 + 368 + func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error) 369 + func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error) 370 + func (s *AdminSessionStore) Delete(sessionID string) error 371 + func (s *AdminSessionStore) DeleteForDID(did string) error 372 + func (s *AdminSessionStore) Cleanup() error // Remove expired sessions 373 + func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed 374 + ``` 375 + 376 + ### Database Location 377 + 378 + The admin database should be in the same directory as the carstore database: 379 + 380 + ```go 381 + adminDBPath := filepath.Join(cfg.Database.Path, "admin.db") 382 + ``` 383 + 384 + This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions. 385 + 386 + --- 387 + 388 + ## Route Structure 389 + 390 + ### Complete Route Table 391 + 392 + | Route | Method | Auth | Handler | Description | 393 + |-------|--------|------|---------|-------------| 394 + | `/admin` | GET | Owner | `DashboardHandler` | Main dashboard with metrics | 395 + | `/admin/crew` | GET | Owner | `CrewListHandler` | List all crew members | 396 + | `/admin/crew/add` | GET | Owner | `CrewAddFormHandler` | Add crew form | 397 + | `/admin/crew/add` | POST | Owner | `CrewAddHandler` | Process add crew | 398 + | `/admin/crew/{rkey}` | GET | Owner | `CrewEditFormHandler` | Edit crew form | 399 + | `/admin/crew/{rkey}/update` | POST | Owner | `CrewUpdateHandler` | Process crew update | 400 + | `/admin/crew/{rkey}/delete` | POST | Owner | `CrewDeleteHandler` | Delete crew member | 401 + | `/admin/settings` | GET | Owner | `SettingsHandler` | Hold settings page | 402 + | `/admin/settings/update` | POST | Owner | `SettingsUpdateHandler` | Update settings | 403 + | `/admin/api/stats` | GET | Owner | `StatsAPIHandler` | JSON stats endpoint | 404 + | `/admin/api/top-users` | GET | Owner | `TopUsersAPIHandler` | JSON top users | 405 + | `/admin/auth/login` | GET | Public | `LoginHandler` | Login page | 406 + | `/admin/auth/oauth/authorize` | GET | Public | OAuth authorize | Start OAuth flow | 407 + | `/admin/auth/oauth/callback` | GET | Public | `CallbackHandler` | OAuth callback | 408 + | `/admin/auth/logout` | GET | Owner | `LogoutHandler` | Logout and clear session | 409 + | `/admin/static/*` | GET | Public | Static files | CSS, JS assets | 410 + 411 + ### Route Registration 412 + 413 + ```go 414 + func (ui *AdminUI) RegisterRoutes(r chi.Router) { 415 + // Public routes (login flow) 416 + r.Get("/admin/auth/login", ui.handleLogin) 417 + r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) 418 + r.Get("/admin/auth/oauth/callback", ui.handleCallback) 419 + 420 + // Static files (public) 421 + r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", ui.staticHandler())) 422 + 423 + // Protected routes (require owner) 424 + r.Group(func(r chi.Router) { 425 + r.Use(ui.requireOwner) 426 + 427 + // Dashboard 428 + r.Get("/admin", ui.handleDashboard) 429 + 430 + // Crew management 431 + r.Get("/admin/crew", ui.handleCrewList) 432 + r.Get("/admin/crew/add", ui.handleCrewAddForm) 433 + r.Post("/admin/crew/add", ui.handleCrewAdd) 434 + r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm) 435 + r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate) 436 + r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete) 437 + 438 + // Settings 439 + r.Get("/admin/settings", ui.handleSettings) 440 + r.Post("/admin/settings/update", ui.handleSettingsUpdate) 441 + 442 + // API endpoints (for HTMX) 443 + r.Get("/admin/api/stats", ui.handleStatsAPI) 444 + r.Get("/admin/api/top-users", ui.handleTopUsersAPI) 445 + 446 + // Logout 447 + r.Get("/admin/auth/logout", ui.handleLogout) 448 + }) 449 + } 450 + ``` 451 + 452 + --- 453 + 454 + ## Feature Implementations 455 + 456 + ### Dashboard Handler 457 + 458 + ```go 459 + type DashboardStats struct { 460 + TotalCrewMembers int 461 + TotalBlobs int64 462 + TotalStorageBytes int64 463 + TotalStorageHuman string 464 + TierDistribution map[string]int // tier -> count 465 + RecentActivity []ActivityEntry 466 + } 467 + 468 + func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) { 469 + ctx := r.Context() 470 + 471 + // Collect basic stats 472 + crew, _ := ui.pds.ListCrewMembers(ctx) 473 + 474 + stats := DashboardStats{ 475 + TotalCrewMembers: len(crew), 476 + TierDistribution: make(map[string]int), 477 + } 478 + 479 + // Count tier distribution 480 + for _, member := range crew { 481 + tier := member.Tier 482 + if tier == "" { 483 + tier = ui.quotaMgr.GetDefaultTier() 484 + } 485 + stats.TierDistribution[tier]++ 486 + } 487 + 488 + // Storage stats (loaded via HTMX to avoid slow initial load) 489 + // The actual calculation happens in handleStatsAPI 490 + 491 + data := struct { 492 + AdminPageData 493 + Stats DashboardStats 494 + }{ 495 + AdminPageData: ui.newPageData(r), 496 + Stats: stats, 497 + } 498 + 499 + ui.templates.ExecuteTemplate(w, "dashboard", data) 500 + } 501 + ``` 502 + 503 + ### Crew List Handler 504 + 505 + ```go 506 + type CrewMemberView struct { 507 + RKey string 508 + DID string 509 + Handle string // Resolved from DID 510 + Role string 511 + Permissions []string 512 + Tier string 513 + TierLimit string // Human-readable 514 + CurrentUsage int64 515 + UsageHuman string 516 + UsagePercent int 517 + Plankowner bool 518 + AddedAt time.Time 519 + } 520 + 521 + func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) { 522 + ctx := r.Context() 523 + 524 + crew, err := ui.pds.ListCrewMembers(ctx) 525 + if err != nil { 526 + ui.renderError(w, "Failed to list crew: "+err.Error()) 527 + return 528 + } 529 + 530 + // Enrich with usage data 531 + var crewViews []CrewMemberView 532 + for _, member := range crew { 533 + view := CrewMemberView{ 534 + RKey: member.RKey, 535 + DID: member.Member, 536 + Role: member.Role, 537 + Permissions: member.Permissions, 538 + Tier: member.Tier, 539 + Plankowner: member.Plankowner, 540 + AddedAt: member.AddedAt, 541 + } 542 + 543 + // Get tier limit 544 + if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil { 545 + view.TierLimit = quota.FormatHumanBytes(*limit) 546 + } else { 547 + view.TierLimit = "Unlimited" 548 + } 549 + 550 + // Get usage (expensive - consider caching) 551 + usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr) 552 + view.CurrentUsage = usage 553 + view.UsageHuman = quota.FormatHumanBytes(usage) 554 + if limit != nil && *limit > 0 { 555 + view.UsagePercent = int(float64(usage) / float64(*limit) * 100) 556 + } 557 + 558 + crewViews = append(crewViews, view) 559 + } 560 + 561 + // Sort by usage (highest first) 562 + sort.Slice(crewViews, func(i, j int) bool { 563 + return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage 564 + }) 565 + 566 + data := struct { 567 + AdminPageData 568 + Crew []CrewMemberView 569 + Tiers []TierOption 570 + }{ 571 + AdminPageData: ui.newPageData(r), 572 + Crew: crewViews, 573 + Tiers: ui.getTierOptions(), 574 + } 575 + 576 + ui.templates.ExecuteTemplate(w, "crew", data) 577 + } 578 + ``` 579 + 580 + ### Add Crew Handler 581 + 582 + ```go 583 + func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) { 584 + ctx := r.Context() 585 + 586 + if err := r.ParseForm(); err != nil { 587 + ui.setFlash(w, "error", "Invalid form data") 588 + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) 589 + return 590 + } 591 + 592 + did := strings.TrimSpace(r.FormValue("did")) 593 + role := r.FormValue("role") 594 + tier := r.FormValue("tier") 595 + 596 + // Parse permissions checkboxes 597 + var permissions []string 598 + if r.FormValue("perm_read") == "on" { 599 + permissions = append(permissions, "blob:read") 600 + } 601 + if r.FormValue("perm_write") == "on" { 602 + permissions = append(permissions, "blob:write") 603 + } 604 + if r.FormValue("perm_admin") == "on" { 605 + permissions = append(permissions, "crew:admin") 606 + } 607 + 608 + // Validate DID format 609 + if !strings.HasPrefix(did, "did:") { 610 + ui.setFlash(w, "error", "Invalid DID format") 611 + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) 612 + return 613 + } 614 + 615 + // Add crew member 616 + _, err := ui.pds.AddCrewMember(ctx, did, role, permissions) 617 + if err != nil { 618 + ui.setFlash(w, "error", "Failed to add crew member: "+err.Error()) 619 + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) 620 + return 621 + } 622 + 623 + // Set tier if specified 624 + if tier != "" && tier != ui.quotaMgr.GetDefaultTier() { 625 + if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil { 626 + slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err) 627 + } 628 + } 629 + 630 + ui.setFlash(w, "success", "Crew member added successfully") 631 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 632 + } 633 + ``` 634 + 635 + ### Update Crew Handler 636 + 637 + ```go 638 + func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { 639 + ctx := r.Context() 640 + rkey := chi.URLParam(r, "rkey") 641 + 642 + if err := r.ParseForm(); err != nil { 643 + ui.setFlash(w, "error", "Invalid form data") 644 + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 645 + return 646 + } 647 + 648 + // Get current crew member 649 + current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) 650 + if err != nil { 651 + ui.setFlash(w, "error", "Crew member not found") 652 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 653 + return 654 + } 655 + 656 + // Parse new values 657 + role := r.FormValue("role") 658 + tier := r.FormValue("tier") 659 + 660 + var permissions []string 661 + if r.FormValue("perm_read") == "on" { 662 + permissions = append(permissions, "blob:read") 663 + } 664 + if r.FormValue("perm_write") == "on" { 665 + permissions = append(permissions, "blob:write") 666 + } 667 + if r.FormValue("perm_admin") == "on" { 668 + permissions = append(permissions, "crew:admin") 669 + } 670 + 671 + // Update tier if changed 672 + if tier != current.Tier { 673 + if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { 674 + ui.setFlash(w, "error", "Failed to update tier: "+err.Error()) 675 + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 676 + return 677 + } 678 + } 679 + 680 + // For role/permissions changes, need to delete and recreate 681 + // (ATProto records are immutable, updates require delete+create) 682 + if role != current.Role || !slicesEqual(permissions, current.Permissions) { 683 + // Delete old record 684 + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { 685 + ui.setFlash(w, "error", "Failed to update: "+err.Error()) 686 + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 687 + return 688 + } 689 + 690 + // Create new record with updated values 691 + if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil { 692 + ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error()) 693 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 694 + return 695 + } 696 + 697 + // Re-apply tier to new record 698 + if tier != "" { 699 + ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier) 700 + } 701 + } 702 + 703 + ui.setFlash(w, "success", "Crew member updated successfully") 704 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 705 + } 706 + ``` 707 + 708 + ### Delete Crew Handler 709 + 710 + ```go 711 + func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) { 712 + ctx := r.Context() 713 + rkey := chi.URLParam(r, "rkey") 714 + 715 + // Get crew member to log who was deleted 716 + member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) 717 + if err != nil { 718 + ui.setFlash(w, "error", "Crew member not found") 719 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 720 + return 721 + } 722 + 723 + // Prevent deleting self (captain) 724 + session := getAdminSession(ctx) 725 + if member.Member == session.DID { 726 + ui.setFlash(w, "error", "Cannot remove yourself from crew") 727 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 728 + return 729 + } 730 + 731 + // Delete 732 + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { 733 + ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error()) 734 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 735 + return 736 + } 737 + 738 + slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID) 739 + 740 + // For HTMX requests, return empty response (row will be removed) 741 + if r.Header.Get("HX-Request") == "true" { 742 + w.WriteHeader(http.StatusOK) 743 + return 744 + } 745 + 746 + ui.setFlash(w, "success", "Crew member removed") 747 + http.Redirect(w, r, "/admin/crew", http.StatusFound) 748 + } 749 + ``` 750 + 751 + ### Settings Handler 752 + 753 + ```go 754 + func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) { 755 + ctx := r.Context() 756 + 757 + _, captain, err := ui.pds.GetCaptainRecord(ctx) 758 + if err != nil { 759 + ui.renderError(w, "Failed to load settings: "+err.Error()) 760 + return 761 + } 762 + 763 + data := struct { 764 + AdminPageData 765 + Settings struct { 766 + Public bool 767 + AllowAllCrew bool 768 + EnableBlueskyPosts bool 769 + OwnerDID string 770 + HoldDID string 771 + } 772 + }{ 773 + AdminPageData: ui.newPageData(r), 774 + } 775 + data.Settings.Public = captain.Public 776 + data.Settings.AllowAllCrew = captain.AllowAllCrew 777 + data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts 778 + data.Settings.OwnerDID = captain.Owner 779 + data.Settings.HoldDID = ui.pds.DID() 780 + 781 + ui.templates.ExecuteTemplate(w, "settings", data) 782 + } 783 + 784 + func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { 785 + ctx := r.Context() 786 + 787 + if err := r.ParseForm(); err != nil { 788 + ui.setFlash(w, "error", "Invalid form data") 789 + http.Redirect(w, r, "/admin/settings", http.StatusFound) 790 + return 791 + } 792 + 793 + public := r.FormValue("public") == "on" 794 + allowAllCrew := r.FormValue("allow_all_crew") == "on" 795 + enablePosts := r.FormValue("enable_bluesky_posts") == "on" 796 + 797 + _, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts) 798 + if err != nil { 799 + ui.setFlash(w, "error", "Failed to update settings: "+err.Error()) 800 + http.Redirect(w, r, "/admin/settings", http.StatusFound) 801 + return 802 + } 803 + 804 + ui.setFlash(w, "success", "Settings updated successfully") 805 + http.Redirect(w, r, "/admin/settings", http.StatusFound) 806 + } 807 + ``` 808 + 809 + ### Metrics Handler (for HTMX lazy loading) 810 + 811 + ```go 812 + func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) { 813 + ctx := r.Context() 814 + 815 + // Calculate total storage (expensive operation) 816 + // Iterate through all layer records 817 + records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true) 818 + if err != nil { 819 + http.Error(w, "Failed to load stats", http.StatusInternalServerError) 820 + return 821 + } 822 + 823 + var totalSize int64 824 + uniqueDigests := make(map[string]bool) 825 + userUsage := make(map[string]int64) 826 + 827 + for _, record := range records { 828 + var layer atproto.LayerRecord 829 + if err := json.Unmarshal(record.Value, &layer); err != nil { 830 + continue 831 + } 832 + 833 + if !uniqueDigests[layer.Digest] { 834 + uniqueDigests[layer.Digest] = true 835 + totalSize += layer.Size 836 + } 837 + 838 + userUsage[layer.UserDID] += layer.Size 839 + } 840 + 841 + stats := struct { 842 + TotalBlobs int `json:"totalBlobs"` 843 + TotalSize int64 `json:"totalSize"` 844 + TotalHuman string `json:"totalHuman"` 845 + }{ 846 + TotalBlobs: len(uniqueDigests), 847 + TotalSize: totalSize, 848 + TotalHuman: quota.FormatHumanBytes(totalSize), 849 + } 850 + 851 + // If HTMX request, return HTML partial 852 + if r.Header.Get("HX-Request") == "true" { 853 + data := struct { 854 + Stats interface{} 855 + }{Stats: stats} 856 + ui.templates.ExecuteTemplate(w, "usage_stats", data) 857 + return 858 + } 859 + 860 + // Otherwise return JSON 861 + w.Header().Set("Content-Type", "application/json") 862 + json.NewEncoder(w).Encode(stats) 863 + } 864 + ``` 865 + 866 + --- 867 + 868 + ## Templates 869 + 870 + ### Base Layout (templates/base.html) 871 + 872 + ```html 873 + {{ define "base" }} 874 + <!DOCTYPE html> 875 + <html lang="en"> 876 + <head> 877 + <meta charset="UTF-8"> 878 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 879 + <title>{{ .Title }} - Hold Admin</title> 880 + {{ template "head" . }} 881 + </head> 882 + <body> 883 + {{ template "nav" . }} 884 + 885 + <main class="admin-container"> 886 + {{ template "flash" . }} 887 + {{ template "content" . }} 888 + </main> 889 + 890 + <footer class="admin-footer"> 891 + <p>Hold: {{ .HoldDID }}</p> 892 + </footer> 893 + </body> 894 + </html> 895 + {{ end }} 896 + ``` 897 + 898 + ### Head Component (templates/components/head.html) 899 + 900 + ```html 901 + {{ define "head" }} 902 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 903 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 904 + <script src="https://unpkg.com/lucide@latest"></script> 905 + {{ end }} 906 + ``` 907 + 908 + ### Navigation (templates/components/nav.html) 909 + 910 + ```html 911 + {{ define "nav" }} 912 + <nav class="admin-nav"> 913 + <div class="nav-brand"> 914 + <a href="/admin">Hold Admin</a> 915 + </div> 916 + <ul class="nav-links"> 917 + <li><a href="/admin" class="{{ if eq .ActivePage "dashboard" }}active{{ end }}">Dashboard</a></li> 918 + <li><a href="/admin/crew" class="{{ if eq .ActivePage "crew" }}active{{ end }}">Crew</a></li> 919 + <li><a href="/admin/settings" class="{{ if eq .ActivePage "settings" }}active{{ end }}">Settings</a></li> 920 + </ul> 921 + <div class="nav-user"> 922 + <span>{{ .User.Handle }}</span> 923 + <a href="/admin/auth/logout">Logout</a> 924 + </div> 925 + </nav> 926 + {{ end }} 927 + ``` 928 + 929 + ### Dashboard Page (templates/pages/dashboard.html) 930 + 931 + ```html 932 + {{ define "dashboard" }} 933 + {{ template "base" . }} 934 + {{ define "content" }} 935 + <h1>Dashboard</h1> 936 + 937 + <div class="stats-grid"> 938 + <div class="stat-card"> 939 + <h3>Crew Members</h3> 940 + <p class="stat-value">{{ .Stats.TotalCrewMembers }}</p> 941 + </div> 942 + 943 + <div class="stat-card" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML"> 944 + <p>Loading storage stats...</p> 945 + </div> 946 + </div> 947 + 948 + <section class="dashboard-section"> 949 + <h2>Tier Distribution</h2> 950 + <div class="tier-chart"> 951 + {{ range $tier, $count := .Stats.TierDistribution }} 952 + <div class="tier-bar"> 953 + <span class="tier-name">{{ $tier }}</span> 954 + <span class="tier-count">{{ $count }}</span> 955 + </div> 956 + {{ end }} 957 + </div> 958 + </section> 959 + 960 + <section class="dashboard-section"> 961 + <h2>Top Users by Storage</h2> 962 + <div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML"> 963 + <p>Loading top users...</p> 964 + </div> 965 + </section> 966 + {{ end }} 967 + {{ end }} 968 + ``` 969 + 970 + ### Crew List Page (templates/pages/crew.html) 971 + 972 + ```html 973 + {{ define "crew" }} 974 + {{ template "base" . }} 975 + {{ define "content" }} 976 + <div class="page-header"> 977 + <h1>Crew Members</h1> 978 + <a href="/admin/crew/add" class="btn btn-primary">Add Crew Member</a> 979 + </div> 980 + 981 + <table class="data-table"> 982 + <thead> 983 + <tr> 984 + <th>DID</th> 985 + <th>Role</th> 986 + <th>Permissions</th> 987 + <th>Tier</th> 988 + <th>Usage</th> 989 + <th>Actions</th> 990 + </tr> 991 + </thead> 992 + <tbody id="crew-list"> 993 + {{ range .Crew }} 994 + {{ template "crew_row" . }} 995 + {{ end }} 996 + </tbody> 997 + </table> 998 + {{ end }} 999 + {{ end }} 1000 + ``` 1001 + 1002 + ### Crew Row Partial (templates/partials/crew_row.html) 1003 + 1004 + ```html 1005 + {{ define "crew_row" }} 1006 + <tr id="crew-{{ .RKey }}"> 1007 + <td> 1008 + <code title="{{ .DID }}">{{ .DID | truncate 20 }}</code> 1009 + {{ if .Plankowner }}<span class="badge badge-gold">Plankowner</span>{{ end }} 1010 + </td> 1011 + <td>{{ .Role }}</td> 1012 + <td> 1013 + {{ range .Permissions }} 1014 + <span class="badge badge-perm">{{ . }}</span> 1015 + {{ end }} 1016 + </td> 1017 + <td> 1018 + <span class="badge badge-tier tier-{{ .Tier }}">{{ .Tier }}</span> 1019 + <small>({{ .TierLimit }})</small> 1020 + </td> 1021 + <td> 1022 + <div class="usage-cell"> 1023 + <span>{{ .UsageHuman }}</span> 1024 + <div class="progress-bar"> 1025 + <div class="progress-fill {{ if gt .UsagePercent 90 }}danger{{ else if gt .UsagePercent 75 }}warning{{ end }}" 1026 + style="width: {{ .UsagePercent }}%"></div> 1027 + </div> 1028 + <small>{{ .UsagePercent }}%</small> 1029 + </div> 1030 + </td> 1031 + <td> 1032 + <a href="/admin/crew/{{ .RKey }}" class="btn btn-sm">Edit</a> 1033 + <button class="btn btn-sm btn-danger" 1034 + hx-post="/admin/crew/{{ .RKey }}/delete" 1035 + hx-confirm="Are you sure you want to remove this crew member?" 1036 + hx-target="#crew-{{ .RKey }}" 1037 + hx-swap="outerHTML"> 1038 + Delete 1039 + </button> 1040 + </td> 1041 + </tr> 1042 + {{ end }} 1043 + ``` 1044 + 1045 + ### Settings Page (templates/pages/settings.html) 1046 + 1047 + ```html 1048 + {{ define "settings" }} 1049 + {{ template "base" . }} 1050 + {{ define "content" }} 1051 + <h1>Hold Settings</h1> 1052 + 1053 + <form action="/admin/settings/update" method="POST" class="settings-form"> 1054 + <div class="setting-group"> 1055 + <h2>Access Control</h2> 1056 + 1057 + <label class="toggle-setting"> 1058 + <input type="checkbox" name="public" {{ if .Settings.Public }}checked{{ end }}> 1059 + <span class="toggle-label"> 1060 + <strong>Public Hold</strong> 1061 + <small>Allow anonymous users to read blobs (no auth required for pulls)</small> 1062 + </span> 1063 + </label> 1064 + 1065 + <label class="toggle-setting"> 1066 + <input type="checkbox" name="allow_all_crew" {{ if .Settings.AllowAllCrew }}checked{{ end }}> 1067 + <span class="toggle-label"> 1068 + <strong>Open Registration</strong> 1069 + <small>Allow any authenticated user to join as crew via requestCrew</small> 1070 + </span> 1071 + </label> 1072 + </div> 1073 + 1074 + <div class="setting-group"> 1075 + <h2>Integrations</h2> 1076 + 1077 + <label class="toggle-setting"> 1078 + <input type="checkbox" name="enable_bluesky_posts" {{ if .Settings.EnableBlueskyPosts }}checked{{ end }}> 1079 + <span class="toggle-label"> 1080 + <strong>Bluesky Posts</strong> 1081 + <small>Post to Bluesky when images are pushed to this hold</small> 1082 + </span> 1083 + </label> 1084 + </div> 1085 + 1086 + <div class="setting-group"> 1087 + <h2>Hold Information</h2> 1088 + <dl> 1089 + <dt>Hold DID</dt> 1090 + <dd><code>{{ .Settings.HoldDID }}</code></dd> 1091 + <dt>Owner DID</dt> 1092 + <dd><code>{{ .Settings.OwnerDID }}</code></dd> 1093 + </dl> 1094 + </div> 1095 + 1096 + <button type="submit" class="btn btn-primary">Save Settings</button> 1097 + </form> 1098 + {{ end }} 1099 + {{ end }} 1100 + ``` 1101 + 1102 + --- 1103 + 1104 + ## Environment Variables 1105 + 1106 + Add to `.env.hold.example`: 1107 + 1108 + ```bash 1109 + # ============================================================================= 1110 + # ADMIN PANEL CONFIGURATION 1111 + # ============================================================================= 1112 + 1113 + # Enable the admin web UI (default: false) 1114 + # When enabled, accessible at /admin 1115 + HOLD_ADMIN_ENABLED=false 1116 + 1117 + # Admin session duration (default: 24h) 1118 + # How long admin sessions remain valid before requiring re-authentication 1119 + # Format: Go duration string (e.g., 24h, 168h for 1 week) 1120 + HOLD_ADMIN_SESSION_DURATION=24h 1121 + ``` 1122 + 1123 + ### Config Struct Updates 1124 + 1125 + ```go 1126 + // In pkg/hold/config.go 1127 + 1128 + type Config struct { 1129 + // ... existing fields ... 1130 + 1131 + Admin AdminConfig 1132 + } 1133 + 1134 + type AdminConfig struct { 1135 + Enabled bool `env:"HOLD_ADMIN_ENABLED" envDefault:"false"` 1136 + SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"` 1137 + } 1138 + ``` 1139 + 1140 + --- 1141 + 1142 + ## Security Considerations 1143 + 1144 + ### 1. Owner-Only Access 1145 + 1146 + All admin routes validate that the authenticated user's DID matches `captain.Owner`. This check happens: 1147 + - In the OAuth callback (primary gate) 1148 + - In the `requireOwner` middleware (defense in depth) 1149 + - Before destructive operations (extra validation) 1150 + 1151 + ### 2. Cookie Security 1152 + 1153 + ```go 1154 + http.SetCookie(w, &http.Cookie{ 1155 + Name: "hold_admin_session", 1156 + Value: sessionID, 1157 + Path: "/admin", // Scoped to admin paths only 1158 + MaxAge: 86400, // 24 hours 1159 + HttpOnly: true, // No JavaScript access 1160 + Secure: isHTTPS(r), // HTTPS only in production 1161 + SameSite: http.SameSiteLaxMode, // CSRF protection 1162 + }) 1163 + ``` 1164 + 1165 + ### 3. CSRF Protection 1166 + 1167 + For state-changing operations: 1168 + - Forms include hidden CSRF token 1169 + - HTMX requests include token in header 1170 + - Server validates token before processing 1171 + 1172 + ```html 1173 + <form action="/admin/crew/add" method="POST"> 1174 + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> 1175 + ... 1176 + </form> 1177 + ``` 1178 + 1179 + ### 4. Input Validation 1180 + 1181 + - DID format validation before database operations 1182 + - Tier names validated against `quotas.yaml` 1183 + - Permission values validated against known set 1184 + - All user input sanitized before display 1185 + 1186 + ### 5. Rate Limiting 1187 + 1188 + Consider adding rate limiting for: 1189 + - Login attempts (prevent brute force) 1190 + - OAuth flow starts (prevent abuse) 1191 + - API endpoints (prevent DoS) 1192 + 1193 + ### 6. Audit Logging 1194 + 1195 + Log all administrative actions: 1196 + ```go 1197 + slog.Info("Admin action", 1198 + "action", "crew_add", 1199 + "admin_did", session.DID, 1200 + "target_did", newMemberDID, 1201 + "permissions", permissions) 1202 + ``` 1203 + 1204 + --- 1205 + 1206 + ## Implementation Phases 1207 + 1208 + ### Phase 1: Foundation (Est. 4-6 hours) 1209 + 1210 + 1. Create `pkg/hold/admin/` package structure 1211 + 2. Implement `AdminSessionStore` with SQLite 1212 + 3. Implement OAuth client setup (reuse `pkg/auth/oauth/`) 1213 + 4. Implement `requireOwner` middleware 1214 + 5. Create basic template loading with embed.FS 1215 + 6. Add env var configuration to `pkg/hold/config.go` 1216 + 1217 + **Deliverables:** 1218 + - Admin package compiles 1219 + - Can start OAuth flow 1220 + - Session store creates/validates sessions 1221 + 1222 + ### Phase 2: Authentication (Est. 3-4 hours) 1223 + 1224 + 1. Implement login page handler 1225 + 2. Implement OAuth authorize redirect 1226 + 3. Implement callback with owner validation 1227 + 4. Implement logout handler 1228 + 5. Wire up routes in `cmd/hold/main.go` 1229 + 1230 + **Deliverables:** 1231 + - Can login as hold owner 1232 + - Non-owners rejected at callback 1233 + - Sessions persist across requests 1234 + 1235 + ### Phase 3: Dashboard (Est. 3-4 hours) 1236 + 1237 + 1. Create base template and navigation 1238 + 2. Implement dashboard handler with basic stats 1239 + 3. Implement stats API for HTMX lazy loading 1240 + 4. Implement top users API 1241 + 5. Create dashboard template 1242 + 1243 + **Deliverables:** 1244 + - Dashboard shows crew count, tier distribution 1245 + - Storage stats load asynchronously 1246 + - Top users table displays 1247 + 1248 + ### Phase 4: Crew Management (Est. 4-6 hours) 1249 + 1250 + 1. Implement crew list handler 1251 + 2. Create crew list template with HTMX delete 1252 + 3. Implement add crew form and handler 1253 + 4. Implement edit crew form and handler 1254 + 5. Implement delete crew handler 1255 + 1256 + **Deliverables:** 1257 + - Full CRUD for crew members 1258 + - Tier and permission editing works 1259 + - HTMX updates without page reload 1260 + 1261 + ### Phase 5: Settings (Est. 2-3 hours) 1262 + 1263 + 1. Implement settings handler 1264 + 2. Create settings template 1265 + 3. Implement settings update handler 1266 + 1267 + **Deliverables:** 1268 + - Can toggle public/allowAllCrew/enableBlueskyPosts 1269 + - Settings persist correctly 1270 + 1271 + ### Phase 6: Polish (Est. 2-4 hours) 1272 + 1273 + 1. Add CSS styling 1274 + 2. Add flash messages 1275 + 3. Add CSRF protection 1276 + 4. Add input validation 1277 + 5. Add audit logging 1278 + 6. Update documentation 1279 + 1280 + **Deliverables:** 1281 + - Professional-looking UI 1282 + - Security hardening complete 1283 + - Documentation updated 1284 + 1285 + **Total Estimated Time: 18-27 hours** 1286 + 1287 + --- 1288 + 1289 + ## Testing Strategy 1290 + 1291 + ### Unit Tests 1292 + 1293 + ```go 1294 + // pkg/hold/admin/session_test.go 1295 + func TestSessionStore_Create(t *testing.T) { 1296 + store := newTestSessionStore(t) 1297 + 1298 + sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour) 1299 + require.NoError(t, err) 1300 + require.NotEmpty(t, sessionID) 1301 + 1302 + session, err := store.Get(sessionID) 1303 + require.NoError(t, err) 1304 + assert.Equal(t, "did:plc:test", session.DID) 1305 + } 1306 + 1307 + // pkg/hold/admin/auth_test.go 1308 + func TestRequireOwner_RejectsNonOwner(t *testing.T) { 1309 + pds := setupTestPDSWithOwner(t, "did:plc:owner") 1310 + store := newTestSessionStore(t) 1311 + 1312 + // Create session for non-owner 1313 + sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour) 1314 + 1315 + middleware := requireOwner(pds, store) 1316 + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1317 + w.WriteHeader(http.StatusOK) 1318 + })) 1319 + 1320 + req := httptest.NewRequest("GET", "/admin", nil) 1321 + req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID}) 1322 + w := httptest.NewRecorder() 1323 + 1324 + handler.ServeHTTP(w, req) 1325 + 1326 + assert.Equal(t, http.StatusForbidden, w.Code) 1327 + } 1328 + ``` 1329 + 1330 + ### Integration Tests 1331 + 1332 + ```go 1333 + // pkg/hold/admin/integration_test.go 1334 + func TestAdminLoginFlow(t *testing.T) { 1335 + // Start test hold server 1336 + server := startTestHoldWithAdmin(t) 1337 + defer server.Close() 1338 + 1339 + // Verify login page accessible 1340 + resp, _ := http.Get(server.URL + "/admin/auth/login") 1341 + assert.Equal(t, http.StatusOK, resp.StatusCode) 1342 + 1343 + // Verify dashboard redirects to login 1344 + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { 1345 + return http.ErrUseLastResponse 1346 + }} 1347 + resp, _ = client.Get(server.URL + "/admin") 1348 + assert.Equal(t, http.StatusFound, resp.StatusCode) 1349 + assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login") 1350 + } 1351 + ``` 1352 + 1353 + ### Manual Testing Checklist 1354 + 1355 + - [ ] Login as owner succeeds 1356 + - [ ] Login as non-owner fails with clear error 1357 + - [ ] Dashboard loads with correct stats 1358 + - [ ] Add crew member with all permission combinations 1359 + - [ ] Edit crew member permissions 1360 + - [ ] Change crew member tier 1361 + - [ ] Delete crew member 1362 + - [ ] Toggle public setting 1363 + - [ ] Toggle allowAllCrew setting 1364 + - [ ] Toggle enableBlueskyPosts setting 1365 + - [ ] Logout clears session 1366 + - [ ] Session expires after configured duration 1367 + - [ ] Expired session redirects to login 1368 + 1369 + --- 1370 + 1371 + ## Future Enhancements 1372 + 1373 + ### Potential Future Features 1374 + 1375 + 1. **Crew Invite Links** - Generate one-time invite URLs for adding crew 1376 + 2. **Usage Alerts** - Email/webhook when users approach quota 1377 + 3. **Bulk Operations** - Add/remove multiple crew members at once 1378 + 4. **Export Data** - Download crew list, usage reports as CSV 1379 + 5. **Activity Log** - View recent admin actions 1380 + 6. **API Keys** - Generate programmatic access keys for admin API 1381 + 7. **Backup/Restore** - Backup crew records, restore from backup 1382 + 8. **Multi-Hold Management** - Manage multiple holds from one UI (separate feature) 1383 + 1384 + ### Performance Optimizations 1385 + 1386 + 1. **Cache usage stats** - Don't recalculate on every request 1387 + 2. **Paginate crew list** - Handle holds with 1000+ crew members 1388 + 3. **Background stat refresh** - Update stats periodically in background 1389 + 4. **Batch DID resolution** - Resolve multiple DIDs in parallel 1390 + 1391 + --- 1392 + 1393 + ## References 1394 + 1395 + - [ATProto OAuth Specification](https://atproto.com/specs/oauth) 1396 + - [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) 1397 + - [HTMX Documentation](https://htmx.org/docs/) 1398 + - [Chi Router](https://github.com/go-chi/chi) 1399 + - [Go html/template](https://pkg.go.dev/html/template)
+10 -28
pkg/appview/db/oauth_store.go
··· 8 8 "log/slog" 9 9 "time" 10 10 11 + atoauth "atcr.io/pkg/auth/oauth" 11 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 13 14 ) ··· 283 284 continue 284 285 } 285 286 286 - // Check if scopes match (need to import oauth package for ScopesMatch) 287 - // Since we're in db package, we can't import oauth (circular dependency) 288 - // So we'll implement a simple scope comparison here 289 - if !scopesMatch(sessionData.Scopes, desiredScopes) { 287 + // Check if scopes match (expands include: scopes before comparing) 288 + if !atoauth.ScopesMatch(sessionData.Scopes, desiredScopes) { 289 + slog.Debug("Session has mismatched scopes", 290 + "component", "oauth/store", 291 + "session_key", sessionKey, 292 + "account_did", accountDID, 293 + "session_scopes", sessionData.Scopes, 294 + "desired_scopes", desiredScopes, 295 + ) 290 296 sessionsToDelete = append(sessionsToDelete, sessionKey) 291 297 } 292 298 } ··· 309 315 } 310 316 311 317 return len(sessionsToDelete), nil 312 - } 313 - 314 - // scopesMatch checks if two scope lists are equivalent (order-independent) 315 - // Local implementation to avoid circular dependency with oauth package 316 - func scopesMatch(stored, desired []string) bool { 317 - if len(stored) == 0 && len(desired) == 0 { 318 - return true 319 - } 320 - if len(stored) != len(desired) { 321 - return false 322 - } 323 - 324 - desiredMap := make(map[string]bool, len(desired)) 325 - for _, scope := range desired { 326 - desiredMap[scope] = true 327 - } 328 - 329 - for _, scope := range stored { 330 - if !desiredMap[scope] { 331 - return false 332 - } 333 - } 334 - 335 - return true 336 318 } 337 319 338 320 // GetSessionStats returns statistics about stored OAuth sessions
+16 -3
pkg/appview/db/oauth_store_test.go
··· 5 5 "testing" 6 6 "time" 7 7 8 + atcroauth "atcr.io/pkg/auth/oauth" 8 9 "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 10 11 ) ··· 161 162 } 162 163 163 164 func TestScopesMatch(t *testing.T) { 164 - // Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior 165 + // Test oauth.ScopesMatch function including include: scope expansion 165 166 tests := []struct { 166 167 name string 167 168 stored []string ··· 204 205 desired: []string{}, 205 206 expected: true, 206 207 }, 208 + { 209 + name: "include scope expansion", 210 + stored: []string{ 211 + "atproto", 212 + "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag", 213 + }, 214 + desired: []string{ 215 + "atproto", 216 + "include:io.atcr.authFullApp", 217 + }, 218 + expected: true, 219 + }, 207 220 } 208 221 209 222 for _, tt := range tests { 210 223 t.Run(tt.name, func(t *testing.T) { 211 - result := scopesMatch(tt.stored, tt.desired) 224 + result := atcroauth.ScopesMatch(tt.stored, tt.desired) 212 225 if result != tt.expected { 213 - t.Errorf("scopesMatch(%v, %v) = %v, want %v", 226 + t.Errorf("ScopesMatch(%v, %v) = %v, want %v", 214 227 tt.stored, tt.desired, result, tt.expected) 215 228 } 216 229 })
+42 -5
pkg/auth/oauth/client.go
··· 17 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 18 ) 19 19 20 + // permissionSetExpansions maps lexicon IDs to their expanded scope format. 21 + // These must match the collections defined in lexicons/io/atcr/authFullApp.json 22 + // Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes. 23 + var permissionSetExpansions = map[string]string{ 24 + "io.atcr.authFullApp": "repo?" + 25 + "collection=io.atcr.manifest&" + 26 + "collection=io.atcr.repo.page&" + 27 + "collection=io.atcr.sailor.profile&" + 28 + "collection=io.atcr.sailor.star&" + 29 + "collection=io.atcr.tag", 30 + } 31 + 32 + // ExpandIncludeScopes expands any "include:" prefixed scopes to their full form 33 + // by looking up the corresponding permission-set in the embedded lexicon files. 34 + // For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..." 35 + func ExpandIncludeScopes(scopes []string) []string { 36 + var expanded []string 37 + for _, scope := range scopes { 38 + if strings.HasPrefix(scope, "include:") { 39 + lexiconID := strings.TrimPrefix(scope, "include:") 40 + if exp, ok := permissionSetExpansions[lexiconID]; ok { 41 + expanded = append(expanded, exp) 42 + } else { 43 + expanded = append(expanded, scope) // Keep original if unknown 44 + } 45 + } else { 46 + expanded = append(expanded, scope) 47 + } 48 + } 49 + return expanded 50 + } 51 + 20 52 // NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration 21 53 // Automatically configures confidential client for production deployments 22 54 // keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) ··· 97 129 } 98 130 99 131 // ScopesMatch checks if two scope lists are equivalent (order-independent) 100 - // Returns true if both lists contain the same scopes, regardless of order 132 + // Returns true if both lists contain the same scopes, regardless of order. 133 + // Expands any "include:" prefixed scopes in the desired list before comparing, 134 + // since the PDS returns expanded scopes in the stored session. 101 135 func ScopesMatch(stored, desired []string) bool { 136 + // Expand any include: scopes in desired before comparing 137 + expandedDesired := ExpandIncludeScopes(desired) 138 + 102 139 // Handle nil/empty cases 103 - if len(stored) == 0 && len(desired) == 0 { 140 + if len(stored) == 0 && len(expandedDesired) == 0 { 104 141 return true 105 142 } 106 - if len(stored) != len(desired) { 143 + if len(stored) != len(expandedDesired) { 107 144 return false 108 145 } 109 146 110 147 // Build map of desired scopes for O(1) lookup 111 - desiredMap := make(map[string]bool, len(desired)) 112 - for _, scope := range desired { 148 + desiredMap := make(map[string]bool, len(expandedDesired)) 149 + for _, scope := range expandedDesired { 113 150 desiredMap[scope] = true 114 151 } 115 152