+25
.air.hold.toml
+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
+1
.gitignore
+6
-4
Dockerfile.dev
+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
+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
+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
+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
+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
+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
+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
+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