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

Configure Feed

Select the types of activity you want to include in your feed.

fix oauth scope mismatch

+1510 -50
+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